본문으로 건너뛰기

MCP Apps

MCP Apps는 MCP 서버가 일반 도구 결과와 함께 대화형 ui:// 리소스 위젯을 반환할 수 있도록 하는 비준된 MCP 확장입니다(2026-01-26 표준화). 모델이 원시 JSON을 받는 대신 서버가 리소스 URI를 포함하면, TanStack AI가 이를 가져와 UIResourcePart로 클라이언트에 스트리밍하며 완전한 대화형 iframe 위젯으로 렌더링할 수 있습니다.

MCP Apps 지원에는 다음 두 수준이 있습니다.

  • 정적 — MCP 도구 결과에 ui:// 리소스가 포함됩니다. TanStack AI는 chat() 실행 중 이를 읽고 어시스턴트 UIMessageUIResourcePart로 제공합니다. 추가 라우트는 필요하지 않으며 MCPAppResource로 렌더링합니다.
  • 대화형 — 위젯의 iframe이 도구 호출 또는 프롬프트 작업을 다시 전송합니다. 라우트에 서버 핸들러(createMcpAppCallHandler)를 마운트하고 클라이언트 브리지(createMcpAppBridge)를 연결하여 해당 작업이 올바른 MCP 서버에 도달하도록 합니다.

정적 위젯

MCP 도구의 결과에 ui:// 리소스가 포함되면 TanStack AI는 어시스턴트 UIMessageUIResourcePart를 생성합니다. 이 파트는 일반 ToolResultPart함께 메시지의 parts 배열에 추가되며 모델 입력에는 절대 들어가지 않습니다.

UIResourcePart 형태

import type { UIResourcePart } from '@tanstack/ai'

// Arrives on the assistant UIMessage alongside ToolCallPart / ToolResultPart:
// {
// type: 'ui-resource'
// resource: { uri: string; mimeType: string; text?: string; blob?: string }
// serverId?: string // pool prefix / config key — routes interactive calls
// toolCallId: string // links to the originating tool call
// toolName: string // MCP tool name whose UI this resource renders
// meta?: Record<string, unknown> // reserved — currently always undefined
// }

ui:// 리소스를 반환하는 MCP 서버를 연결하는 것 외에 서버 측 변경은 필요하지 않습니다. 채팅 실행 중 리소스를 즉시 읽습니다. 읽기에 실패해도(네트워크 오류 또는 리소스 누락) 도구 결과는 모델로 계속 전달되며 위젯만 표시되지 않습니다(fail-soft).

서버 라우트

// 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 } = await request.json()

const mcp = await createMCPClient({
transport: {
type: 'http',
url: process.env.MCP_URL!,
},
})

const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
mcp: { clients: [mcp] },
})

return toServerSentEventsResponse(stream)
},
},
},
})

React 클라이언트 — 정적 위젯 렌더링

선택적 peer dependency를 설치합니다.

pnpm add @mcp-ui/client

그런 다음 어시스턴트 메시지의 각 ui-resource 파트를 렌더링합니다.

