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