> ## 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.

# Talking to the MCP host

> How a BindJS view calls tools, messages the chat, and adjusts its presentation when rendered inside an MCP host

`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](/guides/concepts/native-rendering).

## The MCPHost interface

The hook returns either a single `MCPHost` object or `null`. Always guard for `null` before you use it.

```typescript theme={null}
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:

```typescript theme={null}
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.

```typescript theme={null}
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()`.

```typescript theme={null}
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)
    }
}
```

<Tip>
  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.
</Tip>

## 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.

```typescript theme={null}
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.

```typescript theme={null}
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:

```typescript theme={null}
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.

```typescript theme={null}
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.

## Navigation

`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:

```typescript theme={null}
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.

```typescript theme={null}
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.

```typescript theme={null}
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.

```typescript theme={null}
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()`:

```typescript theme={null}
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.

<Note>
  `.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](/bindjs/authoring/state).
</Note>

## 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.

```typescript theme={null}
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:

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

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.

## What to read next

<CardGroup cols={2}>
  <Card title="State and environment" icon="database" href="/bindjs/authoring/state">
    The hooks and patterns the example above relies on — `useState`, `useStore`, `useEnvironment`.
  </Card>

  <Card title="Hooks" icon="circle-nodes" href="/bindjs/authoring/hooks">
    A concept-level index of every runtime-injected hook with one-paragraph use cases.
  </Card>

  <Card title="Composition and slots" icon="cube" href="/bindjs/authoring/composition">
    How components compose into trees and how to declare slots that accept child components.
  </Card>

  <Card title="Connecting to Claude Desktop" icon="plug" href="/guides/connecting-to-mcp-hosts/claude-desktop">
    Wire up your MCP App to a host so the bridge becomes live.
  </Card>
</CardGroup>
