Hosts and platforms
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.
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:
- Your mappings, matched against the name.
- The MCP Apps extension: a client that declares it is
"ext-apps"(below). - 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, 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, and the name as this.clientInfo.
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.
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 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 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). |
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). |
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).
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:
- The page sends
ui/initialize,{ appInfo: { name: "FrontMCP Widget", version: "1.0.0" }, appCapabilities, protocolVersion }, to any origin.appCapabilities.availableDisplayModeslistsinline,fullscreenandpip. - The host answers with
{ hostCapabilities, hostContext }. The origin of that first answer is the only one the page trusts from then on. WithhostCapabilities.serverToolProxy(orserverTools) the page can call tools. - The page sends
ui/notifications/initialized. - The host sends
ui/notifications/tool-inputwith{ arguments }andui/notifications/tool-resultwith the call's{ content, structuredContent }. - When the widget calls a tool, the page sends a
tools/callrequest,{ name, arguments, _meta: { "frontmcp/widgetCall": true } }, and waits up to 10 seconds for the host's answer (Calling a tool from a widget). - The host may send
ui/notifications/tool-cancelled,{ reason }, which the page dispatches astool:cancelled, andui/resource-teardownbefore it removes the widget. The page still takes the olderui/notifications/cancelledtoo.
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() 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). It's a notification, so the host doesn't answer it. A host of your own has to handle it, as the one below 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 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:
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()anduseStructuredContent()arewindow.openai.toolOutput.getToolOutput(),useToolOutput()and a.tsxwidget'soutputprop are the output embedded in the page. When the page has none, as a static page doesn't, they arewindow.openai.toolOutputfrom the start.- When the host assigns a new
toolOutputand firesopenai:set_globals, all of them follow it, andonToolResultcallbacks run. The page'stool:resultevent 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.clientInfoandthis.platformare the host's, and so is the caller's identity.- It's on the
"mcp"surface, like any client call: a tool withavailableWhen: { surface: ["agent"] }answersTool "…" 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 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, 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(), 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.
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
<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:
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 _metaOn MCP 2026-07-28 the same method isn't there:
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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). 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). 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). 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.
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). 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). 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).
this.platform is "unknown" or "generic-mcp" for my client
Its name has none of the words FrontMCP looks for. Add a mapping.
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). Narrow the mapping, or map the name to "ext-apps".