Conceptos

La CSP de MCP Apps, explicada

Toda MCP App corre bajo una Content Security Policy estricta. Lo que tu widget cargue de otro origen tiene que estar declarado en _meta.ui.csp; si no, el host lo bloquea y el widget sale en blanco.

8 min de lectura

Qué es la CSP de MCP Apps

La CSP de MCP Apps es la Content Security Policy que el host aplica al iframe aislado donde pinta tu recurso ui://. La cabecera no la escribes tú: declaras orígenes en el _meta.ui.csp del recurso (connectDomains, resourceDomains, frameDomains, baseUriDomains) y el host monta la política con esa lista. Lo que no declaras, se bloquea. Esa única regla explica casi todos los casos de un widget de MCP Apps que sale en blanco en Claude o en ChatGPT y que, abierto en el navegador, funciona.

Las reglas están en la especificación de MCP Apps (SEP-1865, estable desde el 26 de enero de 2026). Si MCP Apps te pilla de nuevas, empieza por qué son las MCP Apps.

La política por defecto si no declaras _meta.ui.csp

Si el recurso no trae csp, la especificación obliga al host a aplicar esta:

text
default-src 'none';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
media-src 'self' data:;
connect-src 'none';

Leída línea a línea, te dice qué funciona sin tocar nada:

  • Los <script> y <style> en línea del documento se ejecutan. Un único HTML autocontenido va bien.
  • Imágenes y vídeo solo del propio origen del documento o de URIs data:. Un <img src="https://..."> de cualquier otro sitio se rechaza.
  • connect-src 'none': ni fetch, ni XHR, ni WebSocket. Tampoco a tu propia API.
  • default-src 'none' cubre todo lo que no aparece, así que las fuentes y los iframes anidados de otros orígenes también se bloquean.

La especificación añade que el host no puede permitir dominios no declarados y que debe bloquear object-src siempre. Declarar orígenes es la única forma de abrir esa base.

Los cuatro campos de _meta.ui.csp

Cada campo se traduce en una o varias directivas CSP. La correspondencia está en la especificación y en los tipos del SDK @modelcontextprotocol/ext-apps:

CampoDirectivas CSPPara quéSi no lo declaras
connectDomainsconnect-srcfetch, XHR, WebSocketninguna conexión de red
resourceDomainsimg-src, script-src, style-src, font-src, media-srcimágenes, scripts, hojas de estilo, fuentes, audio y vídeoningún recurso estático externo
frameDomainsframe-srciframes anidados (un reproductor de vídeo, un mapa embebido)frame-src 'none'
baseUriDomainsbase-uriun <base href> que apunte a otro sitiobase-uri 'self'

La política va en el recurso, no en la tool. Así queda el contenido del recurso de un widget que enseña fotos de producto desde un CDN y consulta una API de precios:

json
{
  "uri": "ui://shop/product-card.html",
  "mimeType": "text/html;profile=mcp-app",
  "text": "<!doctype html>...",
  "_meta": {
    "ui": {
      "prefersBorder": true,
      "csp": {
        "resourceDomains": ["https://images.example-shop.com"],
        "connectDomains": ["https://api.example-shop.com"]
      }
    }
  }
}

Con el SDK oficial de TypeScript, ese mismo objeto va en los contents que devuelve el callback del recurso:

ts
import { RESOURCE_MIME_TYPE, registerAppResource } from '@modelcontextprotocol/ext-apps/server'

registerAppResource(server, 'Product card', 'ui://shop/product-card.html', {}, async () => ({
  contents: [
    {
      uri: 'ui://shop/product-card.html',
      mimeType: RESOURCE_MIME_TYPE,
      text: html,
      _meta: {
        ui: {
          csp: {
            resourceDomains: ['https://images.example-shop.com'],
            connectDomains: ['https://api.example-shop.com'],
          },
        },
      },
    },
  ],
}))

Comodines y redirecciones

