Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.metabind.ai/llms.txt

Use this file to discover all available pages before exploring further.

useMCPHost() returns the MCP host bridge when the BindJS view is rendered inside an MCP host — a sandboxed iframe in Claude, ChatGPT, VS Code, or Cursor, or natively via the Assistant SDK on iOS or Android. When the view runs anywhere else (a local preview, a standalone editor, a non-MCP context), the hook returns null. Every interaction described on this page goes through that bridge: calling other tools, posting messages to the chat, updating the LLM’s working context, requesting a different display mode, opening links the iframe can’t navigate to itself. This page is about how to use the bridge from inside a component body. For the wider picture of how a single BindJS definition reaches each rendering target, see Native rendering.

The MCPHost interface

The hook returns either a single MCPHost object or null. Always guard for null before you use it.
interface MCPHost {
  // Tool calls
  toolCall: (name: string, args?: Record<string, any>) => Promise<any>

  // Messaging
  sendMessage: (message: string) => Promise<void>
  updateModelContext: (content: Record<string, any>) => Promise<void>

  // Display + navigation
  requestDisplayMode: (mode: 'inline' | 'fullscreen' | 'pip') => Promise<void>
  openLink: (url: string) => Promise<void>
  sizeChanged: (height: number) => void

  // Logging
  log: (level: 'debug' | 'info' | 'warning' | 'error', message: string, data?: any) => void

  // Low-level transport
  sendRequest: (method: string, params?: any) => Promise<any>
  sendNotification: (method: string, params?: any) => void
}
The hook is registered on every conforming runtime, so calling it never throws. The bridge methods themselves are only invocable when the host has supplied a concrete implementation:
const body = (props, children) => {
    const host = useMCPHost()

    return Button("Refresh", () => {
        if (!host) return
        host.toolCall("refresh", { id: props.id })
    })
}
The pattern — read the host, return early if it’s null, then call methods — is the same shape every interaction below uses.

Calling tools with toolCall

toolCall invokes another tool on the same MCP App and resolves to the tool’s structured data — not the raw MCP envelope.
const result = await host.toolCall("search_products", { query: "shoes" })
The runtime unwraps the response for you:
  • If the underlying MCP response carries structuredContent, that value is returned directly.
  • Otherwise, the runtime parses the first text block as JSON and returns the parsed value. If parsing fails, you get the raw text.
  • If the tool returns isError: true, toolCall throws. There is no { data, error } envelope — use try / catch or .catch().
const onSearch = async () => {
    try {
        const products = await host.toolCall("search_products", { query })
        setResults(products)
    } catch (err) {
        host.log("error", "search failed", { query, error: String(err) })
        setError(err)
    }
}
The structured-vs-text unwrapping happens in the runtime, before your code sees the result. You don’t need to inspect MCP content blocks to read the data — await the call and you get the parsed value.

Messaging the chat

Two methods talk to the surrounding chat. They have different consequences and are usually combined. sendMessage injects a message into the host’s chat as if the user had typed it. The LLM sees the message and produces a full response turn.
await host.sendMessage("Tell me more about this product")
updateModelContext updates the LLM’s working context silently. No response is triggered. The argument is an arbitrary key-value object — the host injects it into the model context for the next turn.
await host.updateModelContext({
    selectedProduct: { id: 123, name: "Boot" },
    cart: { qty: 2, color: "oat" },
})
Common pattern. Push state silently, then send a user-style message that triggers a turn against that context. The LLM responds with the new state already in scope:
await host.updateModelContext({ selectedProduct: result })
await host.sendMessage("Tell me more about this product.")
The result is a chat turn that talks about the right product without the user having to repeat which one they meant. The model context becomes a backchannel for the UI to ground the conversation.

Display modes

requestDisplayMode asks the host to resize or reposition the BindJS view’s container. Three modes are defined:
  • 'inline' — embedded in the chat transcript at natural height. This is the default.
  • 'fullscreen' — the view takes over the host surface.
  • 'pip' — picture-in-picture overlay, persistent across host navigation.
Button("Open full view", () => {
    host?.requestDisplayMode("fullscreen")
})
The request is advisory. Hosts that don’t support a mode silently ignore the call. Unknown modes fall back to 'inline'. Don’t assume the mode change took effect — design the view so the inline rendering is acceptable on its own. openLink asks the host to open a URL. Sandboxed iframes can’t navigate the parent window directly, so this routes through the chat host’s URL handler:
Button("View product page", () => {
    host?.openLink("https://example.com/products/" + props.id)
})
The host decides whether the URL opens in a new tab, a system browser, or an in-app surface. Treat openLink as the only portable way to leave the view — window.open, anchor target="_blank", and similar web-only mechanisms either fail silently or break sandbox isolation, depending on the host.

Iframe sizing

sizeChanged informs the chat host of a content-height change so it can resize the iframe.
host?.sizeChanged(280)
The web runtime fires this automatically on layout changes — when content is added, removed, or grows. Component code rarely needs to call it. Reach for it when you’re driving a layout that the runtime can’t observe (a deferred third-party widget, an animation that lands at a different height than where it started) and the iframe is rendering with the wrong height.

Logging

