Apps

useMcpApp() bridge

data, hostContext, sendPrompt, callTool, and openLink from inside the iframe.

useMcpApp()

The single client-side composable, auto-imported into every MCP App SFC. It returns everything the iframe needs to talk to the host:

const {
  initialData,  // Ref<T | null>          — snapshot of the handler payload at mount, never updated
  data,         // Ref<T | null>          — hydrated from structuredContent, refreshed by callTool
  loading,      // Ref<boolean>           — true until first payload arrives
  error,        // Ref<Error | null>      — bridge / transport / payload errors
  pending,      // Ref<boolean>           — true while a callTool() is in flight
  hostContext,  // Ref<HostContext | null> — theme, displayMode, locale, …
  callTool,     // (name, params?) => Promise<T | null> — re-invoke any MCP tool
  sendPrompt,   // (prompt: string) => void              — push a message into the chat
  openLink,     // (url: string) => void                 — ask the host to open a URL
} = useMcpApp<MyPayload>()

Pass your payload type as the generic to get full inference downstream.

initialData & data

initialData is a frozen snapshot of the handler's structuredContent inlined at build time (or window.openai.toolOutput on ChatGPT). It never changes — use it for bootstrap values such as IDs or query params that later callTool results might overwrite in data.

data is already populated on first render when the handler returns structuredContent, then refreshed by callTool or host tool-result pushes. loading starts as true and becomes false after the first payload arrives. Use pending for in-flight callTool() refreshes:

<script setup lang="ts">
const { initialData, data, callTool } = useMcpApp<{ listId: string }>()

async function refresh() {
  const result = await callTool('list_todos', { listId: initialData.value!.listId })
  // `data` now holds the latest tool result; `initialData` still has `{ listId }`
}
</script>
<template>
  <section v-if="loading" class="skeleton" />
  <section v-else-if="data" class="content">
    {{ data.swatches.length }} swatches from {{ data.base }}
  </section>
</template>

hostContext

The host hands the iframe a context object during the ui/initialize handshake. Use it to adapt to dark mode, fullscreen, or a fixed iframe size:

interface HostContext {
  theme?: 'light' | 'dark'
  displayMode?: 'inline' | 'fullscreen' | 'pip'
  containerDimensions?: { width?: number, height?: number, maxWidth?: number, maxHeight?: number }
  locale?: string
  timeZone?: string
  platform?: 'web' | 'desktop' | 'mobile'
}
<script setup lang="ts">
const { hostContext } = useMcpApp()
const isDark = computed(() => hostContext.value?.theme === 'dark')
const isFullscreen = computed(() => hostContext.value?.displayMode === 'fullscreen')
</script>

<template>
  <main :data-theme="isDark ? 'dark' : 'light'" :data-mode="isFullscreen ? 'fullscreen' : 'inline'">
  </main>
</template>
hostContext is null on the very first paint and populates after the handshake (typically <50 ms). Always use a fallback in your template.

sendPrompt(prompt) — Follow-Ups

Push a message into the chat as if the user had typed it. The LLM then routes it like any other request — including invoking another MCP App:

<button @click="sendPrompt(`Use ${swatch.name} (${swatch.hex}) as the brand colour.`)">
  Use this colour
</button>

The host receives the prompt as if the user had typed it. The LLM may reply, call another tool, or open a different MCP App in response — app-to-app workflows fall out of this primitive.

Follow-ups are best-effort. Hosts that implement ui/message forward the prompt cleanly. ChatGPT acknowledges the request but doesn't always re-render the next tool inline (an upstream limitation).

callTool(name, params) — In-Place Refresh

Re-invoke any MCP tool from the iframe. The result replaces data automatically:

<script setup lang="ts">
const { data, pending, callTool } = useMcpApp<PalettePayload>()

async function refresh(base: string) {
  await callTool('color-picker', { base })
}
</script>

Use this for filters, pagination, refresh buttons — anything that changes the query without a full chat round-trip.

openLink(url)

Sandbox iframes can't open windows. openLink asks the host to do it for you (e.g. open a booking confirmation in a new browser tab):

<button @click="openLink(`https://example.com/colors/${swatch.hex.slice(1)}`)">
  View on the web
</button>
Add the target host to csp.connectDomains if you also need to fetch() it from the iframe.
Copyright © 2026