<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Nguyen Hien</title>
    <description>The latest articles on Forem by Nguyen Hien (@cptrodgers).</description>
    <link>https://forem.com/cptrodgers</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3859748%2F8e400ae9-0927-4574-af06-6ee2b2271a86.jpg</url>
      <title>Forem: Nguyen Hien</title>
      <link>https://forem.com/cptrodgers</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/cptrodgers"/>
    <language>en</language>
    <item>
      <title>ChatGPT Creates a New MCP Session for Every Tool Call. Claude Doesn't.</title>
      <dc:creator>Nguyen Hien</dc:creator>
      <pubDate>Tue, 07 Apr 2026 10:43:06 +0000</pubDate>
      <link>https://forem.com/cptrodgers/why-your-mcp-apps-feels-slower-on-chatgpt-than-claude-44hf</link>
      <guid>https://forem.com/cptrodgers/why-your-mcp-apps-feels-slower-on-chatgpt-than-claude-44hf</guid>
      <description>&lt;p&gt;We caught something weird.&lt;/p&gt;

&lt;p&gt;We're building &lt;a href="https://github.com/cptrodgers/mcpr" rel="noopener noreferrer"&gt;mcpr&lt;/a&gt;, an open-source proxy for MCP servers. Our cloud dashboard tracks every MCP request at the protocol level — including session lifecycle. Initialize calls, tool invocations, session IDs, latencies. Everything.&lt;/p&gt;

&lt;p&gt;While monitoring a production MCP server that serves &lt;strong&gt;both&lt;/strong&gt; ChatGPT and Claude simultaneously, we noticed a pattern that made us do a double-take:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ChatGPT:&lt;/strong&gt; 2 tool calls. 2 separate sessions.&lt;br&gt;
&lt;strong&gt;Claude:&lt;/strong&gt; 2 tool calls. 1 session.&lt;/p&gt;

&lt;p&gt;Same server. Same tools. Same protocol. Completely different behavior.&lt;/p&gt;

&lt;p&gt;Let us show you.&lt;/p&gt;
&lt;h2&gt;
  
  
  The raw data
&lt;/h2&gt;

&lt;p&gt;Here's exactly what our dashboard recorded for a simple interaction where the AI calls two tools back-to-back.&lt;/p&gt;
&lt;h3&gt;
  
  
  ChatGPT — one session per tool call
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Session 1:
  04:02:40 PM  initialize              3ms   ok
  04:02:40 PM  tools/call  create_matching_question  12ms  ok
  -- session ended --

Session 2:
  04:03:47 PM  initialize              3ms   ok
  04:03:47 PM  tools/call  submit_answer            12ms  ok
  -- session ended --
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two sessions. Two full &lt;code&gt;initialize&lt;/code&gt; handshakes. Each session lives for roughly &lt;strong&gt;one second&lt;/strong&gt; — just long enough to shake hands, call a tool, and disappear.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft0bvh7exxcbdthpluyd2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft0bvh7exxcbdthpluyd2.png" alt="ChatGPT creates two separate MCP sessions for two tool calls" width="800" height="519"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Claude — one session, many calls
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Session 1:
  02:03:06 PM  initialize              30ms  ok
  02:03:07 PM  tools/list              11ms  ok
  02:03:08 PM  resources/list          14ms  ok
  02:03:38 PM  resources/read           4ms  ok
  02:03:41 PM  tools/call  create_cloze_question    35ms  ok
  02:03:45 PM  tools/call  get_latest_answer         6ms  ok
  -- session ended --
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;One session. One &lt;code&gt;initialize&lt;/code&gt;. Claude even runs discovery — &lt;code&gt;tools/list&lt;/code&gt;, &lt;code&gt;resources/list&lt;/code&gt;, &lt;code&gt;resources/read&lt;/code&gt; — before making any tool calls. All within the same session. Total duration: &lt;strong&gt;39 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnkegatbhkb2yqq3lzn65.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnkegatbhkb2yqq3lzn65.png" alt="Claude reuses a single MCP session across multiple tool calls" width="800" height="557"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this matters more than you think
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Initialize is not free
&lt;/h3&gt;

&lt;p&gt;Every MCP &lt;code&gt;initialize&lt;/code&gt; is a full handshake. The client sends its capabilities, the server responds with its own, they negotiate a protocol version. Some servers also load config, set up database connections, or warm caches during init.&lt;/p&gt;