log sends a structured log entry to the host. Use it instead of console.log for diagnostics that need to reach the host: a sandboxed iframe’s console isn’t always visible from the surrounding chat surface, but log is.
host.log("info", "product loaded", { id: 123 })
host.log("error", "tool call failed", { name: "search_products", error: String(err) })
The four levels — debug, info, warning, error — map to the host’s log surface. The optional data argument is a JSON-compatible object that travels with the message.

Low-level transport

sendRequest and sendNotification are the JSON-RPC primitives every other method on the bridge is built on.
const result = await host.sendRequest("custom/openInspector", { id })
host.sendNotification("custom/analytics", { event: "viewed" })
Reach for them only when you need to invoke a custom host-defined method that isn’t part of the standard interface. Because the standard interface covers tool calls, messaging, display, navigation, sizing, and logging, custom transport is rare.

Fetch on mount

A common tool-result UI is “fetch data when the view appears, then render based on the result”. The pattern uses useState for the data and triggers the call inside .onAppear():
const body = (props, children) => {
    const host = useMCPHost()
    const [data, setData] = useState(null)
    const [err, setErr] = useState(null)

    return VStack([
        data ? Text(JSON.stringify(data))
            : err ? Text("Error: " + err.message)
            : Text("Loading…"),
    ]).onAppear(() => {
        if (!host) return
        host.toolCall("search_products", { query: "shoes" })
            .then(setData)
            .catch(setErr)
    })
}
Three states cover most cases — loaded data, an error, the still-loading default — and the body branches on which one is set. State updates from the .then and .catch callbacks trigger a re-render, so the body runs again with the new values.
.onAppear() fires once when the view is first composed. If you need to refetch on a prop change, drive the call from a state effect or re-key the subtree with .id(...) so a new instance is composed. See State and environment.

End-to-end example: a product card

A product card pulls together every piece of the bridge described above. Tapping View details fetches the product via a tool, renders it in the card, places the selected product in the LLM’s context, and prompts the LLM to talk about it — so the chat that follows is grounded in the same product the user is looking at.
const properties = {
    productId: PropertyString({
        title: "Product ID",
        description: "The product to load when the user taps View details.",
        required: true,
    }),
}

const body = (props, children) => {
    const host = useMCPHost()
    const [product, setProduct] = useState(null)
    const [busy, setBusy] = useState(false)

    const onViewDetails = async () => {
        if (!host) return
        setBusy(true)
        try {
            const result = await host.toolCall("get_product", { id: props.productId })
            setProduct(result)

            // Tell the LLM what the user is now looking at, then ask it to react.
            await host.updateModelContext({ selectedProduct: result })
            await host.sendMessage("Tell me more about this product.")
        } catch (err) {
            host.log("error", "product fetch failed", {
                id: props.productId,
                error: String(err),
            })
        } finally {
            setBusy(false)
        }
    }

    return VStack({ spacing: 12 }, [
        product ? Text(product.name).font("headline") : Empty(),
        product ? Text(product.description) : Empty(),

        busy
            ? ProgressView()
            : Button("View details", onViewDetails),
    ])
}

export default defineComponent({ properties, body })
What’s happening, step by step:
1

The user taps View details

The button’s handler reads the MCPHost bridge and bails out if it’s null — the same view rendered in a local preview is still safe to render, the button is inert.
2

The view fetches the product via a tool

toolCall("get_product", { id }) invokes a Data Tool defined elsewhere in the same MCP App. The runtime resolves it to the structured product object directly. If the tool throws, the catch block logs through the host and the busy state clears.
3

The component renders the product

setProduct(result) triggers a re-render. The body runs again with product populated, and the Text(product.name) / Text(product.description) paths replace the Empty() placeholders.
4

The view tells the LLM what's selected

updateModelContext({ selectedProduct: result }) updates the model’s working context silently. No turn fires. From the model’s perspective, it now knows which product the user is looking at.
5

The view prompts the LLM to react

sendMessage("Tell me more about this product.") injects a user-style message. The LLM produces a response turn — and because the context already carries selectedProduct, the response is grounded in the right product without any further plumbing.
The same shape — toolCall to fetch, updateModelContext to inform the model, sendMessage to prompt — covers most real uses of the host bridge. Vary the messaging step to match the experience you want: skip sendMessage if you only want to update the context for the next user-driven turn; skip updateModelContext if you only want to force a turn against the context already in flight.

Important rules

  • useMCPHost() returns null outside an MCP context. Always guard. Local previews, the standalone editor, unit-test runners — none of these supply a bridge, and the same component should still render somewhere reasonable.
  • toolCall throws on tool errors. Use try / catch (or .catch() on the promise). There is no { data, error } envelope to destructure.
  • sendMessage triggers a turn; updateModelContext doesn’t. If you only want to inform the model, use updateModelContext alone. If you want a response, follow the silent update with a sendMessage.
  • Display-mode requests are advisory. Hosts may ignore them. Design the view so the inline rendering is acceptable on its own.
  • Sandboxed iframes can’t navigate. Use openLink, not window.open or anchor tags. The host’s URL handler decides what happens next.

State and environment

The hooks and patterns the example above relies on — useState, useStore, useEnvironment.

Hooks

A concept-level index of every runtime-injected hook with one-paragraph use cases.

Composition and slots

How components compose into trees and how to declare slots that accept child components.

Connecting to Claude Desktop

Wire up your MCP App to a host so the bridge becomes live.