From 63341b947e84015dabb161e1a65e01438bb4538b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 09:58:26 -0700 Subject: [PATCH] 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. --- build/vite.js | 10 +++ src/app/embed.js | 25 ++++-- src/app/embed.test.mjs | 35 ++++++++ src/firstRunExperience.js | 3 +- src/tools/globePanel.js | 148 +++++++++++++++++++++++----------- src/tools/globePanel.test.mjs | 19 ++++- 6 files changed, 185 insertions(+), 55 deletions(-) diff --git a/build/vite.js b/build/vite.js index c683dbe03..977e25eb2 100644 --- a/build/vite.js +++ b/build/vite.js @@ -2,6 +2,12 @@ import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; import { embedFramingPlugin } from './embed-framing.js'; +/** Origins allowed to read the dev and preview servers' responses. */ +export const PANEL_CORS_ORIGINS = Object.freeze([ + /^https?:\/\/(?:(?:[^:]+\.)?localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/, + /^https:\/\/[a-z0-9]+\.claudemcpcontent\.com$/, +]); + /** Build browser assets with explicit inputs; never load environment or providers. */ export function createBrowserViteConfig({ plugins = [], @@ -44,6 +50,10 @@ export function createBrowserViteConfig({ fs: { deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'], }, + // Cross-origin reads: local pages, as Vite allows by default, plus the + // MCP Apps panels that load the app into a conversation (Claude's + // sandbox origins). Development and preview only. + cors: { origin: PANEL_CORS_ORIGINS }, // These headers protect the document containing Provider Settings. // Embed-mode documents are framable instead; see embed-framing.js. headers: { diff --git a/src/app/embed.js b/src/app/embed.js index 7892a9500..71566f54c 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -28,9 +28,21 @@ const FOLLOW_ATTEMPTS = 20; const FOLLOW_RETRY_MS = 1000; const FLIGHT_SECONDS = 2; -/** Whether this page was opened in embed mode. */ +/** + * Whether this page shows the app embedded: `?embed=1` when another page + * frames it, or inline when a panel page loads the app into itself and sets + * `globalThis.GEV_EMBED_INLINE` first. + */ export function isEmbedded(location = globalThis.location) { - return new URLSearchParams(location?.search || '').get('embed') === '1'; + return ( + isEmbeddedInline() || + new URLSearchParams(location?.search || '').get('embed') === '1' + ); +} + +/** Whether a panel page loaded the app into itself. */ +export function isEmbeddedInline() { + return globalThis.GEV_EMBED_INLINE === true; } const delay = (ms, signal) => @@ -157,14 +169,17 @@ export function installViews({ windowRef.document.body.classList.add('ui-embed'); shell.setCleanView?.(true); - const parent = windowRef.parent; + // A framing page talks to the app across frames; an inline panel shares + // the page with it and talks through the page's own window. + const peer = isEmbeddedInline() ? windowRef : windowRef.parent; const post = (message) => { - if (parent && parent !== windowRef) parent.postMessage(message, '*'); + if (peer && (peer !== windowRef || isEmbeddedInline())) + peer.postMessage(message, '*'); }; // Views apply one at a time, in the order they arrive. let queue = ready; const onMessage = (event) => { - if (event.source !== parent || event.data?.type !== EMBED_VIEW_MESSAGE) + if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE) return; const { id = null } = event.data; let view; diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index ebe9bfabc..b2e9b3c3f 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -152,6 +152,41 @@ test('an embedded page takes views only from its parent and answers it', async ( remove(); }); +test('an app loaded inline into a panel page talks through its own window', async () => { + const posted = []; + const windowRef = new EventTarget(); + windowRef.parent = windowRef; + windowRef.postMessage = (message) => posted.push(message); + windowRef.document = { body: { classList: new Set() } }; + windowRef.document.body.classList.add = Set.prototype.add; + globalThis.GEV_EMBED_INLINE = true; + try { + assert.equal(isEmbedded({ search: '' }), true); + const remove = installViews({ + shell: { initialRestorePromise: Promise.resolve() }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run: recorder().run, + location: { search: '', hash: '' }, + windowRef, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(posted, [{ type: 'gev:ready' }]); + const event = new Event('message'); + Object.assign(event, { + source: windowRef, + data: { type: 'gev:view', id: 7, view: { camera: { lat: 1, lon: 2 } } }, + }); + windowRef.dispatchEvent(event); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(posted[1].id, 7); + assert.equal(posted[1].ok, true); + remove(); + } finally { + delete globalThis.GEV_EMBED_INLINE; + } +}); + test('annotations in a link are drawn after it restores, embedded or not', async () => { const { calls, run } = recorder(); const an = encodeURIComponent( diff --git a/src/firstRunExperience.js b/src/firstRunExperience.js index 33af95d5f..60bd5d2a8 100644 --- a/src/firstRunExperience.js +++ b/src/firstRunExperience.js @@ -212,7 +212,8 @@ export function shouldShowFirstRun({ const params = new URLSearchParams(location?.search || ''); if (params.get('welcome') === '0') return false; // An embedded globe never shows the welcome launcher. - if (params.get('embed') === '1') return false; + if (params.get('embed') === '1' || globalThis.GEV_EMBED_INLINE === true) + return false; // The demo/support escape hatch outranks both suppressions on purpose. if (params.get('welcome') === '1') return true; if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index dbe886902..58d8007dd 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -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 ` @@ -25,9 +52,9 @@ function panelHtml(appOrigin) { @@ -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 , 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, + }, }, }); } diff --git a/src/tools/globePanel.test.mjs b/src/tools/globePanel.test.mjs index b231dc9bd..49de8afee 100644 --- a/src/tools/globePanel.test.mjs +++ b/src/tools/globePanel.test.mjs @@ -7,16 +7,27 @@ import { } from './globePanel.js'; import { composeCatalog, coreTools } from './index.js'; -test('the globe panel is an MCP Apps resource that may frame only the app', () => { +test('the globe panel is an MCP Apps resource that runs the app inside itself', () => { const resource = createGlobePanelResource({ appUrl: 'http://localhost:5173/some/path', }); assert.equal(resource.uri, GLOBE_PANEL_URI); assert.equal(resource.mimeType, MCP_APP_MIME_TYPE); assert.equal(MCP_APP_MIME_TYPE, 'text/html;profile=mcp-app'); - assert.deepEqual(resource._meta.ui.csp, { - frameDomains: ['http://localhost:5173'], - }); + const { csp } = resource._meta.ui; + // Hosts may not let a panel frame other sites, so it declares no frames + // and instead loads the app's code and data from the app's origin. + assert.equal(csp.frameDomains, undefined); + assert.equal(csp.resourceDomains[0], 'http://localhost:5173'); + assert.deepEqual(csp.connectDomains.slice(0, 2), [ + 'http://localhost:5173', + 'ws://localhost:5173', + ]); + assert.ok(csp.resourceDomains.includes('https://tile.googleapis.com')); + assert.deepEqual(csp.baseUriDomains, ['http://localhost:5173']); + assert.match(resource.text, /window\.GEV_EMBED_INLINE = true/); + assert.match(resource.text, /fetch\(APP_ORIGIN \+ '\/\?embed=1'\)/); + assert.doesNotMatch(resource.text, /