&lt;p&gt;ChatGPT pays this cost &lt;strong&gt;on every single tool call&lt;/strong&gt;. Claude pays it &lt;strong&gt;once&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A conversation that triggers 10 tool calls means 10 handshakes on ChatGPT vs 1 on Claude. If your initialize takes 30-50ms — which is modest — you're adding &lt;strong&gt;300-500ms of pure overhead&lt;/strong&gt; that your users feel but can't explain.&lt;/p&gt;

&lt;p&gt;And that's the optimistic case. We've seen MCP servers where &lt;code&gt;initialize&lt;/code&gt; fetches user preferences, loads database schemas, or warms embedding caches. If your init takes 200ms, ten tool calls just cost you &lt;strong&gt;two full seconds&lt;/strong&gt; of invisible latency on ChatGPT.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Your in-memory state is gone
&lt;/h3&gt;

&lt;p&gt;This is the sneaky one. The silent killer.&lt;/p&gt;

&lt;p&gt;If your MCP server stores &lt;strong&gt;anything&lt;/strong&gt; in memory per session — user context, conversation history, cached API responses, computed state — ChatGPT will destroy it between tool calls.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# This pattern works perfectly on Claude.
# On ChatGPT, it's a landmine.
&lt;/span&gt;
&lt;span class="n"&gt;session_cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;session_cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;history&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_tool_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# On Claude: same session_id, cache hit, everything works
&lt;/span&gt;    &lt;span class="c1"&gt;# On ChatGPT: NEW session_id, cache miss, data is gone
&lt;/span&gt;    &lt;span class="n"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session_cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# None on ChatGPT!
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You test on Claude. Everything works. State persists across tool calls. You ship it. Then ChatGPT users start reporting bugs — results missing context, follow-up calls returning empty data, conversations that seem to "forget" what just happened.&lt;/p&gt;

&lt;p&gt;The worst part? Your server logs show zero errors. Every individual request succeeds. The failure is &lt;strong&gt;between&lt;/strong&gt; requests, in the gap where your state quietly vanishes.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Tool discovery follows different paths
&lt;/h3&gt;

&lt;p&gt;Look at the session data again. Claude calls &lt;code&gt;tools/list&lt;/code&gt; and &lt;code&gt;resources/list&lt;/code&gt; during the session. It discovers what's available, reads resources, then acts on what it learned.&lt;/p&gt;

&lt;p&gt;ChatGPT skips all of this. It goes straight to &lt;code&gt;initialize&lt;/code&gt; then &lt;code&gt;tools/call&lt;/code&gt;. No discovery phase. This suggests ChatGPT caches the tool schema externally and doesn't need to rediscover it per session — which makes sense given the disposable session model.&lt;/p&gt;

&lt;p&gt;This is actually clever engineering on ChatGPT's side: if you're going to throw away the session anyway, why waste time discovering what you already know?&lt;/p&gt;

&lt;h2&gt;
  
  
  How to build MCP servers that survive both models
&lt;/h2&gt;

&lt;p&gt;The rule is simple: &lt;strong&gt;design for the worst case&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Make initialize blazing fast
&lt;/h3&gt;

&lt;p&gt;ChatGPT will call it constantly. Every millisecond in init multiplies across every tool call in a conversation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Bad: heavy init that ChatGPT will pay for on every tool call
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;load_database_schema&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;# 200ms
&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;warm_embedding_cache&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;       &lt;span class="c1"&gt;# 500ms
&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch_user_preferences&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;     &lt;span class="c1"&gt;# 100ms
&lt;/span&gt;    &lt;span class="c1"&gt;# Total: 800ms per tool call on ChatGPT
&lt;/span&gt;
&lt;span class="c1"&gt;# Good: return immediately, defer everything
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;capabilities&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{...}}&lt;/span&gt;     &lt;span class="c1"&gt;# &amp;lt; 5ms
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Go stateless or go home
&lt;/h3&gt;

&lt;p&gt;Don't rely on session-scoped state. Period. Use external persistence keyed on something stable — user ID, API key, anything that survives a session reset.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Fragile: dies on ChatGPT
&lt;/span&gt;&lt;span class="n"&gt;session_state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="c1"&gt;# Robust: works everywhere
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pass context explicitly
&lt;/h3&gt;

&lt;p&gt;If tool B depends on output from tool A, don't cache it in session memory. Return it as structured output so the AI client can pass it back as input. Let the AI be the state carrier — it's the only thing that actually persists across ChatGPT's disposable sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we spotted this
&lt;/h2&gt;

