Conceptos

Temas en MCP Apps: modo oscuro y estilos del host

El host le dice a tu vista si el chat está en claro o en oscuro y le pasa un juego de variables CSS. Si las sigues, tu widget parece de la casa en los dos temas. Si no, parece pegado encima.

7 min de lectura

¿Me hará falta chaqueta esta tarde? Salgo andando hacia la oficina sobre las cinco.

Ha usado show_weather

Un widget de MCP Apps de verdad, pintado como lo pinta un host. Hecho con Widgetry.

Cómo funcionan los temas en MCP Apps

Los temas en MCP Apps funcionan a través del contexto del host. Cuando tu vista se conecta, el host le manda un theme ("light" o "dark"), un juego de propiedades CSS en styles.variables (colores, tamaños de letra, radios, sombras) y, si quiere, reglas @font-face en styles.css.fonts. Cuando la persona cambia el chat de claro a oscuro, el host envía ui/notifications/host-context-changed con lo que ha cambiado. A ti te toca aplicar esos valores al documento y escribir un CSS que los lea. Con eso, el modo oscuro de tu MCP App sale casi solo.

Todo esto forma parte de la especificación de MCP Apps. Si el protocolo te suena a chino, lee antes qué son las MCP Apps.

Qué le da el host a tu vista

Los campos que importan para el tema:

CampoQué lleva
theme"light" o "dark": el tema actual del chat
styles.variablespropiedades CSS como --color-background-primary, --color-text-secondary, --font-sans, --border-radius-md, --shadow-sm
styles.css.fontsCSS con reglas @font-face o @import para las fuentes del host
displayMode, containerDimensions, safeAreaInsetspistas de maquetación: en línea o a pantalla completa, el espacio que tienes, los bordes que hay que respetar

Los nombres de las variables están fijados en los tipos del SDK y van por familias: --color-background-*, --color-text-*, --color-border-* y --color-ring-* (primary, secondary, tertiary, inverse, ghost, más info, danger, success, warning y disabled), --font-sans y --font-mono, --font-weight-*, --font-text-*-size y --font-heading-*-size con sus interlineados, --border-radius-*, --border-width-regular y --shadow-*. Cada host puede mandar solo una parte, así que pon siempre un valor de respaldo.

Cada host las rellena a su manera. A octubre de 2026, Claude publica su paleta completa en sus pautas de diseño, usa light-dark() de CSS en los valores y sirve su fuente desde https://assets.claude.ai. ChatGPT manda variables de estilo del host desde mayo de 2026, según su changelog.

Tres formas de plantear el tema

Seguir al host en todo. Fondo transparente, tokens del host para cada color y la fuente del host. El widget parece parte de la conversación. Es lo que piden los dos grandes para la estructura. Claude: tokens del host para fondos, texto, bordes e iconos, y los colores de tu marca para los acentos. Las pautas de UI de OpenAI: colores del sistema para texto, iconos y separadores, colores de marca en acentos, insignias y botones principales, y nada de fuentes propias.

Tu propia marca, en claro y en oscuro. Tu paleta, tu tipografía y dos versiones que cambian con el tema del host. Encaja en widgets muy visuales (una tarjeta del tiempo, un globo 3D, un recibo con marca) donde el aspecto es lo importante. El precio: el widget puede parecer un cuerpo extraño, y mantienes dos paletas.

Mixto. Tokens del host para texto, bordes y superficies; tus colores para el acento y las series de datos. Para la mayoría de widgets es el punto de partida correcto: estructura nativa y detalles reconocibles.

Elijas lo que elijas, las pautas de Claude son claras: toda vista tiene que funcionar en tema claro y en oscuro.

Helpers del SDK: applyDocumentTheme, useHostStyles y compañía

@modelcontextprotocol/ext-apps hace el trabajo con el DOM:

  • applyDocumentTheme(theme) pone data-theme y color-scheme en <html>, para que resuelvan los selectores [data-theme="dark"] y los valores light-dark(). getDocumentTheme() lo lee.
  • applyHostStyleVariables(variables) escribe cada entrada de styles.variables en :root.
  • applyHostFonts(css) inyecta una vez las reglas de fuentes del host.

