Apps guide (MCP Apps / OpenAI Apps SDK)
Ship a HETIBANK result as an interactive view inside an agent host (ChatGPT and other MCP clients).
How it works
An MCP tool returns structuredContent; a ui:// resource (an HTML bundle) renders it. The view talks to the host with JSON-RPC over postMessage using the MCP Apps protocol. ChatGPT additionally exposes legacy window.openai globals — the kit supports both.
tools/call ──► structuredContent ──► ui://widget/…html ──► AppProvider ──► your React view
│ ▲
└── _meta (widget-only data) ─────────────┘
Install
pnpm add @hetibank/apps @hetibank/ui @hetibank/theme @hetibank/contracts
View
import { AppProvider, AppFrame, useToolResult, useDisplayMode, callTool, WidgetFallback } from "@hetibank/apps";
import { ThemeProvider } from "@hetibank/ui";
import { hetibankDark } from "@hetibank/theme";
import { propertyCardSchema, type PropertyCard } from "@hetibank/contracts";
function View() {
const result = useToolResult();
const mode = useDisplayMode();
const parsed = propertyCardSchema.safeParse(result?.structuredContent);
if (!parsed.success) return <WidgetFallback text="Open HETIBANK to see this home." />;
return <Card component={parsed.data} displayMode={mode} />;
}
export function Root() {
return (
<ThemeProvider theme={hetibankDark} as="section">
<AppProvider appInfo={{ name: "HETIBANK buyer view", version: "0.1.0" }}>
<AppFrame><View /></AppFrame>
</AppProvider>
</ThemeProvider>
);
}
Server side
The plugin server registers the tool and the widget resource:
import { createHetibankMcpServer } from "@hetibank/plugins";
import { widgetHtml } from "@hetibank/apps";
import { readFileSync } from "node:fs";
const server = createHetibankMcpServer({
client: hetibankClient, // injected, authenticated per request
widget: {
uri: "ui://widget/hetibank-buyer-ui-v1.html",
html: widgetHtml({
title: "HETIBANK",
script: readFileSync("dist/widget.js", "utf8"),
rootId: "hetibank-root",
data: { siteOrigin: "https://www.hetibank.com" },
}),
},
});
widgetHtml produces a CSP-safe document: default-src 'none', no remote scripts, inline styles only for the bundle you pass, a root element and data attributes. Do not hand-write the HTML shell — CSP mistakes are how widgets leak.
Bridge API
| Export | Purpose |
|---|---|
AppProvider | starts the bridge, handles ui/initialize, provides context |
useAppBridge() | low-level bridge object |
useToolResult(), useToolMeta() | result and widget-only metadata, subscription-based |
useDisplayMode() | inline / fullscreen / pip as negotiated |
callTool(name, args) | call one of the server's tools from the view |
sendMessage(text) | add a user message to the conversation |
openLink(url) | open a URL in the host's browser |
reportSize() | report height changes (the frame also observes resizes) |
parseStructuredContent(schema, value) | validate before render |
WidgetFallback | honest text fallback when structured content is absent or invalid |
Security
- The view never receives a server secret; tool calls are authorized by the host's session.
_metais widget-only: never render private documents the model should not see.- Navigation carries identifiers, not tokens; the target reauthorizes.
- No remote script tags or eval; the widget bundle is the only script.
- Validate
structuredContentwith the contract schema before rendering — a drifted server should
show a fallback, not a broken card.
Local development
Run the app-widget example: it spawns a mock host that performs the handshake, delivers a property_card, exercises size-changed, tools/call and ui/message:
pnpm --filter @hetibank/example-widget dev
Host compatibility
| Host | Path |
|---|---|
MCP Apps hosts (spec 2026-01-26) | postMessage JSON-RPC, ui/initialize handshake |
| ChatGPT Apps SDK | window.openai globals (toolOutput, callTool, sendFollowUpMessage) |
| Plain iframe without host | renders WidgetFallback text; no errors thrown |
Always test the fallback: a widget that only works with a host is a widget that breaks silently.