Skip to main content
Render MCP tool results as interactive inline apps in ChatKit.
MCP Apps let an MCP server return an HTML application as part of a tool result. In Xpert + ChatKit, a tool can declare a UI resource with _meta.ui.resourceUri, and ChatKit renders that resource as an inline iframe in the conversation. The iframe communicates with the Xpert backend through the standard MCP Apps JSON-RPC bridge. Use MCP Apps when a model-visible tool needs to produce a rich, interactive result such as a chart, dashboard, map, form, media browser, or drilldown analysis. For static structured cards, keep using ChatKit Widgets. For persistent workbench pages or integration configuration screens, keep using Xpert extension views.

What Xpert Supports

The Xpert MCP Apps host supports:
  • tool metadata discovery through _meta.ui.resourceUri
  • inline McpApp ChatKit message components
  • ui:// MCP resources with MIME type text/html;profile=mcp-app
  • JSON-RPC bridge methods including ui/initialize, ui/notifications/tool-input, ui/notifications/tool-result, tools/call, resources/read, ui/open-link, ui/message, ui/update-model-context, ui/request-display-mode, and ui/notifications/size-changed
  • app-only tools through _meta.ui.visibility = ['app']
  • resource CSP, browser permissions, domain, and prefersBorder from MCP App resource metadata
  • short-lived app instances with history refresh recovery from safe message metadata
  • plugin-managed MCP servers installed from Xpert plugins
ChatKit implements the MCP Apps bridge directly. It does not implement ChatGPT-specific window.openai APIs. MCP Apps can still be authored so they remain portable across hosts by relying on the standard bridge first.

Architecture

The chat history stores only safe metadata such as appInstanceId, resourceUri, toolName, toolsetId, and serverName, plus size-limited snapshots of the initial tool input/result. The raw HTML is not stored in the conversation. On page refresh, the backend can reconnect to the toolset and rebuild an expired app instance from the stored metadata.

History Replay And Initial Tool Results

An MCP App usually needs the triggering tool call’s tool-input and tool-result during initialization. Xpert + ChatKit resolves that data in this order:
  1. Use the live app instance first: while the in-memory app instance is still valid, the resource response returns the complete initial toolResult kept on the live instance.
  2. Revive when the live instance is gone: after a history refresh, a closed Agent Toolset, or a backend restart, the MCP Apps Host reconnects to the Toolset, reads the ui:// resource again, and creates a fresh app instance.
  3. Fall back to small snapshots in chat history: if the message contains a size-limited initial toolResult, ChatKit replays it through ui/notifications/tool-result; if no replayable result is available, ChatKit sends only ui/notifications/tool-input and does not invent an empty result.
To keep chat messages from becoming large data blobs, Xpert does not inline every tool result into message content. By default, only standardized CallToolResult payloads whose serialized size is at most 128KB are persisted inline. Oversized results store only toolResultSize and toolResultTruncated: true. The backend threshold can be configured with:
XPERT_MCP_APP_HISTORY_TOOL_RESULT_MAX_BYTES=131072
This keeps live runs fully interactive while preventing history messages from ballooning. Small results can be replayed directly from history; large results should provide a graceful fallback such as a summary, a rerun prompt, or app-visible tools that page or reload data on demand. If a product needs full replay of large historical results, store them in a dedicated artifact store rather than expanding message content.

Runtime Metadata Contract

ChatKit does not inspect arbitrary MCP server implementation details. It relies on the Xpert backend to preserve the MCP App metadata discovered from the selected Toolset. At runtime, an MCP tool becomes app-capable when Xpert sees _meta.ui.resourceUri and the resource URI uses the ui:// scheme. Tool _meta.ui should only carry resourceUri and visibility. Resource security and rendering metadata such as CSP, browser permissions, domain, and prefersBorder belongs on the MCP App resource _meta.ui. Xpert reads resources/read content item metadata first and falls back to resources/list metadata when the read result does not include it. Visibility controls who can call the tool:
VisibilityMeaning
modelThe tool may be exposed to the LLM.
appThe iframe may call the tool through tools/call.
A common pattern is to expose one model-visible tool that opens the app, plus one or more app-only tools used by the iframe. Xpert filters non-model-visible tools out of the LLM tool list. The MCP Apps host also rejects iframe calls to tools that are not app-visible or are disabled in the Xpert Toolset. For plugin-side metadata and tool registration examples, see MCP Tools and MCP Apps.

Resource Requirements