&lt;p&gt;A regular HTTP reverse proxy — nginx, HAProxy, Caddy — would see these as normal HTTP requests. It has no idea that two POST requests belong to different MCP sessions, or that &lt;code&gt;initialize&lt;/code&gt; was called twice instead of once.&lt;/p&gt;

&lt;p&gt;mcpr is different. It parses MCP JSON-RPC at the protocol level. It knows what &lt;code&gt;initialize&lt;/code&gt; means, tracks session IDs, groups tool calls by session, and measures per-method latency. That's how a pattern like this surfaces in the dashboard instead of hiding in raw HTTP logs.&lt;/p&gt;

&lt;p&gt;If you're running MCP servers in production, this kind of protocol-level visibility is the difference between guessing why things are slow and &lt;strong&gt;knowing&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;ChatGPT and Claude have fundamentally different MCP session models:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;ChatGPT&lt;/th&gt;
&lt;th&gt;Claude&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Session lifetime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One tool call&lt;/td&gt;
&lt;td&gt;Entire conversation turn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Initialize calls&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Once per tool call&lt;/td&gt;
&lt;td&gt;Once per session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;In-memory state&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lost between calls&lt;/td&gt;
&lt;td&gt;Persists within session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tool discovery&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Skipped (cached externally)&lt;/td&gt;
&lt;td&gt;Done within session&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Design for the disposable model. If your server works on ChatGPT's session-per-call approach, it'll work everywhere. The reverse is not true.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://github.com/cptrodgers/mcpr" rel="noopener noreferrer"&gt;mcpr&lt;/a&gt; is an open-source MCP proxy (Apache 2.0). See session data like this in the cloud dashboard at &lt;a href="https://mcpr.app" rel="noopener noreferrer"&gt;mcpr.app&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>chatgpt</category>
      <category>claude</category>
      <category>mcp</category>
      <category>performance</category>
    </item>
    <item>
      <title>MCP App CSP Explained: Why Your Widget Won't Render in ChatGPT and Claude</title>
      <dc:creator>Nguyen Hien</dc:creator>
      <pubDate>Fri, 03 Apr 2026 16:16:39 +0000</pubDate>
      <link>https://forem.com/cptrodgers/mcp-app-csp-explained-why-your-widget-wont-render-9n1</link>
      <guid>https://forem.com/cptrodgers/mcp-app-csp-explained-why-your-widget-wont-render-9n1</guid>
      <description>&lt;p&gt;You built an MCP App. The tool works. The server returns data. But the widget renders as a blank iframe. &lt;/p&gt;

&lt;p&gt;You've hit the &lt;code&gt;#1 problem in MCP App development&lt;/code&gt;: &lt;strong&gt;Content Security Policy&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This post explains exactly how CSP works in MCP Apps, what the three domain arrays do, the mistakes that cause silent failures, and how to debug them. By the end, you'll never stare at a blank widget again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sandbox model
&lt;/h2&gt;

&lt;p&gt;Every MCP App widget runs inside a sandboxed iframe. On ChatGPT, that iframe lives at a domain like &lt;code&gt;yourapp.web-sandbox.oaiusercontent.com&lt;/code&gt;. On Claude, it's computed from a hash of your server URL. On VS Code, it's host-controlled.&lt;/p&gt;

&lt;p&gt;The sandbox blocks &lt;strong&gt;everything&lt;/strong&gt; by default. No external API calls. No CDN images. No Google Fonts. No WebSocket connections. Nothing leaves the iframe unless you explicitly declare it.&lt;/p&gt;

&lt;p&gt;You declare allowed domains in &lt;code&gt;_meta.ui.csp&lt;/code&gt; on your MCP resource. The host reads this and sets the iframe's Content Security Policy. If a domain isn't declared, the browser blocks the request before it even happens.&lt;/p&gt;

&lt;p&gt;Here's what a declaration looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;_meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;connectDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;resourceDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://cdn.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple enough. But the devil is in knowing which array to put each domain in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three domain arrays
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;connectDomains&lt;/code&gt; — runtime connections
&lt;/h3&gt;

&lt;p&gt;Controls: &lt;code&gt;fetch()&lt;/code&gt;, &lt;code&gt;XMLHttpRequest&lt;/code&gt;, &lt;code&gt;WebSocket&lt;/code&gt;, &lt;code&gt;EventSource&lt;/code&gt;, &lt;code&gt;navigator.sendBeacon()&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Maps to the CSP &lt;code&gt;connect-src&lt;/code&gt; directive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use when:&lt;/strong&gt; your widget calls an API at runtime.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This fetch will be BLOCKED unless api.stripe.com is in connectDomains&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charges&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.stripe.com/v1/charges&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Same for WebSockets&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wss://realtime.example.com/feed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;resourceDomains&lt;/code&gt; — static assets
&lt;/h3&gt;

