Load the God's Eye View panel through the MCP server

Hosts serve panels from their own sites and may refuse other addresses;
Codex refuses any address on the user's machine. The panel now never
requests the app's server: it loads the app's panel build and data
through panel_request, a tool only the panel may call, which requests
the path from the app's server. One path in every host; only map
providers load directly.

npm run build:panel writes the panel build to dist/panel, served at
/panel/: one app script, one stylesheet, Cesium's script, and Cesium's
workers as one script Cesium runs from memory. Tools may now be marked
app-only. The panel CORS rules are gone.
This commit is contained in:
Sameh Khamis committed 2026-10-02 18:29:42 -07:00
1 parent b11d54d8b8
commit 996b95eb45
20 files changed
+1265 -437

No files matched your search

+42 -296
View File
@@ -1,15 +1,21 @@
/**
* The God's Eye View panel: an MCP Apps view (`io.modelcontextprotocol/ui`) that shows
* God's Eye View inside a conversation. The panel is a small page that runs
* the app inside itself in embed mode and sends it each view the
* show_in_gods_eye_view tool returns; see docs/TOOLS.md.
* the app's panel build inside itself in embed mode, loading it through the
* MCP server, and sends it each view the show_in_gods_eye_view tool returns;
* see docs/TOOLS.md.
*/
export const GLOBE_PANEL_URI = 'ui://gods-eye-view/globe';
/** Where the app's server serves its panel build (see build/panel.js). */
export const PANEL_BASE = '/panel/';
/** Cesium's workers as one script, relative to the panel build. */
export const PANEL_WORKERS_PATH = 'cesium/workers.js';
/**
* Sites the app's browser code loads from directly: map imagery, 3D tiles,
* terrain and fonts. Everything else goes through the app's own server.
* terrain and fonts. Everything from the app's own server comes through the
* MCP server.
*/
const PROVIDER_ORIGINS = Object.freeze([
'https://tile.googleapis.com',
@@ -31,19 +37,31 @@ const PROVIDER_ORIGINS = Object.freeze([
'https://fonts.gstatic.com',
]);
/** The WebSocket origin matching an http(s) origin, for live-reload. */
const socketOrigin = (origin) => origin.replace(/^http/, 'ws');
export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
const MCP_APPS_PROTOCOL_VERSION = '2026-01-26';
const PANEL_HEIGHT_PX = 520;
const LOAD_TIMEOUT_MS = 90_000;
/** The panel-only tool the panel loads the app through. */
const PANEL_REQUEST_TOOL = 'panel_request';
/**
* The panel page. It completes the MCP Apps handshake with its host, then
* for each tool result that carries a view: loads the app in embed mode the
* first time, and posts the view to the app once it is ready, so later views
* change the globe without reloading.
* The panel page: its status line, the Open in God's Eye View button, and
* the panel's script, `runtime`, which loads the app through the MCP server
* and shows each view a tool returns.
*/
function panelHtml(appOrigin) {
function panelHtml(runtime) {
const config = {
loadTimeoutMs: LOAD_TIMEOUT_MS,
panelBase: PANEL_BASE,
panelHeight: PANEL_HEIGHT_PX,
protocolVersion: MCP_APPS_PROTOCOL_VERSION,
toolName: PANEL_REQUEST_TOOL,
workersPath: PANEL_WORKERS_PATH,
};
const script = `(${runtime})(${JSON.stringify(config)});`.replace(
/<\/script/gi,
'<\\/script',
);
return `<!doctype html>
<html lang="en">
<head>
@@ -53,7 +71,7 @@ function panelHtml(appOrigin) {
html, body { margin: 0; height: ${PANEL_HEIGHT_PX}px; background: #05070a; color: #b8c4cc;
font: 13px/1.4 system-ui, sans-serif; overflow: hidden; }
#status { position: absolute; inset: 0; z-index: 10000; display: flex; align-items: center;
justify-content: center; }
justify-content: center; padding: 0 24px; text-align: center; }
#open { position: absolute; right: 10px; top: 10px; z-index: 10001; padding: 6px 10px; border: 1px solid #3a4a55;
border-radius: 6px; background: rgba(5, 7, 10, 0.75); color: #dfe8ee; font: inherit; cursor: pointer; }
#open[hidden] { display: none; }
@@ -62,310 +80,38 @@ function panelHtml(appOrigin) {
<body>
<div id="status">Waiting for a view…</div>
<button id="open" type="button" hidden>Open in God's Eye View</button>
<script>
(() => {
const APP_ORIGIN = ${JSON.stringify(appOrigin)};
const LOAD_TIMEOUT_MS = 20000;
const status = document.getElementById('status');
// When the host's security policy blocks part of the panel, say what was
// blocked; otherwise a refused frame only shows as a load that never ends.
let blocked = null;
document.addEventListener('securitypolicyviolation', (event) => {
blocked = event.effectiveDirective + ' blocked ' + (event.blockedURI || 'a resource');
if (status.isConnected)
status.textContent = "God's Eye View could not load here: " + blocked + '.';
});
const open = document.getElementById('open');
let nextId = 1;
const pending = new Map();
const send = (message) =>
window.parent.postMessage({ jsonrpc: '2.0', ...message }, '*');
const request = (method, params) =>
new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
send({ id, method, params });
});
const notify = (method, params) => send({ method, params });
let started = false;
let ready = false;
let queued = null;
let currentUrl = null;
const postView = (view) =>
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
// The panel page lives on the host's site, which serves none of the app.
// Hosts may refuse <base>, so the app's own addresses (relative ones, and
// absolute ones on this page's site) are sent to the app's server instead.
// The page's own site, by scheme and host: location.origin is "null" in a
// srcdoc document, and a host may serve the page from its own scheme.
const PAGE = new URL(document.baseURI);
const toApp = (value) => {
let url;
try {
url = new URL(String(value), document.baseURI);
} catch {
return value;
}
if (url.protocol !== PAGE.protocol || url.host !== PAGE.host) return value;
return APP_ORIGIN + url.pathname + url.search + url.hash;
};
const isRemote = (value) => {
try {
return new URL(String(value), document.baseURI).origin === APP_ORIGIN;
} catch {
return false;
}
};
function redirectRequests() {
const nativeFetch = window.fetch;
window.fetch = (input, init) =>
nativeFetch.call(
window,
input instanceof Request
? new Request(toApp(input.url), input)
: typeof input === 'string' || input instanceof URL
? toApp(input)
: input,
init,
);
const nativeOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
return nativeOpen.call(this, method, toApp(url), ...rest);
};
const nativeBeacon = navigator.sendBeacon?.bind(navigator);
if (nativeBeacon)
navigator.sendBeacon = (url, data) => nativeBeacon(toApp(url), data);
for (const [type, attribute] of [
[HTMLImageElement, 'src'],
[HTMLMediaElement, 'src'],
[HTMLSourceElement, 'src'],
[HTMLLinkElement, 'href'],
]) {
const property = Object.getOwnPropertyDescriptor(type.prototype, attribute);
Object.defineProperty(type.prototype, attribute, {
...property,
set(value) {
const target = toApp(value);
// The app treated the address as its own site; now that it is
// another site, images must load with CORS or WebGL refuses them.
if (target !== value && 'crossOrigin' in this && this.crossOrigin === null)
this.crossOrigin = 'anonymous';
property.set.call(this, target);
},
});
}
// Workers must come from this page's site; one on the app's server
// starts through a small worker here that imports it.
const NativeWorker = window.Worker;
window.Worker = function Worker(url, options) {
const target = toApp(url);
if (!isRemote(target)) return new NativeWorker(url, options);
const source =
options && options.type === 'module'
? 'import ' + JSON.stringify(String(target)) + ';'
: 'importScripts(' + JSON.stringify(String(target)) + ');';
return new NativeWorker(
URL.createObjectURL(new Blob([source], { type: 'text/javascript' })),
options,
);
};
window.Worker.prototype = NativeWorker.prototype;
// Styles the app adds while it runs name images by root-relative url().
const rewriteStyle = (style) => {
const text = style.textContent;
const next = text.replace(/url\\((['"]?)\\//g, 'url($1' + APP_ORIGIN + '/');
if (next !== text) style.textContent = next;
};
new MutationObserver((records) => {
for (const record of records) {
const target = record.target.nodeName === 'STYLE' ? [record.target] : [];
for (const node of [...target, ...record.addedNodes]) {
if (node.nodeName === 'STYLE') rewriteStyle(node);
// Markup the app adds as HTML text sets src without the setters.
if (node.nodeType !== 1) continue;
for (const element of [node, ...node.querySelectorAll('[src]')]) {
const value = element.getAttribute('src');
if (!value || toApp(value) === value) continue;
if ('crossOrigin' in element && element.crossOrigin === null)
element.crossOrigin = 'anonymous';
element.setAttribute('src', toApp(value));
}
}
}
}).observe(document.documentElement, {
childList: true,
subtree: true,
characterData: true,
});
}
/** Point an imported element's own addresses at the app's server. */
function absolutize(root) {
for (const element of [root, ...root.querySelectorAll('*')]) {
for (const attribute of ['src', 'href', 'poster']) {
const value = element.getAttribute(attribute);
if (value && !value.startsWith('#'))
element.setAttribute(attribute, new URL(value, APP_ORIGIN + '/').href);
}
}
return root;
}
/**
* Load God's Eye View into this page. Hosts may not frame other sites, so
* the panel runs the app itself: its requests go to the app's server, its
* styles and markup are copied in, and its scripts run here with the app
* in inline embed mode.
*/
async function startApp(url) {
window.GEV_EMBED_INLINE = true;
redirectRequests();
// Start at the first view, as its link would, instead of the default
// place; the app reads the view from this page's address.
try {
history.replaceState(null, '', location.href.split('#')[0] + new URL(url).hash);
} catch {
// The view still arrives once the app is ready.
}
const response = await fetch(APP_ORIGIN + '/?embed=1');
if (!response.ok) throw new Error('the app answered ' + response.status);
const page = new DOMParser().parseFromString(await response.text(), 'text/html');
for (const node of page.head.querySelectorAll('link[rel="stylesheet"], style'))
document.head.appendChild(absolutize(document.importNode(node, true)));
for (const node of [...page.body.childNodes]) {
if (node.nodeName === 'SCRIPT') continue;
const copy = document.importNode(node, true);
document.body.insertBefore(copy.nodeType === 1 ? absolutize(copy) : copy, status);
}
for (const original of page.querySelectorAll('script')) {
const script = document.createElement('script');
if (original.type) script.type = original.type;
if (original.getAttribute('src'))
script.src = new URL(original.getAttribute('src'), APP_ORIGIN + '/').href;
else script.textContent = original.textContent;
document.body.appendChild(script);
}
}
function show(result) {
const data = result && result.structuredContent;
const view = data && data.view;
const url = (data && data.url) || (view && view.url);
if (!view || !url || new URL(url).origin !== APP_ORIGIN) return;
currentUrl = url;
open.hidden = false;
if (ready) return postView(view);
queued = view;
if (started) return;
started = true;
status.textContent = "Loading God's Eye View…";
startApp(url).catch((error) => {
status.textContent =
"God's Eye View could not load here: " + (error && error.message) + '.';
});
// Say so, rather than wait forever, when the app cannot load here.
setTimeout(() => {
if (!ready)
status.textContent =
"God's Eye View did not load here" +
(blocked ? ' (' + blocked + ')' : '') +
". Use Open in God's Eye View above.";
}, LOAD_TIMEOUT_MS);
}
window.addEventListener('message', (event) => {
const message = event.data;
if (event.source === window) {
if (message && message.type === 'gev:ready') {
ready = true;
status.remove();
if (queued) postView(queued);
queued = null;
}
return;
}
if (event.source !== window.parent || !message || message.jsonrpc !== '2.0')
return;
if (message.id !== undefined && pending.has(message.id) && !message.method) {
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) reject(message.error);
else resolve(message.result);
return;
}
if (message.method === 'ui/notifications/tool-result') show(message.params);
});
open.addEventListener('click', () => {
if (!currentUrl) return;
request('ui/open-link', { url: currentUrl }).catch(() =>
window.open(currentUrl, '_blank', 'noopener'),
);
});
// The handshake the MCP Apps SDK's App.connect performs: hosts validate
// these parameters, and keep a view hidden until it is initialized.
request('ui/initialize', {
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
appInfo: { name: 'gods-eye-view', title: "God's Eye View", version: '1.0.0' },
protocolVersion: ${JSON.stringify(MCP_APPS_PROTOCOL_VERSION)},
}).then(() => {
send({ method: 'ui/notifications/initialized' });
notify('ui/notifications/size-changed', {
width: document.body.clientWidth,
height: ${PANEL_HEIGHT_PX},
});
}, (error) => {
status.textContent =
"This client did not accept the God's Eye View panel" +
(error && error.message ? ': ' + error.message : '.');
});
})();
</script>
<script>${script}</script>
</body>
</html>
`;
}
/**
* The panel as an MCP resource for an app served at `appUrl`. Its security
* policy lets the panel load the app's code from that origin and reach the
* map and imagery providers the app uses directly.
* The panel as an MCP resource. `runtime` is the panel's script, a function
* of its configuration that runs in the panel page; the application supplies
* it (src/app/globePanelRuntime.js), as it is browser code. The security
* policy lets the panel load map imagery, tiles and fonts from their
* providers; everything from the app's own server arrives through the MCP
* server instead.
*/
export function createGlobePanelResource({ appUrl }) {
const appOrigin = new URL(appUrl).origin;
export function createGlobePanelResource({ runtime }) {
if (typeof runtime !== 'function')
throw new TypeError('The globe panel needs its runtime script');
return Object.freeze({
uri: GLOBE_PANEL_URI,
name: 'globe',
title: "God's Eye View globe",
description: "Live God's Eye View, showing the view a tool returns.",
mimeType: MCP_APP_MIME_TYPE,
text: panelHtml(appOrigin),
text: panelHtml(runtime),
_meta: {
ui: {
csp: {
resourceDomains: [appOrigin, ...PROVIDER_ORIGINS],
connectDomains: [
appOrigin,
socketOrigin(appOrigin),
...PROVIDER_ORIGINS,
],
resourceDomains: [...PROVIDER_ORIGINS],
connectDomains: [...PROVIDER_ORIGINS],
},
prefersBorder: true,
},
// The same policy under the key OpenAI's clients read.
'openai/widgetCSP': {
connect_domains: [
appOrigin,
socketOrigin(appOrigin),
...PROVIDER_ORIGINS,
],
resource_domains: [appOrigin, ...PROVIDER_ORIGINS],
},
},
});
}