sandbox란 무엇인가요? sandbox.url직접 호스팅하는 작은 정적 sandbox-proxy HTML 페이지를 가리킵니다(예: 자체 origin의 mcp-sandbox.html). AppRenderer는 격리된 iframe에서 이 페이지를 로드하고 그 안에 위젯을 렌더링합니다. 이 페이지가 보안 경계이므로 배포 시 결정되는 상수이며 모든 위젯에서 동일해야 합니다. 위젯의 주소는 아닙니다. 위젯의 식별자와 HTML은 메시지 파트(part.resource, ui://… 리소스)에서 가져옵니다. 프록시 페이지는 @mcp-ui/client를 참조하세요.

// src/components/Chat.tsx
import { useChat } from '@tanstack/ai-react'
import { fetchServerSentEvents } from '@tanstack/ai-client'
import { MCPAppResource } from '@tanstack/ai-react/mcp-apps'
import type { UIResourcePart } from '@tanstack/ai'

export function Chat() {
const { messages, sendMessage, status } = useChat({
connection: fetchServerSentEvents('/api/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}
// your hosted sandbox-proxy page (a host constant, not the widget URL)
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>
)
}

MCPAppResource는 내부적으로 @mcp-ui/clientAppRenderer를 사용합니다. bridge prop이 없으면 위젯은 표시 전용 모드로 렌더링되며 iframe 내부에서 도구 호출 또는 프롬프트를 트리거하는 사용자 상호작용은 무시됩니다.

프레임워크 지원: React와 Preact가 제공됩니다(@tanstack/ai-react/mcp-apps, @tanstack/ai-preact/mcp-apps). Preact에는 preact/compat 별칭이 필요합니다. Solid, Vue, Svelte, Angular 래퍼는 추후로 미뤄졌습니다. @mcp-ui/client v7의 AppRenderer는 React 전용이며 프레임워크에 구애받지 않는 렌더러 SDK는 향후 작업입니다.

대화형 위젯

도구를 호출하거나 모델에 프롬프트를 다시 보내야 하는 위젯에는 다음 두 가지를 추가로 연결합니다.

  1. 서버 — POST 라우트에 createMcpAppCallHandler를 마운트합니다. 위젯의 iframe이 이 라우트를 호출합니다.
  2. 클라이언트createMcpAppBridge를 만들고 MCPAppResource에 전달합니다. 브리지는 iframe의 작업(도구 호출, 프롬프트, 링크)을 올바른 핸들러로 라우팅합니다.

설치

pnpm add @tanstack/ai-mcp @tanstack/ai-client @mcp-ui/client

서버 — 호출 핸들러 라우트

@tanstack/ai-mcp/appscreateMcpAppCallHandler는 이미 생성한 MCP 클라이언트(단일 MCPClient, MCPClients 풀 또는 이들의 배열)를 받아 다음을 수행하는 요청 핸들러를 반환합니다.

  • 각 클라이언트의 전송 디스크립터를 client.getInfo() / pool.getServers()를 통해 확인합니다(순수 구성 — 활성 소켓은 필요하지 않음).
  • 해당 디스크립터를 사용해 호출마다 MCP 서버에 다시 연결합니다(상태 비저장, 기본적으로 서버리스에 안전).
  • 요청된 toolName이 해당 서버에 실제로 노출되는지 확인합니다(동일 서버 허용 목록).
  • 도구를 호출하고 { ok: true, result } 또는 { ok: false, error }를 반환합니다.
// 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'

// Reuse the same pool you pass to chat({ mcp: { clients: [mcp] } }).
const mcp = await createMCPClients({
weather: {
transport: {
type: 'http',
url: process.env.WEATHER_MCP_URL!,
headers: { Authorization: `Bearer ${process.env.WEATHER_MCP_TOKEN ?? ''}` },
},
},
})

// clients: a single MCPClient, an MCPClients pool, or an array of either.
// The handler reads each client's transport descriptor via getInfo()/getServers()
// and reconnects per call — works in long-lived servers and serverless alike.
const handler = createMcpAppCallHandler({ clients: mcp })

export const Route = createFileRoute('/api/mcp-apps/call')({
server: {
handlers: {
POST: async ({ request }) => {
const body = await request.json()
// body: { threadId, serverId, toolName, args?, messageId? }
const result = await handler(body)
return new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' },
})
},
},
},
})

link 작업에는 onLink 핸들러가 필요합니다. 위젯이 link 작업을 생성했는데 브리지에 onLink 핸들러가 연결되지 않은 경우 브리지는 링크를 삭제하고(경고 기록) openLink{ isError: true }를 반환합니다. 호출이 멈추지 않으며 위젯은 호스트 페이지에서 임의의 URL을 열 수 없습니다. 사용하려면 onLink를 명시적으로 전달합니다.

onLink 핸들러가 있어도 브리지는 http:, https:, mailto: URL만 전달합니다. 안전하지 않은 스킴(javascript:, data:, file:, …)은 핸들러가 실행되기 전에 항상 거부되므로 샌드박스 위젯이 스크립트를 실행하는 URL이나 로컬 리소스 URL을 몰래 전달할 수 없습니다.

동일 서버 허용 목록

createMcpAppCallHandler는 항상 toolName이 대상 서버가 실제로 노출하는 도구 목록에 포함되는지 확인합니다. 서버가 알지 못하는 도구에 대한 요청은 실행되지 않고 { ok: false, error: "Tool not allowed: <name>" }를 반환합니다. 이 서버 노출 검사는 무조건 적용되며 우회할 수 없습니다.

추가 제한을 적용하려면 allowTool 옵션을 사용합니다. 요청은 서버 노출 검사와 allowTool모두 충족해야 합니다. 이는 AND 조건이며 서버 검사를 대체하지 않습니다.

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,
// Additional restriction: even if the server exposes more tools,
// only allow this specific one through the call handler.
allowTool: (req) => req.toolName === 'place_order',
})