&lt;p&gt;Controls: &lt;code&gt;&amp;lt;script src&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;link rel="stylesheet"&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;img src&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt;, &lt;code&gt;@font-face&lt;/code&gt;, CSS &lt;code&gt;url()&lt;/code&gt;, &lt;code&gt;@import&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Maps to CSP &lt;code&gt;script-src&lt;/code&gt;, &lt;code&gt;style-src&lt;/code&gt;, &lt;code&gt;img-src&lt;/code&gt;, &lt;code&gt;font-src&lt;/code&gt;, &lt;code&gt;media-src&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use when:&lt;/strong&gt; your widget loads assets from external CDNs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- These will be BLOCKED unless the domains are in resourceDomains --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.example.com/chart.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://fonts.googleapis.com/css2?family=Inter"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://res.cloudinary.com/demo/image/upload/sample.jpg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;frameDomains&lt;/code&gt; — nested iframes
&lt;/h3&gt;

&lt;p&gt;Controls: &lt;code&gt;&amp;lt;iframe src&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Maps to CSP &lt;code&gt;frame-src&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use when:&lt;/strong&gt; your widget embeds third-party content like YouTube videos, Google Maps, or Spotify players.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- BLOCKED unless youtube.com is in frameDomains --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;iframe&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://www.youtube.com/embed/abc123"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/iframe&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;frameDomains&lt;/code&gt;, nested iframes are blocked entirely. Note that ChatGPT reviews apps with &lt;code&gt;frameDomains&lt;/code&gt; more strictly — only use it when you actually embed iframes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five mistakes that break your widget
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Using &lt;code&gt;resourceDomains&lt;/code&gt; for API calls
&lt;/h3&gt;

&lt;p&gt;This is the most common mistake. Your widget calls &lt;code&gt;fetch()&lt;/code&gt; to an API, and you put the domain in &lt;code&gt;resourceDomains&lt;/code&gt; because "it's a resource." It isn't — &lt;code&gt;fetch()&lt;/code&gt; is a runtime connection.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong: API domain in resourceDomains&lt;/span&gt;
&lt;span class="nx"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;resourceDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Correct: API domain in connectDomains&lt;/span&gt;
&lt;span class="nl"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;connectDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; if your JavaScript code calls it at runtime, it goes in &lt;code&gt;connectDomains&lt;/code&gt;. If an HTML tag loads it as a static asset, it goes in &lt;code&gt;resourceDomains&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Forgetting the font file domain
&lt;/h3&gt;

&lt;p&gt;Google Fonts is a two-domain system. The CSS is served from &lt;code&gt;fonts.googleapis.com&lt;/code&gt;, but the actual font files (&lt;code&gt;.woff2&lt;/code&gt;) come from &lt;code&gt;fonts.gstatic.com&lt;/code&gt;. If you only declare the first, the CSS loads but the fonts don't.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong: CSS loads, fonts don't&lt;/span&gt;
&lt;span class="nx"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;resourceDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://fonts.googleapis.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Correct: both domains declared&lt;/span&gt;
&lt;span class="nl"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;resourceDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://fonts.googleapis.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://fonts.gstatic.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your widget will render with fallback system fonts — a subtle visual bug that's easy to miss during development but obvious to users.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Missing the WebSocket protocol
&lt;/h3&gt;

&lt;p&gt;WebSocket connections use &lt;code&gt;wss://&lt;/code&gt;, not &lt;code&gt;https://&lt;/code&gt;. If you declare the HTTPS version, the WebSocket connection still fails.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong: wss:// connections are still blocked&lt;/span&gt;
&lt;span class="nx"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;connectDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://realtime.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Correct: use the wss:// scheme&lt;/span&gt;
&lt;span class="nl"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;connectDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wss://realtime.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Also correct: declare both if you use both&lt;/span&gt;
&lt;span class="nl"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;connectDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wss://realtime.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Services that need both arrays
&lt;/h3&gt;