Se admiten subdominios comodín: https://*.example.com vale para cualquier subdominio de example.com. Úsalos con cuidado. resourceDomains alimenta script-src además de img-src, así que un comodín sobre un dominio donde cualquiera puede subir ficheros deja que los scripts de ahí corran dentro de tu widget. Los orígenes exactos se revisan mejor, y la revisión de ChatGPT pide que cada lista sea lo más estrecha posible.

Ojo con las redirecciones. Si la URL de una imagen redirige, el navegador comprueba el origen que sirve los bytes al final. Un servicio de imágenes de prueba que responde desde un subdominio de su CDN necesita ese subdominio declarado.

Cómo aplican la política Claude y ChatGPT

Todos los hosts siguen las mismas reglas, pero los dos grandes añaden restricciones propias. A octubre de 2026:

  • Claude bloquea por defecto todo origen externo y lee _meta.ui.csp como se ha descrito. Sus pautas de diseño dicen que frameDomains está restringido en Claude a la espera de una revisión de seguridad, así que no cuentes con iframes anidados ahí. Si cargas la fuente de Claude con applyHostFonts, su guía de temas te pide añadir https://assets.claude.ai a resourceDomains.
  • ChatGPT implementa el estándar MCP Apps y lee el mismo _meta.ui.csp. La guía de UI de OpenAI dice que los iframes anidados están bloqueados por defecto, que frameDomains es solo para componentes que de verdad necesitan embeber orígenes concretos (con una justificación al enviar el plugin) y que la revisión compara la política declarada con lo que hace la interfaz.
  • Los demás hosts (VS Code, Goose, Microsoft 365 Copilot y el resto de la guía de clientes) implementan la misma especificación. Da por hecho que cada uno bloquea lo que no declaras y prueba en los hosts que te importen.

_meta.ui.domain está relacionado pero es otra cosa: pide un origen propio para el sandbox (útil para listas de CORS o callbacks de OAuth), y su formato depende del host, como un subdominio con hash de claudemcpcontent.com en Claude o un subdominio de oaiusercontent.com en ChatGPT.

openai/widgetCSP y los nombres en snake_case

Las apps escritas para el Apps SDK original de OpenAI declaraban su política en _meta["openai/widgetCSP"] con claves en snake_case. La guía oficial de migración las traduce así:

Apps SDK de OpenAIMCP AppsNotas
_meta["openai/widgetCSP"]_meta.ui.cspel objeto entero
connect_domainsconnectDomainsfetch, XHR, WebSocket
resource_domainsresourceDomainsimágenes, fuentes, estilos, scripts
frame_domainsframeDomainsiframes anidados
redirect_domains(sin equivalente)solo OpenAI, para redirecciones de openExternal
(sin equivalente)baseUriDomainssolo MCP Apps, base-uri
_meta["openai/widgetDomain"]_meta.ui.domainorigen propio del sandbox
_meta["openai/widgetPrefersBorder"]_meta.ui.prefersBorderpreferencia de borde

Así que esto:

json
{
  "openai/widgetCSP": {
    "connect_domains": ["https://api.example-shop.com"],
    "resource_domains": ["https://images.example-shop.com"]
  }
}

pasa a ser esto, y una sola declaración sirve en ChatGPT, en Claude y en cualquier otro host de MCP Apps:

json
{
  "ui": {
    "csp": {
      "connectDomains": ["https://api.example-shop.com"],
      "resourceDomains": ["https://images.example-shop.com"]
    }
  }
}

La migración completa desde window.openai está en la UI de un plugin de ChatGPT con MCP Apps.

Por qué mi widget de MCP Apps sale en blanco: lista de comprobación