The app resource must return HTML with the MCP App profile MIME type text/html;profile=mcp-app. Registering the resource is the MCP server’s responsibility; validating and sandboxing it is the host’s responsibility. The resource can also declare display metadata in _meta.ui: title, description, and icon. title and description may be strings or Xpert-style I18nObject values. icon uses the shared IconDefinition shape. ChatKit stores only this safe descriptor in the message history, localizes the text with the current ChatKit language, and renders the icon/title/description in the MCP App message header. Security defaults are intentionally strict:
  • the initial App HTML must come from a ui:// resource
  • raw HTML is fetched at render time and is not persisted in chat history
  • CSP defaults to deny-by-default, with explicit domains from resource _meta.ui.csp
  • camera, microphone, geolocation, and clipboard-write are denied unless resource _meta.ui.permissions explicitly requests them
  • iframe resources/read calls are limited to the same MCP server and reject browser/script schemes such as http://, https://, javascript://, data://, and blob://
  • resource domain does not create a dedicated origin in v1; it is treated as unsupported host metadata
  • iframe tool calls always go through the Xpert backend and its tenant, organization, workspace, toolset, and tool-enabled checks

Theme Variables

Before ChatKit writes an MCP App HTML document into the iframe, it injects host theme variables into the app <head>. Variables use the generic --mcp-app-* prefix so other MCP Apps hosts can reuse the same contract:
<style id="mcp-app-host-theme">
  :root {
    color-scheme: light;
    --mcp-app-color-background: hsl(0 0% 100%);
    --mcp-app-color-foreground: hsl(222.2 84% 4.9%);
    --mcp-app-color-primary: hsl(221.2 83.2% 53.3%);
  }
