File size: 16,132 Bytes
3464008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
// MCP Apps (extension `io.modelcontextprotocol/ui`, spec 2026-01-26) β€” the
// SHARED app-shell foundation for the WorldMonitor interactive-dashboard fleet.
//
// The first shipped widget (get_country_risk, v1.11.0) inlined its whole HTML β€”
// DOCTYPE, CSP, dark-mode CSS vars, and the ~150-line postMessage bridge β€” in
// api/mcp/ui/country-risk-app.ts. That file stays as the reference widget; this
// module factors the parts that MUST be identical across every widget (the
// bridge protocol, the 4-category CSP, uppercase DOCTYPE + color-scheme meta,
// the dark-mode theme handling, and the size-reporting handshake) into one
// `buildAppHtml` builder so the fleet can't drift on the orank quality/CSP
// signals a scanner reads statically off each served shell.
//
// Bridge protocol (raw JSON-RPC 2.0 over `window.postMessage(msg, "*")`, no
// envelope), per the extension β€” identical to the country-risk reference:
//   View β†’ Host  request : `ui/initialize` {appInfo, appCapabilities, protocolVersion}
//   Host β†’ View  result  : {hostCapabilities, hostInfo, hostContext}
//   View β†’ Host  notify  : `ui/notifications/initialized`
//   Host β†’ View  notify  : `ui/notifications/tool-input`  {arguments}
//   Host β†’ View  notify  : `ui/notifications/tool-result` (a CallToolResult)
//   View β†’ Host  notify  : `ui/notifications/size-changed` {height}
//
// Incoming messages are gated on `event.source === window.parent` β€” the sandbox
// origin is opaque ("null"), so a source-identity check is the available trust
// boundary. Rendering ALWAYS uses `textContent` / numeric coercion (never
// innerHTML), so a hostile tool payload cannot inject markup.
//
// Authoring constraint: each widget's `styles`, `body`, and `renderBody` are
// interpolated into the outer template below. The SHARED bridge carries no
// `${` sequences; per-widget `renderBody` strings must likewise avoid backticks
// and `${` so the outer TS template literal stays un-escaped.

export const UI_PROTOCOL_VERSION = '2026-01-26';

// The MCP-Apps UI resource mimeType is EXACTLY `text/html;profile=mcp-app` (the
// extension's content profile) β€” NOT `text/html+skybridge` (OpenAI Apps SDK).
export const UI_RESOURCE_MIME_TYPE = 'text/html;profile=mcp-app';

// The MCP server origin(s) the views connect back to. Mirrored into BOTH the
// HTML `<meta http-equiv>` CSP connect-src and the spec-native
// `_meta.ui.csp.connectDomains` so a host learns the identical policy two ways.
export const UI_CONNECT_DOMAINS = ['https://worldmonitor.app', 'https://www.worldmonitor.app'] as const;

// The agent hosts allowed to embed a shell (advisory in a <meta> CSP β€” browsers
// honor frame-ancestors only via HTTP header β€” but the static scanner reads it
// here, and it documents intent).
const UI_FRAME_ANCESTORS = ['https://chatgpt.com', 'https://claude.ai', 'https://claude.com'] as const;

// Per-resource `_meta.ui` (ext-apps `UIResourceMeta`). `connectDomains` mirrors
// the HTML meta's connect-src (the MCP server origin β€” the app's data ultimately
// originates there); the other allowlists stay empty (the secure default) because
// every widget loads no external assets, embeds no frames, and needs no external
// base URI (postMessage + inline CSS/JS only). `prefersBorder` asks the host to
// frame the card.
export interface UiResourceMeta {
  ui: {
    csp: {
      connectDomains: string[];
      resourceDomains: string[];
      frameDomains: string[];
      baseUriDomains: string[];
    };
    prefersBorder: boolean;
  };
}

