TL;DR: Learn how to break free from polling and push live data directly into your Sales Dashboard MCP App using WebSocket and connectedDomains, on Goose open source AI Agent.
Code Repository for this article - Here
This article is a part of my series of articles on MCP Apps:
- The Foundations of MCP Apps
- Building MCP Apps based Sales Analytics Agentic UI & deploying it on Amazon Bedrock AgentCore
- Adding WebSocket-Powered Realtime Streaming to MCP Apps (This article)
In my previous article, we built a complete sales analytics chatflow using MCP Apps — interactive forms, chart visualizations, and PDF reports, all rendered inside the AI Agent chat window. The entire chatflow relied on a request-response pattern: the MCP App calls a tool, the server returns data, the UI renders it.
But what happens when you need live data — a dashboard that updates every couple of seconds, showing real-time KPIs, live transaction feeds, and state-level rankings that shift as new sales come in?
The naive approach is polling: call the MCP tool on a setInterval, render the result, repeat. Every 2 seconds, the sandboxed iframe would fire a tools/call to get-realtime-sales-snapshot via the postMessage JSON-RPC bridge:
It worked! But each round-trip adds latency, and the host is doing unnecessary work relaying data that the UI could receive directly.
What we really want is a push model — a persistent connection where a backend pushes fresh data the moment it's ready.
That's where connectedDomains comes in.
connectedDomains in MCP Apps CSP
MCP Apps run inside sandboxed iframes. By default, they can't make outbound network requests - no fetch, no XMLHttpRequest, no WebSocket. This is by design; the sandbox isolates the app from the outside world.
But the MCP Apps spec provides an escape hatch through Content Security Policy (CSP) fields declared in the resource's _meta.ui.csp. In Part 1, we used resourceDomains to load Chart.js from a CDN. Now, we'll use a different field:
-
resourceDomains— allows loading scripts, stylesheets, images (used for CDN assets) -
connectedDomains— allows outbound connections:fetch,XMLHttpRequest, and WebSocket
This is the single line that unlocks realtime streaming for our MCP App.
Architecture
After (WebSocket push):
Direct connection, no host relay, server pushes data!
The MCP App still performs the ui/initialize handshake with the host (that's required for iframe integration), but all data flows over a direct WebSocket connection to a separate lightweight backend.
Step 1: Declare connectedDomains in the Resource
The first change is in the MCP server's resource registration. When we return the dashboard HTML, we include connectedDomains in the CSP metadata:
// index.ts — Resource registration
server.registerResource(
"realtime-sales-dashboard-ui",
REALTIME_DASHBOARD_URI,
{
mimeType: MIME,
description: "Live sales dashboard with KPI cards, state rankings, and activity feed",
},
async (uri) => {
return {
contents: [
{
uri: uri.href,
mimeType: MIME,
text: REALTIME_DASHBOARD_UI(),
_meta: {
ui: {
prefersBorder: false,
csp: {
resourceDomains: ["https://cdn.jsdelivr.net"],
connectedDomains: ["ws://localhost:8765"],
},
},
},
},
],
};
}
);
That one addition — connectedDomains: ["ws://localhost:8765"] — tells the MCP host: "This app needs to open WebSocket connections to localhost:8765. Please allow it in the iframe's CSP."
Without this, the browser would block the new WebSocket() call with a CSP violation. With it, the iframe gets a targeted connect-src directive that permits exactly this one origin.
Step 2: Build a Lightweight Python WebSocket Server
The data source is a standalone Python script using the websockets library. It ports the simulation logic from the TypeScript data layer into Python — state multipliers, metric nudging, activity feed generation, KPI aggregation — and pushes snapshots over WebSocket.
# ws_server.py — Core handler
@dataclass
class ClientState:
metric: str = "revenue"
state: str = "ALL"
paused: bool = False
async def handler(websocket):
"""Handle a single WebSocket client connection."""
client = ClientState()
# Start push loop as a background task
push_task = asyncio.create_task(_push_loop(websocket, client))
async for raw_message in websocket:
msg = json.loads(raw_message)
msg_type = msg.get("type", "")
if msg_type == "filter":
client.metric = msg.get("metric", client.metric)
client.state = msg.get("state", client.state)
# Send immediate snapshot on filter change
snapshot = build_snapshot(client.metric, client.state)
await websocket.send(json.dumps(snapshot))
elif msg_type == "pause":
client.paused = True
elif msg_type == "resume":
client.paused = False
snapshot = build_snapshot(client.metric, client.state)
await websocket.send(json.dumps(snapshot))
async def _push_loop(websocket, client):
"""Periodically push snapshots to a connected client."""
while True:
if not client.paused:
snapshot = build_snapshot(client.metric, client.state)
await websocket.send(json.dumps(snapshot))
await asyncio.sleep(1.8)
Key design decisions:
Per-client filter state: Each connected iframe gets its own
ClientState. When the user changes the metric dropdown from "Revenue" to "Orders", the client sends{ type: "filter", metric: "orders" }and the server immediately pushes a snapshot with the new filter applied.Pause/Resume over the wire: Instead of just suppressing renders on the client, pause/resume messages stop the server-side push loop for that client — saving bandwidth.
Shared simulation state: The simulation (metric nudging, activity feed) runs globally. All clients see the same underlying market data; they just filter it differently.
Step 3: Connect from the MCP App
Inside the dashboard's <script> block, the initialize() function now calls connectWebSocket() instead of starting a polling timer:
// --- WebSocket state ---
var WS_URL = 'ws://localhost:8765';
var ws = null;
var wsConnected = false;
var wsReconnectDelay = 500;
function connectWebSocket() {
ws = new WebSocket(WS_URL);
ws.onopen = function() {
wsConnected = true;
wsReconnectDelay = 500;
// Send current filter state on connect
wsSend({ type: 'filter', metric: currentMetric, state: currentState });
};
ws.onmessage = function(event) {
var snapshot = JSON.parse(event.data);
if (snapshot && Array.isArray(snapshot.kpis)) {
renderSnapshot(snapshot);
}
};
ws.onclose = function() {
wsConnected = false;
scheduleWsReconnect();
};
}
function scheduleWsReconnect() {
setTimeout(function() {
connectWebSocket();
wsReconnectDelay = Math.min(wsReconnectDelay * 2, 8000);
}, wsReconnectDelay);
}
The rendering functions — renderKpis(), renderStateChart(), renderActivityFeed(), drawSparkline() — remain completely unchanged.
User interactions are routed through the WebSocket:
function onMetricChange(value) {
currentMetric = value;
resetKpiTraces();
wsSend({ type: 'filter', metric: currentMetric, state: currentState });
}
function togglePause() {
isPaused = !isPaused;
wsSend({ type: isPaused ? 'pause' : 'resume' });
}
No more callTool. No more postMessage relay. The UI talks directly to the data source.
The Message Protocol
The WebSocket communication uses a simple JSON protocol:
Client → Server (upstream):
{ "type": "filter", "metric": "orders", "state": "MH" }
{ "type": "pause" }
{ "type": "resume" }
Server → Client (downstream):
{
"generatedAt": "2026-06-10T07:00:00Z",
"metric": "orders",
"metricLabel": "Orders",
"state": "MH",
"stateLabel": "Maharashtra",
"kpis": [
{ "metric": "revenue", "label": "Revenue", "valueRaw": 180000, "valueFormatted": "₹1.8L" },
...
],
"stateRankings": [
{ "code": "MH", "name": "Maharashtra", "valueRaw": 1800, "valueFormatted": "1,800 orders" },
...
],
"activity": [
{ "type": "order", "label": "New Order", "city": "Mumbai", "amountText": "₹4,200", ... },
...
]
}
Running It
# Terminal 1: Start the Python WebSocket server
pip3 install websockets
python3 ws_server.py
# Terminal 2: Start the MCP server
npm run dev
# MCP live at
# http://localhost:3000/mcp
Let us take Goose as our AI client.
aaif-goose
/
goose
an open source, extensible AI agent that goes beyond code suggestions - install, execute, edit, and test with any LLM
🦆 goose has moved! This project has moved from
block/gooseto the Agentic AI Foundation (AAIF) at the Linux Foundation. Some links and references are still being updated — please bear with us during the transition.
goose
your native open source AI agent — desktop app, CLI, and API — for code, workflows, and everything in between
goose is a general-purpose AI agent that runs on your machine. Not just for code — use it for research, writing, automation, data analysis, or anything you need to get done.
A native desktop app for macOS, Linux, and Windows. A full CLI for terminal workflows. An API to embed it anywhere. Built in Rust for performance and portability.
goose works with 15+ providers — Anthropic, OpenAI, Google, Ollama, OpenRouter, Azure, Bedrock, and more. Use API keys or your existing Claude, ChatGPT, or Gemini subscriptions via ACP. Connect to 70+ extensions via…
Goose supports various AI providers:
We will use Amazon Bedrock as our LLM Provider.
Trigger the show-realtime-sales-dashboard tool using prompt show dashboard. The dashboard connects via WebSocket, and you'll see KPIs, charts, and the activity feed updating in real-time — with sparkline traces accumulating over time.
When to Use This Pattern
Not every MCP App needs a WebSocket. If your app does a one-time data fetch — like the sales visualization or PDF report from the previous article — the tools/call pattern is simpler and perfectly adequate.
Use WebSocket + connectedDomains when:
- Data changes frequently — live dashboards, monitoring, real-time feeds
- Server should control the push cadence — you want the server to decide when to send updates, not the client polling at arbitrary intervals
- You want to reduce host load — polling via MCP tools means the host relays every request; WebSocket bypasses this
- Bidirectional communication is needed — the client needs to send filter changes, pause/resume signals, or other commands back to the data source
Conclusion
This was a fun exploration of what becomes possible when you combine MCP Apps' sandbox security model with targeted CSP relaxations. The connectedDomains field is a small addition to the spec, but it opens the door to an entirely different class of MCP App — one that can maintain persistent connections to external data sources.
If you want to try this yourself, the complete source code is available in the GitHub repo. The Python WebSocket server is in ws_server.py, and the dashboard UI changes are in src/ui/realtime-dashboard.ts.
by Ashita Prasad (GitHub, LinkedIn, X, Instagram)
Disclaimer: The opinions expressed here are my own and do not necessarily represent those of current or past employers. Please note that you are solely responsible for your judgement on checking facts. This post does not monetize via any advertising.





Top comments (0)