채팅 라우트 — serverId 연결

UIResourcePartserverId는 MCP 클라이언트에 지정한 prefix에서 가져옵니다. 두 곳에서 동일한 키를 사용합니다.

다중 서버 라우팅: 대화형 호출은 각 클라이언트의 prefixserverId를 기준으로 라우팅됩니다. createMCPClients는 기본적으로 모든 서버의 prefix를 구성 키로 설정하므로 별도 설정 없이 라우팅됩니다. 여러 서버를 전달하면서 하나의 prefix를 비활성화하면(prefix: '') 해당 서버에는 serverId가 없고 위젯이 대화형 호출을 수행할 수 없습니다. 각 대화형 서버에 서로 다른 prefix를 지정합니다(기본값을 사용해도 됩니다).

// 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()

// The pool key "weather" becomes the serverId on every UIResourcePart
// emitted by this server — must match the key used when constructing
// the pool passed to createMcpAppCallHandler.
const pool = await createMCPClients({
weather: {
transport: { type: 'http', url: process.env.WEATHER_MCP_URL! },
},
})

const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: body.messages,
mcp: { clients: [pool] },
})

return toServerSentEventsResponse(stream)
},
},
},
})

클라이언트 — 브리지 및 대화형 렌더링

@tanstack/ai-clientcreateMcpAppBridgeMCPAppResource에 전달하는 작업 핸들러를 반환합니다. 다음과 같이 라우팅합니다.

  • tool 작업 → 도구 호출 페이로드와 함께 callEndpoint로 POST합니다.
  • prompt 작업 → chat.sendMessage(prompt)를 호출합니다.
  • link 작업 → 제공된 경우 onLink(url)을 호출하고, 그렇지 않으면 경고와 함께 삭제합니다.

React(또는 Preact)에서는 useMcpAppBridge 훅을 사용합니다. 이 훅은 지정된 threadId/callEndpoint에 대해 안정적인 브리지를 반환하고 최신 sendMessage/onLink를 항상 호출하므로 useMemo를 직접 작성하거나 exhaustive-deps와 씨름할 필요가 없습니다. (@tanstack/ai-client의 기반 createMcpAppBridge는 직접 사용해야 하는 경우 프레임워크에 구애받지 않습니다.)

// 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 correlating widget calls back to this conversation.
const threadId = 'weather-chat'
const { messages, sendMessage, status } = useChat({
connection: fetchServerSentEvents('/api/chat'),
})

const bridge = useMcpAppBridge({
threadId,
callEndpoint: '/api/mcp-apps/call',
chat: { sendMessage: (content, body) => sendMessage(content, { body }) },
// Opt in to link navigation — absent means links are blocked.
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}
// your hosted sandbox-proxy page (a host constant, not the widget URL)
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>
)
}

쓰기 반영은 클라이언트 측에서 수행됩니다. 위젯 도구 호출은 기본적으로 스레드의 채팅 기록에 추가되지 않습니다. 대화 상태의 쓰기 반영 경로는 현재 릴리스의 범위에 포함되지 않습니다. 각 위젯 상호작용은 독립적으로 처리됩니다.

세션 영속성

호출 핸들러는 client.getInfo() / pool.getServers()에서 읽은 전송 디스크립터를 사용하여 모든 위젯 작업마다 MCP 서버에 다시 연결합니다(호출마다 재연결 — 상태 비저장, 서버리스에 안전). 영속 세션이 필요한 상태 저장 MCP 전송에는 인메모리 세션 저장소를 선택적으로 사용합니다.

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 ?? '' } },
})

// In-memory store: one Node.js process, no cross-instance sharing.
// Shape matches the McpSessionStore interface — SQL / KV stores
// can be dropped in later with no API change.
const store = inMemoryMcpSessionStore({ ttlMs: 30 * 60_000 })

const handler = createMcpAppCallHandler({ clients: mcp, store })

현재 제한: inMemoryMcpSessionStore는 단일 인스턴스(하나의 Node.js 프로세스)입니다. 서버리스 재시작 후에도 유지되지 않으며 복제본 간 확장도 지원하지 않습니다. McpSessionStore 인터페이스는 영속성 확장 지점이며, API 변경 없이 영속 백엔드(데이터베이스, KV 저장소)를 추가할 수 있습니다.