&lt;p&gt;Some services serve both static assets AND API responses from the same or related domains. Mapbox is a classic example — it serves API responses (tile coordinates) and image tiles (actual map pictures) from the same origins.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong: only connect, map tiles don't render&lt;/span&gt;
&lt;span class="nx"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;connectDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.mapbox.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Correct: both connect and resource&lt;/span&gt;
&lt;span class="nl"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;connectDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.mapbox.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;resourceDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.mapbox.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Other services that commonly need both: Cloudinary (API + image CDN), Firebase (API + hosting), Supabase (API + storage).&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Works in dev, breaks when published
&lt;/h3&gt;

&lt;p&gt;ChatGPT has a more relaxed CSP in developer mode. When you publish your app, stricter rules apply. Two things that catch people:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing &lt;code&gt;_meta.ui.domain&lt;/code&gt;.&lt;/strong&gt; Developer mode works without it. Published mode requires it — this is the domain ChatGPT uses to scope your widget's sandbox origin.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;_meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://myapp.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// required for published apps&lt;/span&gt;
    &lt;span class="na"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Missing &lt;code&gt;openai/widgetCSP&lt;/code&gt;.&lt;/strong&gt; Some published apps need the ChatGPT-specific CSP format alongside the standard &lt;code&gt;_meta.ui.csp&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;_meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;connectDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;resourceDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://cdn.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ChatGPT compatibility layer&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openai/widgetCSP&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;connect_domains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="nx"&gt;resource_domains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://cdn.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the naming difference: &lt;code&gt;connectDomains&lt;/code&gt; (camelCase) in the standard spec vs &lt;code&gt;connect_domains&lt;/code&gt; (snake_case) in the ChatGPT extension.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to debug CSP violations
&lt;/h2&gt;

&lt;p&gt;When CSP blocks a request, the browser logs it to the console. Here's how to find it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open DevTools (&lt;code&gt;F12&lt;/code&gt; or &lt;code&gt;Cmd+Opt+I&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Go to the &lt;strong&gt;Console&lt;/strong&gt; tab&lt;/li&gt;
&lt;li&gt;Look for red errors starting with &lt;code&gt;Refused to&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The error message tells you exactly what was blocked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Refused to connect to 'https://api.example.com/data'
because it violates the following Content Security Policy directive:
"connect-src 'self'"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What was blocked:&lt;/strong&gt; &lt;code&gt;https://api.example.com/data&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which directive:&lt;/strong&gt; &lt;code&gt;connect-src&lt;/code&gt; — you need &lt;code&gt;connectDomains&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Current policy:&lt;/strong&gt; only &lt;code&gt;'self'&lt;/code&gt; is allowed — the domain isn't declared&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For font issues:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Refused to load the font 'https://fonts.gstatic.com/s/inter/...'
because it violates the following Content Security Policy directive:
"font-src 'self'"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means &lt;code&gt;fonts.gstatic.com&lt;/code&gt; needs to be in &lt;code&gt;resourceDomains&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Debugging checklist
&lt;/h3&gt;