// SINGLE source of truth for every widget's `_meta.ui` β€” shared so the fleet's
// CSP policy can't drift entry-to-entry. Returns a fresh object per call so a
// mutating consumer can't poison siblings.
export function buildUiMeta(): UiResourceMeta {
  return {
    ui: {
      csp: {
        connectDomains: [...UI_CONNECT_DOMAINS],
        resourceDomains: [],
        frameDomains: [],
        baseUriDomains: [],
      },
      prefersBorder: true,
    },
  };
}

// The 4-category CSP shared by every shell. default-src 'none' earns full orank
// credit over a permissive default; script/style-src 'unsafe-inline' keeps the
// inline bridge + styles working; connect-src pins the MCP origin; frame-ancestors
// allowlists the embedding agent hosts; form-action + base-uri are locked.
const SHARED_CSP =
  "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
  "img-src 'self' data:; connect-src 'self' " + UI_CONNECT_DOMAINS.join(' ') + '; ' +
  'frame-ancestors ' + UI_FRAME_ANCESTORS.join(' ') + "; form-action 'none'; base-uri 'none'";

// The shared dark-mode design tokens every widget inherits. A widget's own
// `styles` block is appended AFTER these, so it can add component styles and
// override any token.
const SHARED_STYLE_TOKENS = `
  :root {
    --bg: #ffffff; --fg: #0f172a; --muted: #64748b; --card: #f8fafc;
    --border: #e2e8f0; --accent: #2563eb;
    --low: #16a34a; --moderate: #ca8a04; --high: #ea580c; --severe: #dc2626;
    --up: #16a34a; --down: #dc2626;
  }
  [data-theme="dark"] {
    --bg: #0b1220; --fg: #e5e7eb; --muted: #94a3b8; --card: #131c2e;
    --border: #1e293b; --accent: #60a5fa;
  }
  * { box-sizing: border-box; }
  html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg);
    font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
  .wrap { padding: 16px; max-width: 560px; }
  .head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
  .title { font-size: 20px; font-weight: 650; letter-spacing: 0.2px; }
  .badge { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
  .empty { color: var(--muted); padding: 8px 0; }
  .foot { margin-top: 14px; font-size: 11px; color: var(--muted); }
  a { color: var(--accent); text-decoration: none; }
  .pbar { height: 6px; border-radius: 999px; background: var(--border); overflow: hidden; margin-top: 5px; }
  .pbar > span { display: block; height: 100%; width: 0%; background: var(--accent); }
`;