En TypeScript sin framework, aplica el contexto inicial tras connect() y cada cambio después. Registra el listener antes de conectar para no perderte una actualización temprana:

ts
import { App, applyDocumentTheme, applyHostFonts, applyHostStyleVariables, type McpUiHostContext } from '@modelcontextprotocol/ext-apps'

const app = new App({ name: 'weather-card', version: '1.0.0' })

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

// A change carries only the fields that changed.
app.addEventListener('hostcontextchanged', applyHostContext)

await app.connect()
applyHostContext(app.getHostContext() ?? {})

En React, @modelcontextprotocol/ext-apps/react envuelve los mismos helpers. useHostStyles(app, app?.getHostContext()) aplica tema, variables y fuentes y los vuelve a aplicar en cada cambio (useHostStyleVariables y useHostFonts hacen cada uno su mitad), y useDocumentTheme() te da el tema actual como estado, para el componente raro que tenga que bifurcar según el tema:

tsx
import { useApp, useDocumentTheme, useHostStyles } from '@modelcontextprotocol/ext-apps/react'

function WeatherCard() {
  const { app } = useApp({ appInfo: { name: 'weather-card', version: '1.0.0' }, capabilities: {} })
  useHostStyles(app, app?.getHostContext())
  const theme = useDocumentTheme()
  return <article className="card">{theme === 'dark' ? 'Noche despejada' : 'Soleado'}</article>
}

CSS que funciona en claro y en oscuro

Monta tus propios tokens encima de los del host, con respaldo para los hosts que mandan menos variables y para el instante antes de que llegue el contexto:

