Run God's Eye View inside the MCP Apps panel instead of framing it

Hosts such as Claude do not let a panel frame other sites, so the panel
now loads the app's page, styles and scripts into itself in inline embed
mode, and declares the app and map providers as resource and connect
domains. The dev server answers panel origins with CORS.
This commit is contained in:
Sameh Khamis committed 2026-10-02 15:21:37 -07:00
1 parent cb04fd5b19
commit 63341b947e
6 files changed
+185 -55

No files matched your search

+103 -45
View File
@@ -1,20 +1,47 @@
/**
* 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 loads
* the app in embed mode and sends it each view the show_in_gods_eye_view tool
* returns; see docs/TOOLS.md.
* 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.
*/
export const GLOBE_PANEL_URI = 'ui://gods-eye-view/globe';
/**
* 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.
*/
const PROVIDER_ORIGINS = Object.freeze([
'https://tile.googleapis.com',
'https://maps.googleapis.com',
'https://api.cesium.com',
'https://assets.ion.cesium.com',
'https://assets.cesium.com',
'https://dev.virtualearth.net',
'https://ecn.t0.tiles.virtualearth.net',
'https://ecn.t1.tiles.virtualearth.net',
'https://ecn.t2.tiles.virtualearth.net',
'https://ecn.t3.tiles.virtualearth.net',
'https://services.arcgisonline.com',
'https://server.arcgisonline.com',
'https://tile.openstreetmap.org',
'https://tiles.openfreemap.org',
'https://gibs.earthdata.nasa.gov',
'https://fonts.googleapis.com',
'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;
/**
* The panel page. It completes the MCP Apps handshake with its host, then
* for each tool result that carries a view: loads the app at that view in
* embed mode the first time, and afterwards posts the view to the loaded app
* so the globe changes without reloading.
* 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.
*/
function panelHtml(appOrigin) {
return `<!doctype html>
@@ -25,9 +52,9 @@ function panelHtml(appOrigin) {
<style>
html, body { margin: 0; height: ${PANEL_HEIGHT_PX}px; background: #05070a; color: #b8c4cc;
font: 13px/1.4 system-ui, sans-serif; overflow: hidden; }
#globe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
#status { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
#open { position: absolute; right: 10px; top: 10px; z-index: 1; padding: 6px 10px; border: 1px solid #3a4a55;
#status { position: absolute; inset: 0; z-index: 10000; display: flex; align-items: center;
justify-content: 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; }
</style>
@@ -61,21 +88,43 @@ function panelHtml(appOrigin) {
});
const notify = (method, params) => send({ method, params });
let frame = null;
let frameReady = false;
let started = false;
let ready = false;
let queued = null;
let currentUrl = null;
const embedUrl = (url) => {
const target = new URL(url);
target.searchParams.set('embed', '1');
return target.href;
};
const postView = (view) =>
frame.contentWindow.postMessage(
{ type: 'gev:view', id: nextId++, view },
APP_ORIGIN,
);
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
/**
* Load God's Eye View into this page. Hosts may not frame other sites, so
* the panel runs the app itself: relative addresses resolve to the app's
* server through <base>, the app's styles and markup are copied in, and
* its scripts run here with the app in inline embed mode.
*/
async function startApp() {
window.GEV_EMBED_INLINE = true;
const base = document.createElement('base');
base.href = APP_ORIGIN + '/';
document.head.prepend(base);
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(document.importNode(node, true));
for (const node of [...page.body.childNodes]) {
if (node.nodeName !== 'SCRIPT')
document.body.insertBefore(document.importNode(node, true), 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'), base.href).href;
else script.textContent = original.textContent;
document.body.appendChild(script);
}
}
function show(result) {
const data = result && result.structuredContent;
@@ -84,33 +133,30 @@ function panelHtml(appOrigin) {
if (!view || !url || new URL(url).origin !== APP_ORIGIN) return;
currentUrl = url;
open.hidden = false;
if (!frame) {
frame = document.createElement('iframe');
frame.id = 'globe';
frame.title = "God's Eye View";
frame.allow = 'fullscreen';
frame.src = embedUrl(url);
document.body.appendChild(frame);
status.textContent = "Loading God's Eye View…";
// Say so, rather than wait forever, when the app cannot load here.
setTimeout(() => {
if (!frameReady)
status.textContent =
"God's Eye View did not load here" +
(blocked ? ' (' + blocked + ')' : '') +
". Use Open in God's Eye View above.";
}, LOAD_TIMEOUT_MS);
return;
}
if (frameReady) postView(view);
else queued = view;
if (ready) return postView(view);
queued = view;
if (started) return;
started = true;
status.textContent = "Loading God's Eye View…";
startApp().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 (frame && event.source === frame.contentWindow) {
if (event.source === window) {
if (message && message.type === 'gev:ready') {
frameReady = true;
ready = true;
status.remove();
if (queued) postView(queued);
queued = null;
@@ -161,8 +207,9 @@ function panelHtml(appOrigin) {
}
/**
* The panel as an MCP resource for an app served at `appUrl`. Only that
* origin may be framed inside the panel.
* 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.
*/
export function createGlobePanelResource({ appUrl }) {
const appOrigin = new URL(appUrl).origin;
@@ -174,7 +221,18 @@ export function createGlobePanelResource({ appUrl }) {
mimeType: MCP_APP_MIME_TYPE,
text: panelHtml(appOrigin),
_meta: {
ui: { csp: { frameDomains: [appOrigin] }, prefersBorder: true },
ui: {
csp: {
resourceDomains: [appOrigin, ...PROVIDER_ORIGINS],
connectDomains: [
appOrigin,
socketOrigin(appOrigin),
...PROVIDER_ORIGINS,
],
baseUriDomains: [appOrigin],
},
prefersBorder: true,
},
},
});
}