// The shared bridge + helper library. Injected once per shell. It exposes a
// small helper set (q/num/listState/clampPct/pctText/setText/el/cssVar) in closure scope
// that a widget's `renderBody` uses, then calls the widget-defined
// `renderData(data)` on every tool-result. NOTE: no `${` / backtick here.
const SHARED_BRIDGE_HEAD = `
(function () {
  "use strict";
  var parentWin = window.parent;

  function post(msg) {
    try { parentWin.postMessage(msg, "*"); } catch (e) { /* host gone */ }
  }
  function notify(method, params) {
    post({ jsonrpc: "2.0", method: method, params: params || {} });
  }

  // ---- shared render helpers (widget renderBody uses these) ----
  function q(id) { return document.getElementById(id); }
  function num(v) {
    if (v == null) return null;
    var n = typeof v === "number" ? v : Number(v);
    return isFinite(n) ? n : null;
  }
  // Normalise both full array fields and summary:true fields shaped as
  // { count, sample }. The available flag remains false for absent/null/malformed
  // fields so widgets can distinguish a partial cache miss from a real empty
  // array (including { count: 0, sample: [] }).
  function listState(v) {
    if (Array.isArray(v)) return { available: true, items: v };
    if (v && typeof v === "object" && Array.isArray(v.sample)) {
      return { available: true, items: v.sample };
    }
    return { available: false, items: [] };
  }
  function clampPct(n) { return Math.max(0, Math.min(100, n)); }
  function setText(id, text) { var e = q(id); if (e) e.textContent = text == null ? "β€”" : String(text); }
  function el(tag, cls, text) {
    var e = document.createElement(tag);
    if (cls) e.className = cls;
    if (text != null) e.textContent = String(text);
    return e;
  }
  // Shared 0-100 probability bar: a .pbar node with a filled span, or null when
  // pct is not a finite number. Callers pass an already-0-100 value and append
  // the returned node under a market / forecast row.
  function probabilityBar(pct) {
    if (typeof pct !== "number" || !isFinite(pct)) return null;
    var bar = el("div", "pbar");
    var fill = el("span");
    fill.style.width = clampPct(pct) + "%";
    bar.appendChild(fill);
    return bar;
  }
  function cssVar(name) {
    return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
  }
  function pctText(v) {
    var n = num(v);
    if (n == null) return "β€”";
    return (n > 0 ? "+" : "") + n.toFixed(2) + "%";
  }
  function levelFor(score) {
    if (typeof score !== "number" || isNaN(score)) return { label: "Unknown", varName: "--muted" };
    if (score >= 75) return { label: "Severe", varName: "--severe" };
    if (score >= 50) return { label: "High", varName: "--high" };
    if (score >= 25) return { label: "Moderate", varName: "--moderate" };
    return { label: "Low", varName: "--low" };
  }
  // Text helpers centralised here so widget renderBody stays regex/escape-free.
  function collapseWs(s) { return String(s == null ? "" : s).replace(/\\s+/g, " ").trim(); }
  function paragraphs(s) {
    return String(s == null ? "" : s)
      .split(/\\n\\s*\\n/)
      .map(function (p) { return collapseWs(p); })
      .filter(Boolean);
  }
  function httpUrl(u) {
    if (typeof u !== "string") return "";
    try {
      var parsed = new URL(u.trim());
      return (parsed.protocol === "http:" || parsed.protocol === "https:") ? parsed.href : "";
    } catch (e) { return ""; }
  }
  function countryName(code) {
    var c = String(code == null ? "" : code).toUpperCase().slice(0, 2);
    if (!c) return "";
    try {
      var n = new Intl.DisplayNames(["en"], { type: "region" }).of(c);
      return n || c;
    } catch (e) { return c; }
  }

  function reportSize() {
    var root = q("root");
    if (!root) return;
    var h = Math.ceil(root.getBoundingClientRect().height) + 8;
    notify("ui/notifications/size-changed", { height: h });
  }

  function extractToolData(result) {
    if (!result || typeof result !== "object") return null;
    if (result.structuredContent && typeof result.structuredContent === "object") {
      return result.structuredContent;
    }
    if (Array.isArray(result.content)) {
      for (var i = 0; i < result.content.length; i++) {
        var c = result.content[i];
        if (c && c.type === "text" && typeof c.text === "string") {
          try { return JSON.parse(c.text); } catch (e) { /* not JSON */ }
        }
      }
    }
    return null;
  }

  function applyTheme(hostContext) {
    var theme = hostContext && hostContext.theme;
    if (theme === "dark" || theme === "light") {
      document.documentElement.setAttribute("data-theme", theme);
    } else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
      document.documentElement.setAttribute("data-theme", "dark");
    }
  }

  // Soft-error envelopes are SUCCESSFUL tools/call results (HTTP 200, valid
  // JSON) that carry an error sentinel instead of renderable data β€” the
  // dispatcher returns { _budget_exceeded, ... } when the tool output exceeds
  // its byte budget, and a bad jmespath projection returns { _jmespath_error,
  // ... }. A few tools also surface user-input faults as a result-level
  // { error: "..." } string. Rendering any of these through renderData() shows
  // a blank / empty-success dashboard, so detect them and surface a visible
  // message instead. Returns the message string, or null when data is genuinely
  // renderable.
  function softError(data) {
    if (!data || typeof data !== "object") return null;
    if (data._budget_exceeded === true) {
      return "This response is too large to display here. Narrow the request (fewer items, or a jmespath projection) and try again.";
    }
    if (data._jmespath_error) {
      return "The response projection could not be applied, so there is nothing to render. Remove the jmespath argument and retry.";
    }
    if (typeof data.error === "string" && data.error) return data.error;
    return null;
  }
  // Every fleet widget owns an #empty placeholder and an #card body; on a soft
  // error we reuse #empty as the error slot (hide the card) so the message is
  // visible regardless of which widget is mounted.
  function showError(msg) {
    var card = q("card");
    if (card) card.style.display = "none";
    var empty = q("empty");
    if (empty) { empty.textContent = msg; empty.style.display = "block"; }
  }

  function safeRender(data) {
    var errMsg = softError(data);
    if (errMsg) { showError(errMsg); reportSize(); return; }
    try { renderData(data); } catch (e) { /* never break the host on a bad payload */ }
    reportSize();
  }
`;