&lt;p&gt;When your widget is blank or partially broken:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open DevTools Console — look for &lt;code&gt;Refused to&lt;/code&gt; errors&lt;/li&gt;
&lt;li&gt;For each error, identify the directive (&lt;code&gt;connect-src&lt;/code&gt;, &lt;code&gt;font-src&lt;/code&gt;, &lt;code&gt;script-src&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;Map the directive to the right array:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;connect-src&lt;/code&gt; → &lt;strong&gt;connectDomains&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;script-src&lt;/code&gt;, &lt;code&gt;style-src&lt;/code&gt;, &lt;code&gt;img-src&lt;/code&gt;, &lt;code&gt;font-src&lt;/code&gt;, &lt;code&gt;media-src&lt;/code&gt; → &lt;strong&gt;resourceDomains&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;frame-src&lt;/code&gt; → &lt;strong&gt;frameDomains&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Add the blocked domain to the correct array&lt;/li&gt;
&lt;li&gt;Restart your MCP server and test again&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Copy-paste patterns
&lt;/h2&gt;

&lt;p&gt;Here are CSP declarations for common use cases:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API calls only:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;connectDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.yourbackend.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;CDN images:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;resourceDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://cdn.yourbackend.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Google Fonts:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;resourceDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://fonts.googleapis.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://fonts.gstatic.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Full stack — API + CDN + Fonts:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;connectDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.yourbackend.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;resourceDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://cdn.yourbackend.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://fonts.googleapis.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://fonts.gstatic.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Mapbox maps:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;connectDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.mapbox.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://events.mapbox.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;resourceDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.mapbox.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://cdn.mapbox.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Embedded YouTube:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;frameDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.youtube.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Other sandbox restrictions
&lt;/h2&gt;

&lt;p&gt;CSP isn't the only thing the sandbox blocks. These browser APIs are also restricted inside MCP App iframes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;localStorage&lt;/code&gt; / &lt;code&gt;sessionStorage&lt;/code&gt;&lt;/strong&gt; — may throw &lt;code&gt;SecurityError&lt;/code&gt;. Use in-memory state instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;eval()&lt;/code&gt; / &lt;code&gt;new Function()&lt;/code&gt;&lt;/strong&gt; — blocked by default. Some charting libraries use &lt;code&gt;eval()&lt;/code&gt; internally — check before picking a dependency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;window.open()&lt;/code&gt;&lt;/strong&gt; — blocked. Use the MCP Apps bridge for navigation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;document.cookie&lt;/code&gt;&lt;/strong&gt; — no cookies in sandboxed iframes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;navigator.clipboard&lt;/code&gt;&lt;/strong&gt; — blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;alert()&lt;/code&gt; / &lt;code&gt;confirm()&lt;/code&gt; / &lt;code&gt;prompt()&lt;/code&gt;&lt;/strong&gt; — blocked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your widget depends on any of these, it will fail silently even if your CSP is perfect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Platform differences
&lt;/h2&gt;

&lt;p&gt;The MCP Apps spec is standard, but each host implements it differently:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;CSP source&lt;/th&gt;
&lt;th&gt;Widget domain&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ChatGPT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;_meta.ui.csp&lt;/code&gt; + &lt;code&gt;openai/widgetCSP&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{domain}.web-sandbox.oaiusercontent.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Requires &lt;code&gt;_meta.ui.domain&lt;/code&gt; for published apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Claude&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_meta.ui.csp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SHA-256 of MCP server URL&lt;/td&gt;
&lt;td&gt;Own sandbox model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;VS Code&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_meta.ui.csp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Host-controlled&lt;/td&gt;
&lt;td&gt;Had bugs with &lt;code&gt;resourceDomains&lt;/code&gt; mapping in older versions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're building for multiple platforms, test on each. A widget that works on ChatGPT might fail on Claude or VS Code due to these subtle differences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skip the debugging entirely
&lt;/h2&gt;

&lt;p&gt;Getting CSP right by hand is tedious. Every time you add a new external dependency — a font, an analytics script, an API endpoint — you need to update &lt;code&gt;_meta.ui.csp&lt;/code&gt; and hope you picked the right array.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/cptrodgers/mcpr" rel="noopener noreferrer"&gt;&lt;strong&gt;MCPR&lt;/strong&gt;&lt;/a&gt; is an open-source MCP proxy that handles this for you. It sits between the AI client and your MCP server, reads your &lt;code&gt;_meta.ui.csp&lt;/code&gt; declarations, and injects the correct CSP headers automatically — so you declare once and it works across ChatGPT, Claude, and VS Code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo &lt;span class="nb"&gt;install &lt;/span&gt;mcpr
mcpr &lt;span class="nt"&gt;--config&lt;/span&gt; mcpr.toml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't want to self-host, &lt;a href="https://cloud.mcpr.app" rel="noopener noreferrer"&gt;&lt;strong&gt;MCPR Cloud&lt;/strong&gt;&lt;/a&gt; gives you a managed tunnel with a free subdomain. Claim yours at &lt;code&gt;cloud.mcpr.app&lt;/code&gt; and start proxying in minutes — CSP handled, auth included, every tool call observable.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/cptrodgers/mcpr" rel="noopener noreferrer"&gt;Star us on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/getting-started/quickstart"&gt;Get started with MCPR&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;connectDomains&lt;/code&gt; = &lt;code&gt;fetch()&lt;/code&gt;, WebSocket, XHR (runtime connections)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;resourceDomains&lt;/code&gt; = images, fonts, scripts, stylesheets (static assets)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;frameDomains&lt;/code&gt; = nested iframes (use sparingly)&lt;/li&gt;
&lt;li&gt;Debug with DevTools Console — look for "Refused to" errors&lt;/li&gt;
&lt;li&gt;Google Fonts needs both &lt;code&gt;fonts.googleapis.com&lt;/code&gt; AND &lt;code&gt;fonts.gstatic.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Test on every platform you ship to — they're all slightly different&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>mcp</category>
      <category>csp</category>
      <category>chatgpt</category>
    </item>
  </channel>
</rss>
