| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| export const UI_PROTOCOL_VERSION = '2026-01-26'; |
|
|
| |
| |
| export const UI_RESOURCE_MIME_TYPE = 'text/html;profile=mcp-app'; |
|
|
| |
| |
| |
| export const UI_CONNECT_DOMAINS = ['https://worldmonitor.app', 'https://www.worldmonitor.app'] as const; |
|
|
| |
| |
| |
| const UI_FRAME_ANCESTORS = ['https://chatgpt.com', 'https://claude.ai', 'https://claude.com'] as const; |
|
|
| |
| |
| |
| |
| |
| |
| export interface UiResourceMeta { |
| ui: { |
| csp: { |
| connectDomains: string[]; |
| resourceDomains: string[]; |
| frameDomains: string[]; |
| baseUriDomains: string[]; |
| }; |
| prefersBorder: boolean; |
| }; |
| } |
|
|
| |
| |
| |
| export function buildUiMeta(): UiResourceMeta { |
| return { |
| ui: { |
| csp: { |
| connectDomains: [...UI_CONNECT_DOMAINS], |
| resourceDomains: [], |
| frameDomains: [], |
| baseUriDomains: [], |
| }, |
| prefersBorder: true, |
| }, |
| }; |
| } |
|
|
| |
| |
| |
| |
| 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'"; |
|
|
| |
| |
| |
| 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); } |
| `; |
|
|
| |
| |
| |
| |
| 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(); |
| } |
| `; |
|
|
| |
| |
| |
| |
| |
| 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 { |
| |
| title: string; |
| appName: string; |
| |
| styles: string; |
| |
| |
| body: string; |
| |
| |
| |
| |
| renderBody: string; |
| } |
|
|
| |
| |
| |
| 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>`; |
| } |
|
|