// The bridge tail interpolates the per-widget appInfo.name directly (no
// placeholder sentinel), so a widget whose renderBody happens to contain the
// old `__APP_NAME__` token can't leak it into the served HTML. `appName` is
// JSON.stringified at the call site β€” it becomes a string literal in the
// emitted JS.
function renderBridgeTail(appName: string): string {
  return `
  window.addEventListener("message", function (event) {
    if (event.source !== parentWin) return;
    var msg = event.data;
    if (!msg || typeof msg !== "object" || msg.jsonrpc !== "2.0") return;

    if (msg.id === 1 && msg.result) {
      applyTheme(msg.result.hostContext);
      notify("ui/notifications/initialized", {});
      reportSize();
      return;
    }

    switch (msg.method) {
      case "ui/notifications/tool-result": {
        var data = extractToolData(msg.params && msg.params.result ? msg.params.result : msg.params);
        if (data) safeRender(data);
        break;
      }
      case "ui/notifications/tool-input":
        break;
      case "ui/notifications/host-context-changed":
        applyTheme(msg.params && msg.params.hostContext ? msg.params.hostContext : msg.params);
        break;
      default:
        break;
    }
  });

  applyTheme(null);

  post({
    jsonrpc: "2.0",
    id: 1,
    method: "ui/initialize",
    params: {
      protocolVersion: "${UI_PROTOCOL_VERSION}",
      appInfo: { name: ${JSON.stringify(appName)}, version: "1.0.0" },
      appCapabilities: {}
    }
  });
})();
`;
}

export interface AppShellSpec {
  // Page <title> and the app-shell identity reported in ui/initialize.appInfo.name.
  title: string;
  appName: string;
  // Component CSS appended after the shared design tokens.
  styles: string;
  // Body markup placed inside <div class="wrap" id="root">. Owns its own
  // empty-state + card elements.
  body: string;
  // JS body of `function renderData(data) { ... }`. Runs inside the shared
  // bridge closure with access to q/num/listState/setText/el/cssVar/pctText/clampPct/
  // levelFor/collapseWs/paragraphs/httpUrl/countryName/probabilityBar. MUST
  // avoid backticks and `${`.
  renderBody: string;
}

// Assemble a complete, self-contained MCP-Apps shell. Every widget goes through
// here so DOCTYPE, color-scheme, CSP, theme handling, and the bridge stay
// byte-consistent across the fleet.
export function buildAppHtml(spec: AppShellSpec): string {
  const bridge =
    SHARED_BRIDGE_HEAD +
    '\n  function renderData(data) {\n' + spec.renderBody + '\n  }\n' +
    renderBridgeTail(spec.appName);

  return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- MCP Apps view quality: uppercase DOCTYPE + color-scheme so the host renders
     light/dark correctly (orank mcp-apps-ui-quality + mcp-view-domain checks). -->
<meta name="color-scheme" content="light dark">
<!-- MCP Apps view CSP (orank mcp-view-csp): all 4 required directive categories
     scoped. Shared across the widget fleet via api/mcp/ui/shell.ts. -->
<meta http-equiv="Content-Security-Policy" content="${SHARED_CSP}">
<title>${spec.title}</title>
<style>${SHARED_STYLE_TOKENS}${spec.styles}</style>
</head>
<body>
<div class="wrap" id="root">
${spec.body}
</div>
<script>${bridge}</script>
</body>
</html>`;
}