Skip to main content
Claude renders MCP Apps inside a sandboxed iframe, and every frame between your widget and the chat surface already has a transparent background, so the conversation can show through. When you leave your own background transparent and style text and borders with the host’s style variables, your app looks like part of the conversation rather than an embedded box, and it follows the user’s light or dark mode automatically. The snippets on this page assume you have registered a UI resource and created an App instance from @modelcontextprotocol/ext-apps. See the SDK Quickstart if you haven’t.

Let the host background show through

Three settings on your side keep the transparency intact.

Don’t paint a body background

Any opaque background on <html> or <body> hides the chat surface behind it. Explicitly set both to transparent:
html,
body {
  margin: 0;
  background: transparent;
}

Declare color-scheme in your document head

Browsers give iframe documents an opaque canvas backdrop (white in light mode, near-black in dark mode) when the iframe’s color-scheme differs from the embedding page. Declaring both schemes opts your document into whichever mode the host is in, so the browser drops the backdrop and makes the CSS light-dark() values in Claude’s tokens resolve correctly:
<meta name="color-scheme" content="light dark" />

Request a borderless frame

Set prefersBorder: false in your UI resource’s _meta.ui object so the host doesn’t wrap your widget in its own bordered card. Claude web’s default is already borderless, but other hosts differ, so being explicit keeps your app portable. Register the resource with registerAppResource:
import {
  registerAppResource,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";

registerAppResource(server, "My Widget", "ui://my-app/widget.html", {}, async () => ({
  contents: [
    {
      uri: "ui://my-app/widget.html",
      mimeType: RESOURCE_MIME_TYPE,
      text: widgetHtml, // the bundled HTML string of your widget; see the SDK Quickstart
      _meta: {
        ui: {
          prefersBorder: false,
          // lets applyHostFonts load Anthropic Sans; see "Allow the host font origin in your CSP"
          csp: { resourceDomains: ["https://assets.claude.ai"] },
        },
      },
    },
  ],
}));

Apply the host’s style variables

Claude passes a hostContext object to your widget during the connect() handshake. The fields relevant to theming are:
FieldContents
theme"light" or "dark"
styles.variablesCSS custom properties: --color-background-*, --color-text-*, --color-border-*, --color-ring-*, --font-*, --border-radius-*, --border-width-*
styles.css.fonts@font-face rules for Anthropic Sans, served from https://assets.claude.ai
The Style variables section of the design guidelines lists every variable and its light- and dark-mode value.

Read hostContext and listen for changes

The App class exposes the initial context via getHostContext() once connect() resolves, and delivers subsequent updates (such as the user toggling dark mode) through the hostcontextchanged event. Register the listener before you connect so you don’t miss an early update. The SDK provides three helpers that do the DOM work for you, plus React hooks that wrap them: Keep the <meta name="color-scheme"> tag from the previous section even though applyDocumentTheme also sets color-scheme at runtime. The tag covers the first paint before your script runs and prevents an opaque-backdrop flash.
import {
  App,
  applyDocumentTheme,
  applyHostFonts,
  applyHostStyleVariables,
  type McpUiHostContext,
} from "@modelcontextprotocol/ext-apps";

function applyHostContext(ctx: Partial<McpUiHostContext>) {
  if (ctx.theme) applyDocumentTheme(ctx.theme);
  if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
  if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
}

const app = new App({ name: "my-app", version: "1.0.0" });

// Updates carry only the fields that changed.
app.addEventListener("hostcontextchanged", (changed) => applyHostContext(changed));

await app.connect();
const initial = app.getHostContext();
if (initial) applyHostContext(initial);

Reference the variables in your CSS

Once the variables are on :root, reference them directly. Provide fallbacks so the widget is still readable when rendered outside a host:
body {
  font-family: var(--font-sans, system-ui, sans-serif);
  color: var(--color-text-primary, light-dark(#141413, #faf9f5));
}
.card {
  border: var(--border-width-regular, 0.5px) solid var(--color-border-primary);
  border-radius: var(--border-radius-md, 8px);
}
Claude’s token values use CSS light-dark(), so once applyDocumentTheme has set the root color-scheme, every --color-* variable resolves to the right variant without any [data-theme] selectors on your side.

Allow the host font origin in your CSP

For applyHostFonts to load the @font-face files, your resource’s _meta.ui.csp allowlist must include https://assets.claude.ai in resourceDomains (shown in the registerAppResource snippet above). resourceDomains also adds the listed origins to script-src and style-src, so keep it to origins you trust to serve executable code; prefer bundling third-party fonts into your widget rather than allowlisting public CDNs.