</style>
MCP Apps should use the public --mcp-app-* variables instead of depending on ChatKit internal classes or private tokens. The current host provides:
VariablePurpose
--mcp-app-color-background / --mcp-app-color-foregroundpage background and body text
--mcp-app-color-card / --mcp-app-color-card-foregroundcards, panels, chart containers
--mcp-app-color-popover / --mcp-app-color-popover-foregroundpopovers and menus
--mcp-app-color-primary / --mcp-app-color-primary-foregroundprimary actions, key metrics, chart primary color
--mcp-app-color-secondary / --mcp-app-color-secondary-foregroundsecondary actions
--mcp-app-color-muted / --mcp-app-color-muted-foregroundmuted backgrounds and supporting text
--mcp-app-color-accent / --mcp-app-color-accent-foregroundhover, selected, and accent states
--mcp-app-color-destructive / --mcp-app-color-destructive-foregrounddestructive actions and error states
--mcp-app-color-border, --mcp-app-color-input, --mcp-app-color-ringborders, inputs, focus rings
--mcp-app-color-chart-1 through --mcp-app-color-chart-5chart color hints from the host
--mcp-app-radiusbase border radius
--mcp-app-font-sans, --mcp-app-font-monosans and monospace fonts
--mcp-app-color-schemelight or dark
Recommended app styling:
body {
  margin: 0;
  font-family: var(--mcp-app-font-sans, system-ui, sans-serif);
  color: var(--mcp-app-color-foreground, #0f172a);
  background: var(--mcp-app-color-background, #fff);
}

.panel {
  background: var(--mcp-app-color-card, #fff);
  color: var(--mcp-app-color-card-foreground, #0f172a);
  border: 1px solid var(--mcp-app-color-border, #e2e8f0);
  border-radius: var(--mcp-app-radius, 8px);
}

.primary {
  background: var(--mcp-app-color-primary, #2563eb);
  color: var(--mcp-app-color-primary-foreground, #fff);
}
If a charting library needs colors in JavaScript, read the injected CSS variables:
const styles = getComputedStyle(document.documentElement);
const primaryColor = styles
  .getPropertyValue('--mcp-app-color-primary')
  .trim();
Note: --mcp-app-color-chart-* values are host-provided chart color hints, not a guarantee that they fit every business chart. If the host theme uses neutral or muted chart tokens, the MCP App can define its own semantic data palette such as --sales-chart-revenue, --sales-chart-margin, or --risk-chart-high, while still using --mcp-app-* for background, text, borders, fonts, and radius. ui/initialize still returns hostContext.theme as the light / dark string. The same variable map is also returned as hostContext.themeCssVariables so apps can initialize chart themes or canvas colors. ChatKit also passes the current UI language through hostContext.locale, hostContext.language, and hostContext.direction. Before the iframe document runs, ChatKit sets the app HTML lang and dir attributes to the same values. MCP Apps should use these fields to localize labels, number/date formatting, chart titles, and validation messages inside the app resource.

Bridge Methods

Inside the iframe, use JSON-RPC messages through postMessage. The app should initialize itself before requesting host capabilities. The initialize request includes the app identity, app capabilities, and protocol version:
const id = 1;
window.parent.postMessage({
  jsonrpc: '2.0',
  id,
  method: 'ui/initialize',
  params: {
    protocolVersion: '2026-01-26',
    appInfo: {
      name: 'sales-dashboard',
      version: '0.1.0',
    },
    appCapabilities: {
      availableDisplayModes: ['inline'],
    },
  },
}, '*');
ChatKit responds with the standard McpUiInitializeResult shape:
{
  jsonrpc: '2.0',
  id: 1,
  result: {
    protocolVersion: '2026-01-26',
    hostInfo: {
      name: 'xpert-chatkit',
      version: '1.0.0',
      title: 'Xpert ChatKit',
    },
    hostCapabilities: {
      serverTools: {},
      serverResources: {},
      openLinks: {},
      message: { text: {} },
      updateModelContext: { text: {}, structuredContent: {} },
    },
    hostContext: {
      displayMode: 'inline',
      availableDisplayModes: ['inline'],
      theme: 'light',
      themeCssVariables: {
        '--mcp-app-color-background': 'hsl(0 0% 100%)',
        '--mcp-app-color-foreground': 'hsl(222.2 84% 4.9%)',
      },
      locale: 'zh-Hans',
      language: 'zh',
      direction: 'ltr',
      toolInfo: {
        tool: {
          name: 'sales_overview',
          inputSchema: {
            type: 'object',
            properties: {},
          },
        },
      },
    },
  },
}
After initialization, ChatKit sends the original tool input and then a standard MCP CallToolResult payload:
{
  jsonrpc: '2.0',
  method: 'ui/notifications/tool-input',
  params: {
    arguments: {
      year: 2026,
      groupBy: 'region',
    },
  },
}
{
  jsonrpc: '2.0',
  method: 'ui/notifications/tool-result',
  params: {
    content: [
      { type: 'text', text: 'Revenue by region for 2026.' },
    ],
    structuredContent: {
      chart: {
        labels: ['West', 'East'],
        values: [7600000, 6500000],
      },
    },
    toolName: 'sales_overview',
    toolCallId: 'call_123',
    // Legacy compatibility only; new apps should read params.content and params.structuredContent.
    result: {},
  }
}
The app can then call app-visible tools:
window.parent.postMessage({
  jsonrpc: '2.0',
  id: 2,
  method: 'tools/call',
  params: {
    name: 'sales_drilldown',
    arguments: {
      year: 2026,
      groupBy: 'product',
      filters: { region: 'West' },
    },
  },
}, '*');
The app can ask the host to open external links safely:
window.parent.postMessage({
  jsonrpc: '2.0',
  id: 3,
  method: 'ui/open-link',
  params: {
    url: 'https://example.com/report',
  },
}, '*');
The app can also send follow-up user messages or model context back to the host:
window.parent.postMessage({
  jsonrpc: '2.0',
  id: 4,
  method: 'ui/update-model-context',
  params: {
    content: [{ type: 'text', text: 'User selected West region.' }],
    structuredContent: {
      filters: { region: 'West' },
    },
  },
}, '*');
For dynamic height, notify ChatKit after rendering:
window.parent.postMessage({
  jsonrpc: '2.0',
  method: 'ui/notifications/size-changed',
  params: { height: document.body.scrollHeight },
}, '*');

Authoring and Packaging

The recommended way to ship an MCP App in Xpert is as a plugin-managed MCP server. The plugin owns the MCP server entrypoint, tool metadata, ui:// resource, app-only tools, and install policy. ChatKit only hosts the resulting app instance. For the plugin-side implementation flow, manifest schema, package layout, and local testing checklist, see MCP Tools and MCP Apps.

Relationship to Other Xpert UI Surfaces

MCP Apps are one of several UI extension points in Xpert:
SurfaceBest forRuntime model
ChatKit WidgetsDeclarative cards and structured UI dataData-driven ChatKit renderer
MCP AppsInteractive HTML apps returned by tool callsSession-inline iframe with MCP bridge
Extension ViewsPersistent workbench or integration pagesXpert plugin manifest and view host
MiddlewareTool registration and workflow interceptionAgent runtime behavior
Do not put arbitrary HTML into the widget renderer. Do not use middleware as the resource host. MCP Apps should use the MCP resource and bridge flow, while extension views should keep using Xpert view manifests and platform data/action providers.

Enablement and Operations

In production, enable MCP Apps explicitly:
XPERT_MCP_APPS_ENABLED=true
XPERT_MCP_APP_TOKEN_SECRET=<long-random-secret>
ChatKit stores only safe MCP App component metadata in chat history. The backend also issues an appInstanceToken for each app instance and ChatKit includes it on resource and RPC requests. In production, revive, tools/call, and resources/read requests are rejected if the signed token is missing, expired, or does not match the tenant, workspace, Toolset, server, tool, and resource URI. For local development, non-production environments enable MCP Apps by default and tolerate legacy messages without appInstanceToken. For plugin build, install, controlled stdio runtime, and runtime-copy checks, see MCP Tools and MCP Apps.

Troubleshooting

SymptomLikely causeFix
Resource fails with 404 after refreshOld frontend bundle did not send revive metadata, or the Toolset was deletedRebuild/reload ChatKit frontend and confirm the message includes toolsetId and resourceUri.
App renders but tool calls failTarget tool is not app-visible or is disabledSet _meta.ui.visibility = ['app'] and enable the tool in the Toolset policy.
Resource MIME errorResource did not return text/html;profile=mcp-appFix the MCP resource response MIME type.
External script blockedResource metadata is missing the CSP domainAdd the domain to resource _meta.ui.csp.resourceDomains; do not put it on tool _meta.ui.csp.

See Also