# Hosts and platforms

> How FrontMCP recognizes the client that calls a tool, what each platform gets, how the widget's bridge talks to MCP Apps hosts and the OpenAI Apps SDK, and how a widget's tool calls reach the server as the host's own.

Source: https://frontmcp.dev/reference/ui/hosts

A widget runs inside a host: the chat app, IDE or agent UI that called the tool. Two things adapt to it. On the server, FrontMCP guesses the client's platform from the name in its client info, and shapes the result for it: no widget for `"gemini"`, a component payload in hybrid mode for `"openai"`, `"cursor"` and `"ext-apps"`, React bundled into `.tsx` widgets for `"claude"`. In the page, a script called the bridge works out which host it's running in and talks to it: MCP Apps hosts over `postMessage`, the OpenAI Apps SDK through `window.openai`. When a widget calls a tool, the host sends that call to your server over its own connection, so it arrives as an ordinary `tools/call` from the host's MCP client, marked as the widget's own.

```ts
this.platform   // "openai" | "claude" | "gemini" | "cursor" | "continue" | "cody" | "ext-apps" | "generic-mcp" | "unknown"

@FrontMcp({ ..., transport: { platformDetection: { mappings: [{ pattern, platform }], customOnly? } } })

// in the widget
await window.FrontMcpBridge.callTool("get_weather", { city: "Bergen" })
```

---

## Reference

### How FrontMCP recognizes the client

A client on MCP 2026-07-28 names itself in every request's `_meta["io.modelcontextprotocol/clientInfo"]`; a client with a session names itself once, in `initialize`. FrontMCP works out the platform in three steps, and the first one that gives an answer wins:

