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.
How AI clients render widgets
Section titled “How AI clients render widgets”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:
- Your MCP server returns HTML content via a resource response (e.g.
resources/read) - The AI client creates a sandboxed
<iframe>in its UI - The iframe enforces CSP — any script, image, font, or API call from a domain not listed in the CSP is silently blocked
- There’s no visible error in the widget. It just doesn’t work.
ChatGPT’s sandbox model
Section titled “ChatGPT’s sandbox model”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’s sandbox model
Section titled “Claude’s sandbox model”Claude connectors use a similar sandboxed iframe model but with different CSP header formats. The allowed domains are configured differently from ChatGPT.
The common mistakes
Section titled “The common mistakes”-
Missing
connectDomainsfor API calls. Your widget callsfetch('https://api.example.com/data')butapi.example.comisn’t inconnectDomains. The fetch silently fails. -
Missing
resourceDomainsfor CDN assets. Your widget loads a font from Google Fonts or a script from cdnjs. WithoutresourceDomainsincluding those CDN domains, they’re blocked. -
Using relative paths. Your HTML references
./style.cssor./app.js. Inside a sandboxed iframe with a different origin, these paths resolve to the wrong URL. -
Testing locally without CSP. Widgets work fine on
localhostbecause there’s no sandbox. You deploy, and everything breaks in ChatGPT/Claude. -
Different CSP per platform. Your widget works on ChatGPT but breaks on Claude (or vice versa) because the CSP header format differs.
How mcpr fixes it
Section titled “How mcpr fixes it”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.
mcpr --mcp http://localhost:9000 --widgets http://localhost:4444Your widget server doesn’t need to know about CSP at all. mcpr handles:
-
CSP header injection — reads
_meta.ui.cspdeclarations from your MCP server response and translates them into correct CSP headers for whichever AI client is making the request. -
HTML path rewriting — uses a streaming HTML parser (
lol_html) to rewritesrc,href,action,srcset, and CSSurl()attributes so relative paths resolve correctly inside sandboxed iframes. -
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.
Widget proxying
Section titled “Widget proxying”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.
Proxying a dev server (Vite, etc.)
Section titled “Proxying a dev server (Vite, etc.)”mcpr --mcp http://localhost:9000 --widgets http://localhost:4444Serving static widget files from disk
Section titled “Serving static widget files from disk”mcp = "http://localhost:9000"widgets = "./widgets/dist"Adding extra CSP domains
Section titled “Adding extra CSP domains”If your widget loads resources from external CDNs or makes API calls to external services:
mcpr --mcp http://localhost:9000 --widgets http://localhost:4444 \ --csp cdn.example.com --csp api.example.comThe --csp flag is repeatable. Each domain is added to the injected CSP headers.
Overriding CSP entirely
Section titled “Overriding CSP entirely”If you need full manual control over CSP headers:
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.
Debugging CSP issues
Section titled “Debugging CSP issues”In the browser
Section titled “In the browser”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.
With mcpr events
Section titled “With mcpr events”mcpr emits a csp_violation event when a CSP violation is detected:
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"}With Studio
Section titled “With Studio”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.com | connectDomains: ["ws.example.com"] |