Abre las herramientas de desarrollo del navegador en la página del chat, elige el iframe del widget en la consola y busca mensajes que empiecen por "Refused to load" o "Refused to connect". Te dicen la directiva y la URL. Después, repasa las causas habituales:

  1. Una imagen de un origen sin declarar. Añade el origen a resourceDomains. Si la URL redirige, declara el origen final.
  2. Un fetch a una API que no has declarado. Añádela a connectDomains. Mejor todavía: pide los datos en el servidor. Deja que la tool los reúna y los pase en su resultado, o llama a una tool del servidor desde la vista con app.callServerTool. Sin entrada en la CSP, sin CORS y sin claves en el navegador.
  3. Un script de un CDN. Un <script src> o un import de unpkg o jsDelivr necesita ese origen en resourceDomains. Si además la librería descarga ficheros mientras corre (datos, modelos, WebAssembly), añade el origen también a connectDomains. Empaquetarlo todo en un único HTML, como hace el quickstart oficial con Vite y vite-plugin-singlefile, te ahorra la pregunta.
  4. Fuentes web. Google Fonts necesita dos orígenes en resourceDomains: https://fonts.googleapis.com para la hoja de estilos y https://fonts.gstatic.com para los ficheros. O usa fuentes del sistema, o las del propio host.
  5. Un iframe embebido (vídeo, mapa, formulario). Necesita frameDomains, que Claude restringe y ChatGPT revisa. Muchas veces queda mejor una miniatura con un enlace que se abre con app.openLink.
  6. La política está en la tool en vez de en el recurso. csp va en el _meta.ui del recurso. En la tool, _meta.ui lleva resourceUri.
  7. Solo has probado fuera de un host. Una pestaña normal del navegador no aplica ninguna política de MCP Apps, así que todo carga. Prueba en un host que la aplique antes de publicar.

No todo widget en blanco es culpa de la CSP. Comprueba también que el tipo MIME del recurso es text/html;profile=mcp-app y que la vista registra sus manejadores de entrada y resultado de la tool antes de llamar a connect(); si no, puede perderse los datos que tenía que pintar.

Cómo resuelve Widgetry la CSP

Widgetry es un diseñador de widgets de MCP Apps, y escribe la CSP por ti a partir de dos listas cortas:

  • Las librerías salen de un catálogo cerrado. El script de un widget solo puede importar three.js 0.186.1, GSAP 3.15.0, d3 7.9.0 y Chart.js 4.5.1, cada una con versión fijada y servida desde jsDelivr por un import map. Si el widget usa alguna, su recurso declara https://cdn.jsdelivr.net en resourceDomains y en connectDomains. No tienes que escribirlo.
  • Los dominios de las imágenes se declaran por widget. Solo orígenes https, sin comodines, sin credenciales y sin ruta, normalizados a minúsculas, y como mucho diez. Cada uno amplía lo que el host deja entrar, por eso la lista es corta y exacta.
  • El documento lleva su propia política de imágenes. Esos mismos orígenes entran en una CSP <meta> dentro del documento del widget (img-src y media-src limitados a data:, blob: y esa lista). La vista previa del editor rechaza las mismas imágenes que Claude, así que un dominio que falta se ve mientras diseñas y no en el chat, y un documento exportado se comporta igual en cualquier servidor.
  • Ninguna fuente que declarar. Todos los diseños usan fuentes del sistema a propósito: una fuente web obligaría a declarar su origen en la CSP de cada recurso.
  • Sin peticiones. Un widget de Widgetry pinta los datos que el modelo pasa a su tool y nunca pide nada por su cuenta, así que connectDomains solo contiene el CDN de las librerías.

Un widget que dibuja con d3 y enseña fotos de un servidor de imágenes acaba con este _meta.ui, igual en el endpoint alojado que en el manifiesto exportado:

json
{
  "ui": {
    "prefersBorder": true,
    "csp": {
      "resourceDomains": ["https://cdn.jsdelivr.net", "https://images.example-shop.com"],
      "connectDomains": ["https://cdn.jsdelivr.net"]
    }
  }
}

El snippet de exportación pasa ese _meta tal cual a registerAppResource, así que la política viaja con el widget. Tienes las dos vías, exportar y enlazar, en añadir una interfaz a tu servidor MCP; los tipos de widget, en las plantillas; y si prefieres que un agente fije los dominios de las imágenes por ti, en crear widgets con un agente de IA.

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.