1. Your [mappings](#recognizing-a-client-of-your-own), matched against the name.
2. The MCP Apps extension: a client that declares it is `"ext-apps"` (below).
3. The name, matched, ignoring case, against these words, in this order, where the first match wins:

| The name contains | Platform |
| --- | --- |
| `chatgpt`, `openai` or `gpt` | `"openai"` |
| `claude` or `anthropic` | `"claude"` |
| `gemini`, `bard`, `google-ai` or `google ai` | `"gemini"` |
| `cursor` | `"cursor"` |
| `continue` | `"continue"` |
| `cody` or `sourcegraph` | `"cody"` |
| `mcp` | `"generic-mcp"` |
| anything else | `"unknown"` |

So `openai-mcp` is `"openai"`, `my-mcp-client` is `"generic-mcp"`, and the Playground's own client, `frontmcp.dev playground`, is `"generic-mcp"` too. A request without client info is `"unknown"`.

`"ext-apps"`, a host that supports [MCP Apps](#mcp-apps-hosts), comes from capabilities, not from a name: a client that declares `experimental["io.modelcontextprotocol/ui"]` or `extensions["io.modelcontextprotocol/ui"]` among its capabilities is `"ext-apps"`, even if it declares nothing more than `{}` for it. A client with a session declares them in `initialize`, and a client on 2026-07-28 in `_meta["io.modelcontextprotocol/clientCapabilities"]` of every request. The capability wins over the words in the name, so a client called `gemini-cli` that declares it gets a widget. A mapping wins over the capability: mapping a client's name to `"gemini"` is how you keep widgets from a client that declares the extension. A host that doesn't declare it is recognized by name, and a mapping can say what it is.

Changed in 1.8.7: the capability used to count only for clients with a session. On 2026-07-28 it was ignored, and the platform came from the name alone.

Changed in 1.9: the capability used to come before your mappings too, so a client that declared it was `"ext-apps"` whatever a mapping said.

Tools read the result as [`this.platform`](https://frontmcp.dev/reference/sdk/contexts#thisclientinfo-and-thisplatform), and the name as `this.clientInfo`.

> **Pitfall: The platform is a guess from a name the client chooses**
Any client can put `openai` in its name. Use the platform to shape output, never to decide what a caller may do.

#### `transport.platformDetection`

| Field | Type | Description |
| --- | --- | --- |
| `mappings` | `{ pattern: string \| RegExp, platform }[]` | Checked first, in order, before the MCP Apps extension and the built-in words. A string matches the whole name, ignoring case; a RegExp is tested against it. |
| `customOnly` | `boolean` | With `true`, a name no mapping matches is `"unknown"`, instead of going on to the built-in words. A client that declares the MCP Apps extension is still `"ext-apps"`. |

### What each platform gets

`tools/list` is the same for every client on 2026-07-28. The call result differs:

| Platform | Page in the result (inline) | Hybrid payload | `.tsx` widgets, by default |
| --- | --- | --- | --- |
| `"gemini"` | No | No | |
| `"openai"`, `"cursor"`, `"ext-apps"` | Yes | Yes | React from `esm.sh` |
| `"claude"` | Yes | No | React bundled into the page |
| `"generic-mcp"`, `"unknown"`, `"continue"`, `"cody"` | Yes | No | React from `esm.sh` |

The result's `content` and `structuredContent` are the same for all of them. Serving modes are on [Tool UI](https://frontmcp.dev/reference/ui#serving-modes).

A client with a session recognized as `"ext-apps"` gets a different `_meta` in `tools/list`: `ui.capabilities` from the tool's `widgetCapabilities` is added, and `frontmcp/type` and `ui/toolInvocation/*` are left out.

### The bridge

Every page FrontMCP renders includes a script that sets `window.FrontMcpBridge`. When the page loads, the bridge picks an adapter for the host:

| Adapter | Chosen when | Tool calls |
| --- | --- | --- |
| `openai` | `window.openai.callTool` is a function, or `window.openai.toolOutput` or `toolInput` is set | Through `window.openai.callTool()` |
| `ext-apps` | Otherwise, when the page is in a frame | A `tools/call` request to the parent frame, once the host has [answered the handshake](#mcp-apps-hosts) with `serverToolProxy` |
| `gemini` | `window.gemini` is set, in a page that isn't framed | Not supported |
| `generic` | Anything else, like the page opened on its own | Not supported |

The bridge's code also has a `claude` adapter, but it's never picked for a FrontMCP page: the page always sets `window.__mcpAppsEnabled`, which rules it out. A framed page is `ext-apps` even with `window.claude` set.

#### `window.FrontMcpBridge`

| Member | Description |
| --- | --- |
| `adapterId`, `initialized`, `capabilities` | The adapter's id, whether it's ready, and what it can do: `{ canCallTools, canSendMessages, canOpenLinks, canPersistState, hasNetworkAccess, supportsDisplayModes, supportsTheme }`. |
| `getToolInput()`, `getToolOutput()`, `getStructuredContent()` | The call's data. In an inline page, the input and output embedded in it. After an MCP Apps host's `tool-result`, `getToolOutput()` is the result's `structuredContent`, or for a result without one the first text content parsed as JSON, or as text when it isn't JSON. `getStructuredContent()` is the result's `structuredContent`, and `undefined` without one. See [The OpenAI Apps SDK](#the-openai-apps-sdk) for that host. |
| `callTool(name, args)` | Calls a tool through the host. Returns the host's result. |
| `onToolResult(callback)`, `onContextChange(callback)` | Subscribe to results, and to what the host says about itself. `onToolResult`'s `callback` gets the output, the value `getToolOutput()` returns. `onContextChange`'s gets the changed fields, like `{ theme: "dark" }`, for the host's answer to the handshake and for each change after it. Each returns an unsubscribe function. |
| `getTheme()`, `getDisplayMode()`, `getHostContext()`, `hasCapability(name)` | What the host said about itself. Before the handshake, `getTheme()` is the system's setting. |
| `sendMessage(text)` | Asks the host to add a message to the conversation: the `ui/message` request, to an MCP Apps host. |
| `requestDisplayMode(mode)` | Asks for `"inline"`, `"fullscreen"` or `"pip"`, if the host offers it ([Display modes](#display-modes)). |
| `requestClose()` | Asks an MCP Apps host to remove the widget, with the `ui/notifications/request-teardown` notification. It resolves once that's sent; the host decides ([Teardown](#teardown)). |
| `setSize({ width?, height? })` | Tells the host the widget's size in pixels, with the `ui/notifications/size-changed` notification. |
| `openLink(url)` | Asks an MCP Apps host that advertised `openLink` (or the spec's `openLinks`) with `ui/open-link`, `{ url }`; otherwise opens the link itself. |
| `updateModelContext(data, merge?)` | Tells a host that advertised `updateModelContext` (or `modelContextUpdate`) what the model should know, with `ui/update-model-context`. An object goes as `structuredContent` and as a JSON text block, merged into the last one unless `merge` is `false`, since the host keeps only the latest; anything else goes as a text block. Without the capability it rejects with `Model context update not supported`. |
| `registerTool(...)`, `unregisterTool(name)` | `ui/registerTool` and `ui/unregisterTool`, FrontMCP's own requests, which the MCP Apps spec doesn't have. Unless the host advertised `widgetTools`, they reject with `Widget tool registration not supported`. |
| `log(level, message, data?)` | The MCP `notifications/message` notification, `{ level, data }`, to a host that advertised `logging`, and the console otherwise. `warn` is sent as `warning`, and `data` is the message, or `{ message, data }` when there's data. It resolves once that's sent. |
| `getWidgetState()`, `setWidgetState(state)` | State kept in `localStorage`, under `frontmcp:widget:<tool>`. |

It dispatches `bridge:ready` (with `detail.adapter`) on `window` once the adapter is ready, and `tool:result`, with the host's whole result (`{ content, structuredContent, … }`) as `detail`, when an MCP Apps host sends one. With the OpenAI Apps SDK it dispatches no `tool:result`: use `onToolResult`. From an MCP Apps host it also dispatches `tool:cancelled`, with the host's `reason` in `detail`, and `bridge:teardown` ([Teardown](#teardown)).

A button or link with `data-tool-call="tool_name"` and `data-tool-args='{"city":"Bergen"}'` calls that tool when clicked, without any script of yours. It then dispatches `tool:success` (with `detail.result`) or `tool:error` on the element, bubbling. The click handler is installed once the adapter is ready, so in an MCP Apps host, clicks do nothing until the host answers the handshake.

### MCP Apps hosts

A host that implements MCP Apps loads the page in a frame, and the page and the host exchange JSON-RPC messages with `postMessage`:

1. The page sends `ui/initialize`, `{ appInfo: { name: "FrontMCP Widget", version: "1.0.0" }, appCapabilities, protocolVersion }`, to any origin. `appCapabilities.availableDisplayModes` lists `inline`, `fullscreen` and `pip`.
2. The host answers with `{ hostCapabilities, hostContext }`. The origin of that first answer is the only one the page trusts from then on. With `hostCapabilities.serverToolProxy` (or `serverTools`) the page can call tools.
3. The page sends `ui/notifications/initialized`.
4. The host sends `ui/notifications/tool-input` with `{ arguments }` and `ui/notifications/tool-result` with the call's `{ content, structuredContent }`.
5. When the widget calls a tool, the page sends a `tools/call` request, `{ name, arguments, _meta: { "frontmcp/widgetCall": true } }`, and waits up to 10 seconds for the host's answer ([Calling a tool from a widget](#calling-a-tool-from-a-widget)).
6. The host may send `ui/notifications/tool-cancelled`, `{ reason }`, which the page dispatches as `tool:cancelled`, and `ui/resource-teardown` before it removes the widget. The page still takes the older `ui/notifications/cancelled` too.

A frame whose parent never answers keeps waiting: tool calls fail with `Tool calls not supported on this platform (ext-apps)`, and `data-tool-call` clicks do nothing.

The host's result becomes the widget's output. For a result with `structuredContent`, which is what a tool that returns an object, a string or an array sends, `getToolOutput()`, [`useToolOutput()`](https://frontmcp.dev/reference/ui/components#hooks) and a `.tsx` widget's `output` prop are that value. A result that failed has none: the output is the first text content, which is the error's message, as a string, and `getStructuredContent()` is `undefined`. A widget that wants to show a failure listens for `tool:result` and reads `event.detail.isError`.

Changed in 1.8.7: the output used to be the result's `content` array, `[{ type: "text", text: "{…}" }]`, once an MCP Apps host sent the result, even in an inline page that had the object before. A widget that parsed `content[0].text` to get the object now gets the object itself, and `JSON.parse(output[0].text)` throws. `getStructuredContent()` has had the object all along.

#### The theme

When the host supplies a `theme`, `"light"` or `"dark"`, in the `hostContext` of its answer to `ui/initialize` or in a `ui/notifications/host-context-changed`, the bridge calls the `onContextChange` callbacks with what the host sent, and sets `<meta name="color-scheme" content="dark">` and `<html data-theme="dark">` on the page. With that meta the browser draws the page's defaults, `light-dark()` colors and the frame's background in the host's scheme. The bridge never writes them from the system's setting: a host that sends no theme leaves the page as its CSS makes it, light unless the CSS says otherwise. A `color-scheme` in the widget's own CSS wins over the meta, and then the page follows the system's setting and not the host's.

#### The size

A page with a size hint reports its size to the host with the MCP Apps notification `ui/notifications/size-changed`, `{ width, height }`, once the handshake has settled and again whenever the height changes ([Sizing](https://frontmcp.dev/reference/ui#sizing)). It's a notification, so the host doesn't answer it. A host of your own has to handle it, as the one [below](#hosting-widgets-in-your-own-client) does.

Changed in 1.8.7: the page used to send FrontMCP's own `ui/setSize` request, `{ height, width }`, so a host that implemented only the spec never learned the widget's height, and kept its default frame. A host that handled only `ui/setSize` now never hears it.

#### Display modes

`requestDisplayMode(mode)` asks only for a mode the host listed in `hostContext.availableDisplayModes`, with the `ui/request-display-mode` request, `{ mode }`. For any other mode it sends nothing and rejects with `Display mode "fullscreen" is not available on this host`. The host answers with the mode it set, `{ mode }`, which may not be the one asked for, and `getDisplayMode()` returns that one from then on. Under the OpenAI Apps SDK it calls `window.openai.requestDisplayMode({ mode })`. A tool's [`displayMode`](https://frontmcp.dev/reference/ui#the-ui-option) makes the page ask once, as soon as the bridge has connected, and ignore a refusal.

#### Teardown

A host about to remove the widget may send the `ui/resource-teardown` request. The bridge dispatches `bridge:teardown` on `window`, then answers `{}`, after which the host can remove the frame. Clean up in a listener, without waiting for anything:

```js
window.addEventListener("bridge:teardown", () => clearInterval(refreshTimer));
```

`requestClose()` is how the widget asks for this. The host decides, and sends `ui/resource-teardown` if it agrees.

Changed in 1.9.3: the bridge used FrontMCP's own names, which a host that follows the MCP Apps spec doesn't answer: `ui/openLink`, `ui/updateModelContext` with `{ context, merge }`, `ui/setDisplayMode` (whatever the host offered, with `getDisplayMode()` set to the mode asked for), `ui/log` and `ui/close`, both of them requests, so `log()` and `requestClose()` waited for an answer. It listened only for `ui/notifications/cancelled`, and left `ui/resource-teardown` unanswered, so a host waited out its own timeout before removing the widget. Under the OpenAI Apps SDK, `requestDisplayMode()` did nothing.

### The OpenAI Apps SDK

The OpenAI Apps SDK gives the page a `window.openai` object, so the bridge picks the `openai` adapter. Tool calls go to `window.openai.callTool()`, and `sendMessage()` to `window.openai.sendFollowUpMessage()`. The host's data is `window.openai.toolOutput`, the tool's `structuredContent`, and the bridge reads it:

- `getStructuredContent()` and `useStructuredContent()` are `window.openai.toolOutput`.
- `getToolOutput()`, `useToolOutput()` and a `.tsx` widget's `output` prop are the output embedded in the page. When the page has none, as a [static](https://frontmcp.dev/reference/ui#serving-modes) page doesn't, they are `window.openai.toolOutput` from the start.
- When the host assigns a new `toolOutput` and fires `openai:set_globals`, all of them follow it, and `onToolResult` callbacks run. The page's `tool:result` event isn't dispatched.

Changed in 1.8.7: the bridge didn't read `window.openai.toolOutput`. `useStructuredContent()` was `undefined`, and `getToolOutput()` and `useToolOutput()` stayed on the embedded data whatever the host assigned; only a `.tsx` widget's `output` prop followed it.

Changed in 1.9: a static page embedded `{}` as its output, which won over a `window.openai.toolOutput` already set when the page loaded, so `getToolOutput()`, `useToolOutput()` and the `output` prop were `{}` until the host assigned a new one. A static widget had to read `useStructuredContent()` instead.

These were checked in Chromium with a `window.openai` object that the test page defined (`toolOutput`, `toolInput`, `theme`, `displayMode`, `callTool`, and the `openai:set_globals` event), for a static template, a static `.tsx` widget and an inline one, with FrontMCP 1.9.2; `callTool()` and `requestDisplayMode()` again with 1.9.4. No OpenAI host was available, so what a real host puts in `window.openai` is as its documentation says, not as seen here.

### Calling a tool from a widget

The widget asks the bridge, the bridge asks the host, and the host calls your server with `tools/call` over its own MCP connection. For the server, the call is the host's:

- `this.clientInfo` and `this.platform` are the host's, and so is the caller's identity.
- It's on the `"mcp"` [surface](https://frontmcp.dev/reference/server/environment#surfaces), like any client call: a tool with `availableWhen: { surface: ["agent"] }` answers `Tool "…" not found`.
- The widget gets back the host's result. The bridge marks its call with `_meta: { "frontmcp/widgetCall": true }`, and FrontMCP answers a marked call with the tool's data and no page, since the widget that made it is already on screen. A model's call to the same tool still gets the page.

The marker only helps when the host passes the request's `_meta` on to your server, as [the host below](#hosting-widgets-in-your-own-client) does. Under the OpenAI Apps SDK the call goes through `window.openai.callTool(name, args)`, which can't carry `_meta`, so a tool with `ui` sends its whole page back to the widget, about 38 KB on every call. A tool that widgets call there is better without a `ui` of its own, or served [static](https://frontmcp.dev/reference/ui#serving-modes), which sends no page in any result.

Changed in 1.9.3: the bridge sent no marker, so a widget's call to a tool with `ui` always got that tool's page back.

### `ui/*` requests on a session

Some hosts pass a widget's messages to the server as they are. For clients on a session (Streamable HTTP before MCP 2026-07-28, on FrontMCP's Node server), FrontMCP answers JSON-RPC methods starting with `ui/` itself:

| Method | Answer |
| --- | --- |
| `ui/initialize` | `{ hostCapabilities, protocolVersion }`: `{ serverToolProxy: true, logging: true }` merged with `extApps.hostCapabilities`, with `openLink`, `modelContextUpdate` and `widgetTools` always `false`, and the widget's own protocol version. |
| `ui/callServerTool` | `{ name, arguments }` runs the tool as that session's client, with every check `tools/call` has, and returns its result without `_meta`, so a tool with a widget doesn't send its page back to the widget. Without `name`: `-32602` `Tool name is required`. For a tool the client can't call: `-32603` `Tool "…" not found`. |
| `notifications/message` | A notification, `{ level, logger?, data }`, with MCP's levels, `debug` to `emergency`: written to the server's log under `Widget:<session id>`, and answered `202` with no body. |
| `ui/log` | `{ level, message }` is acknowledged with an empty `result`, `{}`. Without `message`: `-32602` `Log message is required`. With `extApps.hostCapabilities.logging` set to `false`, `-32003` `Logging not supported by host`. |
| `ui/open-link`, `ui/update-model-context`, `ui/registerTool`, `ui/unregisterTool`, and the older `ui/openLink` and `ui/updateModelContext` | `-32003`, like `Open link not advertised by host`. FrontMCP has nothing that does them, so it advertises none, and setting one in `extApps.hostCapabilities` only logs a warning. |
| `ui/request-display-mode`, `ui/setDisplayMode` | `-32003` `Display mode change not supported by host`. |
| `ui/close` | `-32003` `Widget close not supported by host`. |
| Any other notification, like `ui/notifications/request-teardown` | `202` with no body. One FrontMCP can't act on is logged as a warning, like `handleNotification: method=ui/notifications/request-teardown failed: Widget close not supported by host`. |
| any other `ui/…` request | `-32601` `Unknown ext-apps method: ui/…` |

A `ui/*` request without a session gets `-32600` ``Session not initialized — send `initialize` first``. On MCP 2026-07-28, and on [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), which keeps no sessions, `ui/*` methods aren't served: `-32601` `Method not found: ui/callServerTool`. The bridge itself never sends `ui/callServerTool`: it sends `tools/call` to the host.

With `@FrontMcp({ extApps: { enabled: false } })`, a session client's `ui/*` requests all get `-32601` `Method not found: ui/…`, as if the methods didn't exist, and its notifications `202`. In 1.8.6 that setting changed nothing.

Changed in 1.9.3: FrontMCP knew only its own names, `ui/openLink`, `ui/updateModelContext`, `ui/setDisplayMode`, `ui/close` and `ui/log`, and answered a `ui/notifications/…` notification with a `200` and an error, as if it were a request.

Changed in 1.9: `ui/callServerTool` used to return the whole result, `_meta` included, so a tool with a widget sent its page, about 36 KB, with every call the widget made. `ui/log` answered without the `result` that a JSON-RPC answer must have.

#### Caveats

- The `ui/*` answers were checked with FrontMCP 1.9.4 on a Node server with a session, which the Playground can't open; the rest of this page's server behavior runs below.

---

## Usage

### Shaping a result per platform

`summary` returns a shorter text for clients detected as `"gemini"`, which get no widget. The tests call it as several clients, by name.

```ts summary.tool.ts active
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

@Tool({
  name: "summary",
  description: "Summarize a ticket",
  inputSchema: { id: z.string() },
  ui: { template: (ctx: TemplateContext<{ id: string }, { text: string }>) => ctx.helpers.html`<article>${ctx.output.text}</article>` },
})
export class Summary extends ToolContext {
  async execute({ id }: { id: string }) {
    // No widget for this platform: put everything in the text.
    if (this.platform === "gemini") return { text: `${id}: Cannot log in. Open, high priority, assigned to Sam.`, platform: this.platform };
    return { text: `${id}: Cannot log in`, platform: this.platform };
  }
}
```

```ts platform.test.ts
import { test, expect } from "@frontmcp/testing";

async function callAs(mcp: any, client: string) {
  const body = await mcp.raw.request({
    method: "tools/call",
    params: { name: "summary", arguments: { id: "T-1" }, _meta: { "io.modelcontextprotocol/clientInfo": { name: client, version: "1.0.0" } } },
  });
  return body.result;
}

test("the platform comes from the client's name", async ({ mcp }) => {
  const platforms: Record<string, string> = {};
  for (const client of ["openai-mcp", "my-gpt-app", "claude-ai", "gemini-cli", "cursor-vscode", "continue-dev", "sourcegraph-cody", "my-mcp-client", "acme-desk"]) {
    platforms[client] = (await callAs(mcp, client)).structuredContent.platform;
  }
  expect(platforms).toEqual({
    "openai-mcp": "openai",
    "my-gpt-app": "openai",
    "claude-ai": "claude",
    "gemini-cli": "gemini",
    "cursor-vscode": "cursor",
    "continue-dev": "continue",
    "sourcegraph-cody": "cody",
    "my-mcp-client": "generic-mcp",
    "acme-desk": "unknown",
  });
});

test("the Playground's client is generic-mcp", async ({ mcp }) => {
  expect((await mcp.tools.call("summary", { id: "T-1" })).json().platform).toBe("generic-mcp");
});

test("`gemini-cli` gets no widget, and the longer text", async ({ mcp }) => {
  const result = await callAs(mcp, "gemini-cli");
  expect(result._meta["ui/html"]).toBeUndefined();
  expect(result.structuredContent.text).toContain("assigned to Sam");
});

test("the others get the page", async ({ mcp }) => {
  expect((await callAs(mcp, "claude-ai"))._meta["ui/html"]).toContain("<article>T-1: Cannot log in</article>");
});
```

### Recognizing a client of your own

`acme-desk` is an MCP Apps host that doesn't declare the extension, so FrontMCP can't tell what it is from its capabilities. A mapping names it `"ext-apps"`, and it gets the hybrid payload that `"ext-apps"` clients get. A host that does declare the extension needs no mapping, and the last tests show that a mapping still wins over the extension.

```ts main.ts active
import { App, FrontMcp, Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

@Tool({
  name: "card",
  description: "Weather card",
  inputSchema: { city: z.string() },
  ui: { servingMode: "hybrid", template: (ctx: TemplateContext<{ city: string }, { city: string }>) => ctx.helpers.html`<p>${ctx.output.city}</p>` },
})
class Card extends ToolContext {
  async execute({ city }: { city: string }) {
    return { city, platform: this.platform };
  }
}

@App({ id: "weather", name: "Weather", tools: [Card] })
class Weather {}

@FrontMcp({
  info: { name: "weather", version: "1.0.0" },
  apps: [Weather],
  transport: {
    platformDetection: {
      mappings: [
        { pattern: "acme-desk", platform: "ext-apps" },
        { pattern: /^acme-/i, platform: "generic-mcp" },
      ],
    },
  },
})
export default class Server {}
```

```ts mapping.test.ts
import { test, expect } from "@frontmcp/testing";

async function callAs(mcp: any, client: string) {
  const body = await mcp.raw.request({
    method: "tools/call",
    params: { name: "card", arguments: { city: "Oslo" }, _meta: { "io.modelcontextprotocol/clientInfo": { name: client, version: "1.0.0" } } },
  });
  return body.result;
}

test("a string matches the whole name, ignoring case", async ({ mcp }) => {
  expect((await callAs(mcp, "ACME-DESK")).structuredContent.platform).toBe("ext-apps");
});

test("a RegExp is tested against the name", async ({ mcp }) => {
  expect((await callAs(mcp, "acme-mobile")).structuredContent.platform).toBe("generic-mcp");
});

test("names no mapping matches fall back to the built-in words", async ({ mcp }) => {
  expect((await callAs(mcp, "cursor")).structuredContent.platform).toBe("cursor");
});

test("the mapped client gets the hybrid payload", async ({ mcp }) => {
  expect((await callAs(mcp, "acme-desk"))._meta["ui/component"]).toEqual({ type: "html", hash: expect.any(String), toolName: "card" });
  expect((await callAs(mcp, "acme-mobile"))._meta["ui/component"]).toBeUndefined();
});

async function callDeclaring(mcp: any, client: string, capabilities: object) {
  const body = await mcp.raw.request({
    method: "tools/call",
    params: {
      name: "card",
      arguments: { city: "Oslo" },
      _meta: { "io.modelcontextprotocol/clientInfo": { name: client, version: "1.0.0" }, "io.modelcontextprotocol/clientCapabilities": capabilities },
    },
  });
  return body.result;
}

const apps = { mimeTypes: ["text/html;profile=mcp-app"] };

test("a client that declares the MCP Apps extension is `ext-apps`, whatever its name", async ({ mcp }) => {
  for (const capabilities of [{ experimental: { "io.modelcontextprotocol/ui": apps } }, { extensions: { "io.modelcontextprotocol/ui": apps } }]) {
    const result = await callDeclaring(mcp, "other-host", capabilities);
    expect(result.structuredContent.platform).toBe("ext-apps");
    expect(result._meta["ui/component"]).toEqual({ type: "html", hash: expect.any(String), toolName: "card" });
  }
});

test("it wins over the name's words, and a mapping wins over it", async ({ mcp }) => {
  const capabilities = { extensions: { "io.modelcontextprotocol/ui": apps } };
  expect((await callDeclaring(mcp, "gemini-cli", capabilities)).structuredContent.platform).toBe("ext-apps");
  expect((await callDeclaring(mcp, "acme-mobile", capabilities)).structuredContent.platform).toBe("generic-mcp");
  expect((await callDeclaring(mcp, "acme-mobile", {})).structuredContent.platform).toBe("generic-mcp");
});
```

With `customOnly: true` next to `mappings`, `cursor` would be `"unknown"` too.

### Calling a tool from an HTML widget

`data-tool-call` makes a button call a tool through the host, with no script. The script in the template only shows the answer, which arrives as the `tool:success` event. The Playground shows the markup the host receives; clicking it needs a host, like the one in [the next example](#hosting-widgets-in-your-own-client).

```ts get-weather.tool.ts active
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

type Weather = { city: string; tempC: number };

@Tool({
  name: "get_weather",
  description: "Current weather for a city",
  inputSchema: { city: z.string() },
  ui: {
    template: (ctx: TemplateContext<{ city: string }, Weather>) => ctx.helpers.html`
      <p id="now">${ctx.output.city}: ${ctx.output.tempC}°C</p>
      <button data-tool-call="get_weather" data-tool-args='{"city":"Bergen"}'>Bergen</button>
      <script>
        document.addEventListener("tool:success", (e) => {
          const w = e.detail.result.structuredContent;
          document.getElementById("now").textContent = w.city + ": " + w.tempC + "°C";
        });
      </script>`,
  },
})
export class GetWeather extends ToolContext {
  async execute({ city }: { city: string }): Promise<Weather> {
    return { city, tempC: city === "Bergen" ? 12 : 21 };
  }
}
```

```ts markup.test.ts
import { test, expect } from "@frontmcp/testing";

test("the button and the script reach the page", async ({ mcp }) => {
  const html: string = (await mcp.tools.call("get_weather", { city: "Oslo" })).raw._meta["ui/html"];
  expect(html).toContain(`<button data-tool-call="get_weather" data-tool-args='{"city":"Bergen"}'>Bergen</button>`);
  expect(html).toContain('document.addEventListener("tool:success"');
});

test("the page includes the bridge", async ({ mcp }) => {
  const html: string = (await mcp.tools.call("get_weather", { city: "Oslo" })).raw._meta["ui/html"];
  expect(html).toContain("window.FrontMcpBridge");
  expect(html).toContain("window.__mcpAppsEnabled = true;");
});

test("the button's call, marked by the bridge, gets the data without a page", async ({ mcp }) => {
  const body = await mcp.raw.request({
    method: "tools/call",
    params: { name: "get_weather", arguments: { city: "Bergen" }, _meta: { "frontmcp/widgetCall": true } },
  });
  expect(body.result.structuredContent).toEqual({ city: "Bergen", tempC: 12 });
  expect(body.result._meta["ui/html"]).toBeUndefined();
});
```

In an MCP Apps host that answers the handshake with `serverToolProxy`, clicking Bergen sends the host `tools/call` with `{ name: "get_weather", arguments: { city: "Bergen" }, _meta: { "frontmcp/widgetCall": true } }`, and the paragraph changes to `Bergen: 12°C` when the host answers. The last test sends that request as the host passes it on: the result has the data and no page. This was checked in Chromium, with the host below.

### Hosting widgets in your own client

A client that shows widgets has to be the host: put the page in a frame, answer the page's messages, and forward its tool calls to the server. `callServer()` is whatever your client uses to send an MCP request.

```html host.html
<iframe id="widget" sandbox="allow-scripts allow-same-origin"></iframe>
<script type="module">
  const frame = document.getElementById("widget");
  const result = await callServer("tools/call", { name: "get_weather", arguments: { city: "Oslo" } });
  frame.srcdoc = result._meta["ui/html"];

  addEventListener("message", async (event) => {
    if (event.source !== frame.contentWindow) return;
    const msg = event.data;
    const reply = (body) => frame.contentWindow.postMessage({ jsonrpc: "2.0", id: msg.id, ...body }, "*");

    if (msg.method === "ui/initialize") {
      reply({ result: { protocolVersion: "2026-01-26", hostCapabilities: { serverToolProxy: true }, hostContext: { theme: "dark" } } });
    } else if (msg.method === "ui/notifications/initialized") {
      frame.contentWindow.postMessage({
        jsonrpc: "2.0",
        method: "ui/notifications/tool-result",
        params: { content: result.content, structuredContent: result.structuredContent },
      }, "*");
    } else if (msg.method === "tools/call") {
      reply({ result: await callServer("tools/call", msg.params) }); // the widget's call, its _meta included
    } else if (msg.method === "ui/notifications/size-changed") {
      frame.style.height = `${msg.params.height}px`;
    } else if (msg.id !== undefined && msg.method) {
      reply({ error: { code: -32601, message: `${msg.method} isn't supported` } });
    }
  });
</script>
```

Serve the host page from a real origin. From `about:blank` the origin is `null`, and the widget's calls fail with `Failed to execute 'postMessage' on 'Window': Invalid target origin 'null' in a call to 'postMessage'.` This host was run in Chromium against a FrontMCP 1.9.4 server on Node, with the HTML widget above and with a `.tsx` widget: the page picked the `ext-apps` adapter, the widget's calls reached the server as this client, with the marker, and came back without a page, the frame took its height from the widget's `ui/notifications/size-changed` when the tool had `autoResize: true`, and after `tool-result` the widget's `useToolOutput()` and `useStructuredContent()` both held the object.

### Answering `ui/callServerTool` on a session

For a host that forwards widget requests unchanged, a Node server with sessions answers them. This ran against `FrontMcpInstance.bootstrap()` in Node:

```ts ui-requests.ts
const post = (body: object) =>
  fetch("http://127.0.0.1:3000/", {
    method: "POST",
    headers: { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": "2025-11-25", ...(sessionId ? { "mcp-session-id": sessionId } : {}) },
    body: JSON.stringify({ jsonrpc: "2.0", ...body }),
  });

let sessionId: string | null = null;
const init = await post({ id: 1, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "my-host", version: "1.0.0" } } });
sessionId = init.headers.get("mcp-session-id");
await post({ method: "notifications/initialized" });

await post({ id: 2, method: "ui/initialize", params: { appInfo: { name: "widget", version: "1" }, appCapabilities: {}, protocolVersion: "2026-01-26" } });
// → { hostCapabilities: { serverToolProxy: true, logging: true }, protocolVersion: "2026-01-26" }

await post({ id: 3, method: "ui/callServerTool", params: { name: "get_weather", arguments: { city: "Bergen" } } });
// → { content, structuredContent }: the tool's result, as for tools/call from "my-host", without _meta
```

On MCP 2026-07-28 the same method isn't there:

```ts get-weather.tool.ts active
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

@Tool({
  name: "get_weather",
  description: "Current weather for a city",
  inputSchema: { city: z.string() },
  ui: { template: (ctx: TemplateContext<{ city: string }, { city: string }>) => ctx.helpers.html`<p>${ctx.output.city}</p>` },
})
export class GetWeather extends ToolContext {
  async execute({ city }: { city: string }) {
    return { city };
  }
}
```

```ts ui-methods.test.ts
import { test, expect } from "@frontmcp/testing";

test("ui/callServerTool isn't a 2026-07-28 method", async ({ mcp }) => {
  const body = await mcp.raw.request({ method: "ui/callServerTool", params: { name: "get_weather", arguments: { city: "Bergen" } } });
  expect(body.error).toEqual({ code: -32601, message: "Method not found: ui/callServerTool" });
});

test("neither is ui/initialize", async ({ mcp }) => {
  const body = await mcp.raw.request({ method: "ui/initialize", params: {} });
  expect(body.error.code).toBe(-32601);
});

test("the host calls tools/call, which works", async ({ mcp }) => {
  expect(await mcp.tools.call("get_weather", { city: "Bergen" })).toBeSuccessful();
});
```

---

## Troubleshooting

### `Tool calls not supported on this platform (ext-apps)`

The page is in a frame, so it chose the MCP Apps adapter, but the host didn't answer `ui/initialize`, or answered without `serverToolProxy`. Answer the handshake ([Hosting widgets in your own client](#hosting-widgets-in-your-own-client)). A page opened on its own gets `Tool calls not supported on this platform (generic)`: nothing can carry its calls.

### My widget shows my error's message instead of my data

The tool failed, so the host's result has no `structuredContent`, and the output is the first text content: the error's message, as a string ([MCP Apps hosts](#mcp-apps-hosts)). Listen for `tool:result` and read `event.detail.isError`.

### `Request tools/call timed out after 10 s`

The host answered the handshake with `serverToolProxy` but not the widget's `tools/call` request, and the bridge gave up after 10 seconds. The host has to reply with the same `id`.

### `data-tool-call` buttons do nothing

The bridge installs the click handler once its adapter is ready. In a frame, that's when the host answers `ui/initialize`; a host that never answers leaves the buttons inert. In a page that isn't framed and has no `window.openai`, the click dispatches `tool:error`.

### The widget's frame doesn't fit its content

The page reports its size with the `ui/notifications/size-changed` notification, and only when the tool has a size hint, like `autoResize: true`. A host that doesn't handle that notification never hears it ([The size](#the-size)). Set a size hint, and have the host handle the notification.

### `Method not found: ui/callServerTool`

The request came over MCP 2026-07-28, or to `createFetchHandler()`, which have no `ui/*` methods. Have the host send `tools/call` instead, as the bridge does.

### `Unknown ext-apps method: ui/…`

A session client sent a `ui/` method FrontMCP doesn't know. The ones it answers are in [the table above](#ui-requests-on-a-session).

### `Open link not advertised by host`, `Model context update not advertised by host`

FrontMCP answers these `ui/*` requests on a session, but can't do them, whatever `extApps.hostCapabilities` says. The host has to handle them itself.

### `Display mode "fullscreen" is not available on this host`

`requestDisplayMode()` rejected without asking: the host's `hostContext.availableDisplayModes` doesn't list that mode ([Display modes](#display-modes)). Stay in the mode the host gave, `getDisplayMode()`.

### The widget gets the tool's whole page back from its own call

The host didn't pass the call's `_meta` on to your server, so FrontMCP couldn't tell the widget's call from the model's, or the widget runs under the OpenAI Apps SDK, whose `callTool()` can't send it ([Calling a tool from a widget](#calling-a-tool-from-a-widget)). Have the widget call a tool without a `ui`.

### A widget's tool call answers `Tool "…" not found`

The tool exists, but its `availableWhen.surface` leaves out `"mcp"`. A widget's calls are the host's, on the `"mcp"` surface ([Calling a tool from a widget](#calling-a-tool-from-a-widget)).

### `this.platform` is `"unknown"` or `"generic-mcp"` for my client

Its name has none of the [words FrontMCP looks for](#how-frontmcp-recognizes-the-client). Add a [mapping](#recognizing-a-client-of-your-own).

### A client that declares MCP Apps isn't `"ext-apps"`

One of your `platformDetection.mappings` matches its name, and a mapping wins over the extension ([How FrontMCP recognizes the client](#how-frontmcp-recognizes-the-client)). Narrow the mapping, or map the name to `"ext-apps"`.
