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, /