API 레퍼런스

createMcpAppCallHandler (@tanstack/ai-mcp/apps)

import { createMCPClients } from '@tanstack/ai-mcp'
import { createMcpAppCallHandler } from '@tanstack/ai-mcp/apps'
import type { McpAppCallHandlerOptions } from '@tanstack/ai-mcp/apps'

const mcp = await createMCPClients({
weather: { transport: { type: 'http', url: process.env.MCP_URL ?? '' } },
})

const options: McpAppCallHandlerOptions = {
// Pass the MCP client(s) you already created:
// - a single MCPClient
// - an MCPClients pool (pool key = serverId on UIResourcePart)
// - an array of either
// The handler reads each client's transport descriptor via
// client.getInfo() / pool.getServers() (pure config, no live socket)
// and reconnects per call — serverless-safe by default.
clients: mcp,

// Dynamic session store (opt-in for stateful transports)
// store: inMemoryMcpSessionStore(),

// Custom tool allowlist — default: server's own exposed tools only
// AND-ed on top of the always-on same-server exposure check.
allowTool: (req) => req.toolName === 'get_weather',
}

// Returns: (req) => Promise<{ ok: true; result: unknown } | { ok: false; error: string }>
const handler = createMcpAppCallHandler(options)

inMemoryMcpSessionStore (@tanstack/ai-mcp/apps)

import { inMemoryMcpSessionStore } from '@tanstack/ai-mcp/apps'

const store = inMemoryMcpSessionStore({
ttlMs: 30 * 60_000, // optional; default: 30 minutes
})

createMcpAppBridge (@tanstack/ai-client)

import { createMcpAppBridge } from '@tanstack/ai-client'
import type { CreateMcpAppBridgeOptions } from '@tanstack/ai-client'

const options: CreateMcpAppBridgeOptions = {
threadId: 'weather-chat', // identifies the thread for the call handler
callEndpoint: '/api/mcp-apps/call', // POST route mounting createMcpAppCallHandler
chat: {
sendMessage: async (content, body) => {
console.log(content, body)
},
}, // prompt-intent path
fetchImpl: fetch, // optional; injectable for testing
onLink: (url) => window.open(url, '_blank'), // absent → link is dropped (warned), openLink returns { isError: true }
}

// Returns an McpAppBridge with callTool / sendPrompt / openLink methods.
const bridge = createMcpAppBridge(options)

useMcpAppBridge (@tanstack/ai-react / @tanstack/ai-preact)

createMcpAppBridge를 감싼 React/Preact 래퍼입니다. 지정된 threadId/callEndpoint에 대해 안정적인 브리지를 반환하므로 모든 렌더링마다 MCPAppResource가 재생성되지 않으며, 최신 chat.sendMessage/onLink를 항상 호출합니다. createMcpAppBridge와 동일한 옵션을 사용합니다.

import { useChat, useMcpAppBridge } from '@tanstack/ai-react'
import { fetchServerSentEvents } from '@tanstack/ai-client'

function useBridge(threadId: string) {
const { sendMessage } = useChat({
connection: fetchServerSentEvents('/api/chat'),
})
return useMcpAppBridge({
threadId,
callEndpoint: '/api/mcp-apps/call',
chat: { sendMessage: (content, body) => sendMessage(content, { body }) },
onLink: (url) => window.open(url, '_blank', 'noopener'),
})
}

MCPAppResource (@tanstack/ai-react/mcp-apps)

import { MCPAppResource } from '@tanstack/ai-react/mcp-apps'
// `part` is a UIResourcePart from the assistant message; `bridge` is a
// createMcpAppBridge result — both supplied by your component (see examples above).
import { part, bridge } from './chat-context'

const widget = (
<MCPAppResource
part={part} // UIResourcePart from the assistant message (carries the toolName)
sandbox={{ url: new URL('https://your-app.example.com/mcp-sandbox.html') }} // your hosted sandbox-proxy page (host constant; not the widget's ui:// URL)
bridge={bridge} // omit for static, display-only rendering
toolInput={{ city: 'Brooklyn' }} // optional tool input for the renderer context
/>
)

Preact: @tanstack/ai-preact/mcp-apps 에서 동일한 API (preact/compat 별칭 필요).