css
:root {
  --card-bg: var(--color-background-primary, #ffffff);
  --card-ink: var(--color-text-primary, #1d1d1b);
  --card-muted: var(--color-text-secondary, #5d5d58);
  --card-line: var(--color-border-tertiary, #e6e6e2);
  --card-accent: #2f6fd6;
}
:root[data-theme="dark"] {
  --card-bg: var(--color-background-primary, #1f1f1d);
  --card-ink: var(--color-text-primary, #f1f1ee);
  --card-muted: var(--color-text-secondary, #b3b3ad);
  --card-line: var(--color-border-tertiary, #383834);
  --card-accent: #7aa9f5;
}
html, body { margin: 0; background: transparent; }
.card {
  background: var(--card-bg);
  color: var(--card-ink);
  border: var(--border-width-regular, 1px) solid var(--card-line);
  border-radius: var(--border-radius-lg, 12px);
  font-family: var(--font-sans, system-ui, sans-serif);
}
.card svg { fill: currentColor; }

Unas cuantas reglas que ahorran horas de depuración:

  • Declara color-scheme. Pon <meta name="color-scheme" content="light dark"> en el head. La guía de temas de Claude explica el motivo: el navegador pinta un fondo opaco detrás de un iframe cuyo esquema de color no coincide con el de la página, y la etiqueta cubre el primer pintado antes de que corra tu script. applyDocumentTheme lo vuelve a fijar al ejecutarse.
  • Sigue el tema del host, no prefers-color-scheme. Dentro del iframe, la media query responde según el sistema operativo y el navegador. Alguien puede tener el chat en oscuro con el sistema en claro, o al revés. El theme del host es el valor con el que el host se compromete. Usa la media query solo como respaldo cuando no hay host al otro lado.
  • Deja el fondo transparente salvo que quieras pintar una tarjeta. Todos los marcos que Claude pone entre tu widget y el chat ya son transparentes.
  • Usa currentColor en los iconos, para que sigan al color del texto en los dos temas.
  • Redibuja el canvas al cambiar de tema. Las variables CSS se actualizan solas; un gráfico en canvas leyó sus colores una vez. Léelos con getComputedStyle al dibujar y vuelve a dibujar en hostcontextchanged.

Bordes, espaciado y tamaño

Bordes. _meta.ui.prefersBorder en el recurso le pide al host un borde y un fondo visibles (true) o ninguno (false). Si no lo pones, decide el host, y la especificación recomienda fijarlo porque cada uno tiene su valor por defecto. En Claude, sin valor se pinta sin borde en la web y con borde en el móvil. Sin borde significa de lado a lado y sin el relleno del host, así que respeta safeAreaInsets. Dentro del widget, Claude pide un juego limitado de radios y grosores de borde; los tokens --border-radius-* y --border-width-regular del host te dan uno.

Espaciado. Claude pide relleno generoso y agrupaciones lógicas; OpenAI, relleno constante y nada de texto apretado ni pegado al borde. Los dos prefieren una escala tipográfica corta (titular, cuerpo, pie) a muchos tamaños.

Tamaño. No fijes la altura. En Claude, las tarjetas en línea se ajustan a su contenido, sin scroll anidado, y el host limita y recorta lo que se pase. OpenAI deja que una tarjeta crezca con su contenido hasta la altura de la pantalla del móvil. Tu vista informa de su altura al host con ui/notifications/size-changed; la clase App del SDK lo envía por ti cada vez que cambia el contenido, salvo que desactives autoResize. Si necesitas una zona con scroll, pide pantalla completa con ui/request-display-mode.

Contraste. Claude pide como mínimo contraste WCAG AA. Revisa los dos temas, sobre todo los acentos sobre superficies oscuras.

Cómo resuelve Widgetry los temas

Widgetry, un diseñador de widgets de MCP Apps, separa las dos preguntas que responde un tema: qué es el widget (su tipo: tablero, gráfico, tarjeta del tiempo, globo) y qué aspecto tiene (su diseño). Cualquier tipo funciona con cualquier diseño.

  • Un diseño es un juego de tokens --wg-*: lienzo, tres niveles de tinta, tres superficies, color y grosor de línea, dos radios, sombra, desenfoque de vidrio, acento y el texto sobre él, cinco series de datos, positivo y negativo, tres familias tipográficas, peso, espaciado y caja de los titulares, y un brillo. Los estilos de un widget solo leen esos tokens, y por eso un tipo cambia de diseño sin tocar su markup.
  • Los valores por defecto siguen al host. Antes de aplicar ningún diseño, --wg-ink es var(--color-text-primary), --wg-surface es var(--color-background-primary), y así con el resto, dentro de :where() para que cualquier diseño los pise. El runtime aplica el tema, las variables y las fuentes del host con los helpers del SDK de arriba y vuelve a pintar los widgets con script cuando cambia el tema, así que los gráficos en canvas se redibujan.
  • Veinte diseños de serie: el de la casa, cinco clásicos (Nórdico, Aurora, Brutalista, Editorial, Neón) y catorce inspirados en marcas conocidas, de las que toman solo colores, radios, pesos y carácter tipográfico, nunca logotipos.
  • Claro y oscuro por diseño. Trece de los veinte cambian con el tema del chat mediante :root[data-theme="dark"]; Nórdico, uno de ellos, toma los colores del texto y de las superficies directamente del host. Los otros siete se quedan en un esquema a propósito (Aurora y Neón siempre oscuros, Brutalista siempre claro) y declaran su color-scheme para que los controles nativos cuadren.
  • Un diseñador de tokens por organización. Un diseño propio parte de uno de serie y guarda sus propios valores para el tema claro, otro juego para el oscuro y, si hace falta, CSS adicional para lo que los tokens no alcanzan.
  • Solo fuentes del sistema, porque una fuente web obligaría a declarar su origen en la CSP de cada recurso. Lo tienes en la guía de la CSP de MCP Apps.
  • Una vista previa que es un host de verdad. El editor habla MCP Apps con el widget y cambia entre claro y oscuro con la misma notificación host-context-changed que manda Claude, así que ves los dos temas antes de publicar.
  • prefersBorder: true en todos los recursos de widget que Widgetry sirve o exporta.

La tarjeta del tiempo de arriba es un solo tipo; cada diseño pinta su propio cielo. Tienes todos los tipos en las plantillas, y en añadir una interfaz a tu servidor MCP cómo llevarte un widget con su diseño a tu propio servidor.

Tu primer widget, en el chat en unos minutos

Elige una plantilla, pon tus datos y míralo como lo enseñarán ChatGPT o Claude. Después enlázalo desde tu servidor MCP, o deja que tu agente haga el siguiente.