Skip to content

CSP & Widgets

This is the most common reason MCP widgets break. If your widget shows a blank iframe with no error message — CSP is almost certainly the cause.

When an AI client (ChatGPT, Claude) displays your widget, it doesn’t render HTML directly. It loads your widget inside a sandboxed iframe with strict Content Security Policy headers.

The sandbox model works like this:

  1. Your MCP server returns HTML content via a resource response (e.g. resources/read)
  2. The AI client creates a sandboxed <iframe> in its UI
  3. The iframe enforces CSP — any script, image, font, or API call from a domain not listed in the CSP is silently blocked
  4. There’s no visible error in the widget. It just doesn’t work.

ChatGPT Apps use a two-layer iframe model. The outer iframe is hosted by OpenAI. The inner iframe loads your widget content. CSP is enforced at both layers.

Your MCP server declares allowed domains through _meta.ui.csp in its responses:

  • connectDomains — domains your widget’s JavaScript can call (fetch, XMLHttpRequest, WebSocket). Use this for API calls.
  • resourceDomains — domains your widget can load static resources from (scripts, images, fonts, stylesheets). Use this for CDNs.
  • frameDomains — domains your widget can embed in sub-iframes. Rarely needed.

Claude connectors use a similar sandboxed iframe model but with different CSP header formats. The allowed domains are configured differently from ChatGPT.

  1. Missing connectDomains for API calls. Your widget calls fetch('https://api.example.com/data') but api.example.com isn’t in connectDomains. The fetch silently fails.

  2. Missing resourceDomains for CDN assets. Your widget loads a font from Google Fonts or a script from cdnjs. Without resourceDomains including those CDN domains, they’re blocked.

  3. Using relative paths. Your HTML references ./style.css or ./app.js. Inside a sandboxed iframe with a different origin, these paths resolve to the wrong URL.

  4. Testing locally without CSP. Widgets work fine on localhost because there’s no sandbox. You deploy, and everything breaks in ChatGPT/Claude.

  5. Different CSP per platform. Your widget works on ChatGPT but breaks on Claude (or vice versa) because the CSP header format differs.

mcpr sits between the AI client and your server. It reads the response from your MCP server and injects the correct CSP headers — for both ChatGPT and Claude — automatically.

Terminal window
mcpr --mcp http://localhost:9000 --widgets http://localhost:4444

Your widget server doesn’t need to know about CSP at all. mcpr handles:

  1. CSP header injection — reads _meta.ui.csp declarations from your MCP server response and translates them into correct CSP headers for whichever AI client is making the request.

  2. HTML path rewriting — uses a streaming HTML parser (lol_html) to rewrite src, href, action, srcset, and CSS url() attributes so relative paths resolve correctly inside sandboxed iframes.

  3. Single-origin merging — both your MCP backend and widget frontend are served from one URL. The AI client sees one origin, which satisfies same-origin CSP requirements.

mcpr merges your MCP backend and widget frontend behind a single URL:

  • JSON-RPC requests → your MCP server
  • Everything else → your widget server

This is protocol-level detection, not path-based routing. mcpr inspects the request content type and body to determine if it’s a JSON-RPC message.

Terminal window
mcpr --mcp http://localhost:9000 --widgets http://localhost:4444
mcpr.toml
mcp = "http://localhost:9000"
widgets = "./widgets/dist"

If your widget loads resources from external CDNs or makes API calls to external services:

Terminal window
mcpr --mcp http://localhost:9000 --widgets http://localhost:4444 \
--csp cdn.example.com --csp api.example.com

The --csp flag is repeatable. Each domain is added to the injected CSP headers.

If you need full manual control over CSP headers:

Terminal window
mcpr --mcp http://localhost:9000 --csp-mode override --csp "default-src 'self'"

In override mode, mcpr replaces the CSP headers entirely with your provided value instead of extending the auto-detected headers.

Open browser DevTools → Console. CSP violations appear as errors like:

Refused to load the script 'https://cdn.example.com/lib.js'
because it violates the following Content Security Policy directive: "script-src 'self'"

The error tells you exactly which domain and which directive is blocking the resource.

mcpr emits a csp_violation event when a CSP violation is detected:

Terminal window
mcpr --mcp http://localhost:9000 --events 2>/dev/null | jq 'select(.type == "csp_violation")'
{
"ts": "2026-04-02T10:15:32.000Z",
"type": "csp_violation",
"blocked_uri": "https://cdn.example.com/script.js",
"directive": "script-src",
"session": "sess_abc123"
}

Studio provides a visual CSP debugger that shows which rules are blocking which resources, with suggested fixes. You can test widgets with production-equivalent CSP enforcement before deploying.

Quick reference: when to use each CSP domain type

Section titled “Quick reference: when to use each CSP domain type”
Your widget does…You need…
fetch('https://api.example.com/...')connectDomains: ["api.example.com"]
<script src="https://cdn.example.com/lib.js">resourceDomains: ["cdn.example.com"]
<link href="https://fonts.googleapis.com/...">resourceDomains: ["fonts.googleapis.com", "fonts.gstatic.com"]
<img src="https://images.example.com/photo.jpg">resourceDomains: ["images.example.com"]
<iframe src="https://embed.example.com/widget">frameDomains: ["embed.example.com"]
WebSocket to wss://ws.example.comconnectDomains: ["ws.example.com"]