From e03bf008f32575f779e0ac419769d432052deec7 Mon Sep 17 00:00:00 2001
From: Manjunath <301209272+manjunath22466@users.noreply.github.com>
Date: Thu, 1 Oct 2026 01:50:31 +0530
Subject: [PATCH 001/115] fix(ui): harden Cyber panel layout and preset
restoration
---
CHANGELOG.md | 8 ++
docs/CURRENT-STATE.md | 19 ++-
docs/panel-surfaces.md | 150 ++++++++++++++++++++
src/ui/cyberTheme.test.mjs | 209 +++++++++++++++++++++++++++-
src/ui/styles/cyber.css | 73 ++++++++--
src/ui/styles/panel-surfaces.css | 230 +++++++++++++++++++++++++++++++
src/ui/visualSettings.js | 21 +++
style.css | 1 +
8 files changed, 695 insertions(+), 16 deletions(-)
create mode 100644 docs/panel-surfaces.md
create mode 100644 src/ui/styles/panel-surfaces.css
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 58103d04d..c2688159f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -155,6 +155,14 @@
panel method.
- On phones the title bar sits 16 px from the top so both Radio broadcast
waves stay on-screen.
+- Keep Cyber Voice help/error popups and Location/Visual Presets pins clear of
+ their decorative frames. Leave space above attribution for its full logo row.
+ Add shared panel surface hooks for theme styling, rail input and bounded body
+ scrolling without changing panel disclosure or visibility policies.
+- Restore the user's previous visual preset when they explicitly switch from
+ Cyber to another HUD layout, without overriding scene or shared-link state.
+- Move the aligned Cyber side-panel rails upward on desktop so the left stack
+ clears the lower coordinate card; keep Cockpit's independent visor layout.
- Keep Cyber right-rail panels mutually exclusive and Display, CCTV and Context
headers and frames fixed during content scrolling. Restore Radio's nested Context placement and compact
player. Add Cyber Sonar voice controls with settings and effect-state readback.
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 22ce1eb3b..45be529e2 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1,10 +1,11 @@
# God's Eye View Current State
-## Cyber HUD — September 23, 2026
+## Cyber HUD — September 30, 2026
Display > HUD > Layout includes Cyber, also available through the HUD voice
action and shared visual state. An explicit first transition into Cyber selects
-FLIR with Ironbow 0.42; restored links and subsequent visual tuning remain
+FLIR with Ironbow 0.42. An explicit switch back to another HUD layout restores
+the visual preset used before Cyber; restored links and scenes remain
authoritative. The skin uses shared red/slate panel treatments in map and cockpit,
with compact 200px collapsed controls and wider expanded panels. Other HUD
layouts retain their existing presentation.
@@ -17,6 +18,20 @@ headers and decorative frames. The narrow-screen rail remains scrollable to reac
each panel. Radio retains the shared nested Context player and compact
header disclosure, without relocating playback controls on theme changes.
+Voice help/error popups and Location/Visual Presets pins extend beyond their
+Cyber frames without being clipped: the shapes decorate non-interactive
+pseudo-elements. The lower-left telemetry card leaves room for attribution's
+full logo row. New panels can use the [shared surface contract](panel-surfaces.md)
+for theme tokens, rail input and a fixed header with a bounded scroll body.
+Their existing owners still control disclosure, persistence and placement;
+small-screen rail popup clipping and other integration limits are documented
+in that contract.
+
+On desktop, Cyber raises the left rail so it clears the lower coordinate card.
+`layoutRightPanelRail` reads that rendered top as its measured `baseTop`, which
+keeps the right rail aligned. Cockpit retains its separate rail placement for
+its visor layout.
+
Sonar has one contact-highlighting method. Native Cesium points, billboards and
labels are treated in GPU draw commands without replacing their positions,
identities or pick commands. Model and canvas/DOM overlays retain their own
diff --git a/docs/panel-surfaces.md b/docs/panel-surfaces.md
new file mode 100644
index 000000000..2d18023e7
--- /dev/null
+++ b/docs/panel-surfaces.md
@@ -0,0 +1,150 @@
+# Panel surface contract
+
+`src/ui/styles/panel-surfaces.css` supplies an opt-in visual surface for new
+panels. It uses the application's default color, font, border and radius tokens;
+Cyber supplies its existing red/slate palette, monospace typography and shaped
+frame. This is CSS shared by the existing panel system, not a panel registry or
+a controller. A panel ID does not need a new theme selector.
+
+## Markup
+
+A rail panel uses an outer owner with its existing stable `id`, matching
+`data-panel-id`, and `panel-collapsible` class. Its first non-glow child is the
+surface. Keep the header and any outward popup outside the scrolling body:
+
+```html
+
+
',
+ _meta: { ui: { csp: { frameDomains: ['https://a.example'] } } },
+ },
+ ],
+ );
+ assert.equal(
+ (await call('resources/read', { uri: 'ui://fixture/other' })).code,
+ -32002,
+ );
+ // A server without resources does not advertise them.
+ assert.equal(
+ (await server.handle({ jsonrpc: '2.0', id: 1, method: 'initialize' }))
+ .result.capabilities.resources,
+ undefined,
+ );
+ assert.throws(
+ () =>
+ defineTool({
+ name: 'bad',
+ title: 'Bad',
+ description: 'Points at a web page.',
+ inputSchema: { type: 'object' },
+ ui: { resourceUri: 'https://a.example' },
+ run: async () => ({ summary: '', data: {} }),
+ }),
+ /ui:\/\/ resourceUri/,
+ );
+});
diff --git a/src/tools/mcp/protocol.js b/src/tools/mcp/protocol.js
index 44e3e778c..67858833e 100644
--- a/src/tools/mcp/protocol.js
+++ b/src/tools/mcp/protocol.js
@@ -1,6 +1,8 @@
/**
* Model Context Protocol server for a tool catalog: JSON-RPC 2.0 handling of
- * initialize, ping, tools/list and tools/call. Transports (HTTP, stdio) pass
+ * initialize, ping, tools/list and tools/call, and resources/list and
+ * resources/read for the resources an application supplies, such as MCP Apps
+ * views (`ui://` resources) that tools name in `_meta.ui.resourceUri`. Transports (HTTP, stdio) pass
* parsed messages in and send the returned responses out.
*/
@@ -18,6 +20,7 @@ const INVALID_REQUEST = -32600;
const METHOD_NOT_FOUND = -32601;
const INVALID_PARAMS = -32602;
const INTERNAL_ERROR = -32603;
+const RESOURCE_NOT_FOUND = -32002;
/**
* Describe catalog tools as MCP tool definitions.
@@ -36,6 +39,9 @@ export function toMcpTools(catalog, { descriptions = {}, decorate } = {}) {
description: override.description ?? tool.description,
inputSchema: tool.inputSchema,
annotations: { title, ...tool.annotations },
+ ...(tool.ui
+ ? { _meta: { ui: { resourceUri: tool.ui.resourceUri } } }
+ : {}),
};
return decorate
? { ...definition, ...decorate(definition, tool) }
@@ -54,8 +60,12 @@ export function createMcpServer({
instructions,
descriptions,
decorate,
+ resources = [],
}) {
const tools = () => toMcpTools(catalog, { descriptions, decorate });
+ const resourceByUri = new Map(
+ resources.map((resource) => [resource.uri, resource]),
+ );
const methods = {
initialize(params) {
const requested = params?.protocolVersion;
@@ -63,13 +73,28 @@ export function createMcpServer({
protocolVersion: MCP_PROTOCOL_VERSIONS.includes(requested)
? requested
: MCP_PROTOCOL_VERSIONS[0],
- capabilities: { tools: { listChanged: false } },
+ capabilities: {
+ tools: { listChanged: false },
+ ...(resources.length ? { resources: { listChanged: false } } : {}),
+ },
serverInfo: { name, version },
...(instructions ? { instructions } : {}),
};
},
ping: () => ({}),
'tools/list': () => ({ tools: tools() }),
+ 'resources/list': () => ({
+ resources: resources.map(({ text, ...entry }) => entry),
+ }),
+ 'resources/read'(params) {
+ const resource = resourceByUri.get(params?.uri);
+ if (!resource)
+ throw rpcError(RESOURCE_NOT_FOUND, `Unknown resource: ${params?.uri}`);
+ const { uri, mimeType, text, _meta } = resource;
+ return {
+ contents: [{ uri, mimeType, text, ...(_meta ? { _meta } : {}) }],
+ };
+ },
async 'tools/call'(params, { signal }) {
if (typeof params?.name !== 'string')
throw rpcError(INVALID_PARAMS, 'tools/call needs a tool name');
diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js
index 941fd4616..71f28422a 100644
--- a/src/tools/queries/share.js
+++ b/src/tools/queries/share.js
@@ -2,11 +2,18 @@
import { VIEW_PROPERTIES, createView, viewUrl } from '../../view/index.js';
import { defineTool, ToolError } from '../catalog.js';
+import { GLOBE_PANEL_URI } from '../globePanel.js';
import { AREA_SCHEMA, resolveArea } from '../area.js';
import { cameraForArea, cameraLookingAt } from '../views.js';
/** Tool arguments that describe a view: an area to frame, or a camera. */
export const VIEW_ARGUMENTS = Object.freeze({
+ view: Object.freeze({
+ type: 'object',
+ description:
+ 'A view another answer returned (its data.view), shown as it is; ' +
+ 'other arguments given with it change that part of it.',
+ }),
area: AREA_SCHEMA,
...VIEW_PROPERTIES,
});
@@ -16,7 +23,10 @@ export const VIEW_ARGUMENTS = Object.freeze({
* down over it; camera fields given alongside it override the framing.
*/
export async function resolveViewArguments(args, { services, signal }) {
- let camera = { ...args.camera };
+ const base = args.view && typeof args.view === 'object' ? args.view : {};
+ let camera = args.area
+ ? { ...args.camera }
+ : { ...base.camera, ...args.camera };
let label = null;
if (args.area) {
const area = await resolveArea(args.area, { services, signal });
@@ -36,16 +46,22 @@ export async function resolveViewArguments(args, { services, signal }) {
if (!Number.isFinite(camera.lat) || !Number.isFinite(camera.lon))
throw new ToolError(
'invalid_arguments',
- 'Give an area, or a camera with lat and lon',
+ 'Give a view, an area, or a camera with lat and lon',
);
- const view = createView({
- camera,
- layers: args.layers,
- style: args.style ?? null,
- map: args.map ?? null,
- follow: args.follow ?? null,
- annotations: args.annotations ?? [],
- });
+ let view;
+ try {
+ view = createView({
+ camera,
+ layers: args.layers ?? base.layers,
+ style: args.style ?? base.style ?? null,
+ map: args.map ?? base.map ?? null,
+ follow: args.follow ?? base.follow ?? null,
+ annotations: args.annotations ?? base.annotations ?? [],
+ });
+ } catch (error) {
+ // A view passed through can hold anything; report it as bad arguments.
+ throw new ToolError('invalid_arguments', error.message);
+ }
return {
view,
label:
@@ -61,11 +77,13 @@ function appBase(services) {
}
}
-export const openInGodsEyeView = defineTool({
- name: 'open_in_gods_eye_view',
- title: "Open in God's Eye View",
+export const showOnGlobe = defineTool({
+ name: 'show_on_globe',
+ title: 'Show on the globe',
description:
- "A link that opens God's Eye View at a view: an area framed from above " +
+ "Shows a view of God's Eye View's globe: in clients that display apps, " +
+ 'the live globe in the conversation; everywhere, a link that opens it. ' +
+ 'Pass the view another answer returned, or describe one: an area framed from above ' +
'or a camera position, with chosen data layers, visual style and map, ' +
'optionally following an aircraft or satellite and with marks drawn on ' +
'the globe.',
@@ -75,6 +93,7 @@ export const openInGodsEyeView = defineTool({
additionalProperties: false,
},
requires: ['app'],
+ ui: { resourceUri: GLOBE_PANEL_URI },
async run(args, { services, signal }) {
const base = appBase(services);
const { view, label } = await resolveViewArguments(args, {
diff --git a/src/tools/queries/share.test.mjs b/src/tools/queries/share.test.mjs
index 06e4c29fe..dc4d5247a 100644
--- a/src/tools/queries/share.test.mjs
+++ b/src/tools/queries/share.test.mjs
@@ -7,7 +7,7 @@ test('links open the app looking straight down on the area', async () => {
tools: coreTools,
services: { app: { baseUrl: 'https://maps.example/view?x=1' } },
});
- const result = await catalog.call('open_in_gods_eye_view', {
+ const result = await catalog.call('show_on_globe', {
area: { lat: 30.2672, lon: -97.7431, radius_km: 5 },
});
const url = new URL(result.data.url);
@@ -35,11 +35,11 @@ test('altitude is bounded for tiny and planet-sized areas', async () => {
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:4173/' } },
});
- const tiny = await catalog.call('open_in_gods_eye_view', {
+ const tiny = await catalog.call('show_on_globe', {
area: { lat: 0, lon: 0, radius_km: 0.1 },
});
assert.equal(tiny.data.view.camera.altitude_m, 500);
- const world = await catalog.call('open_in_gods_eye_view', {
+ const world = await catalog.call('show_on_globe', {
area: { bbox: [-180, -85, 180, 85] },
});
assert.equal(world.data.view.camera.altitude_m, 15000000);
@@ -48,7 +48,7 @@ test('altitude is bounded for tiny and planet-sized areas', async () => {
services: { app: { baseUrl: 'not a url' } },
});
await assert.rejects(
- broken.call('open_in_gods_eye_view', {
+ broken.call('show_on_globe', {
area: { lat: 0, lon: 0, radius_km: 1 },
}),
(error) => error.code === 'unavailable',
@@ -61,7 +61,7 @@ test('links turn on the requested layers with the app share-link codec', async (
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:4173/' } },
});
- const result = await catalog.call('open_in_gods_eye_view', {
+ const result = await catalog.call('show_on_globe', {
area: { lat: 30.27, lon: -97.74, radius_km: 10 },
layers: ['earthquakes', 'flights', 'flights'],
});
@@ -72,7 +72,7 @@ test('links turn on the requested layers with the app share-link codec', async (
'flights',
]);
await assert.rejects(
- catalog.call('open_in_gods_eye_view', {
+ catalog.call('show_on_globe', {
area: { lat: 0, lon: 0, radius_km: 1 },
layers: ['not-a-layer'],
}),
@@ -86,7 +86,7 @@ test('links carry a camera, style, map and an entity to follow', async () => {
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:4173/' } },
});
- const result = await catalog.call('open_in_gods_eye_view', {
+ const result = await catalog.call('show_on_globe', {
area: { lat: 25, lon: 121, radius_km: 200 },
camera: { pitch_deg: -40, heading_deg: 350 },
layers: ['ais-live-vessels'],
@@ -103,13 +103,13 @@ test('links carry a camera, style, map and an entity to follow', async () => {
id: 'ae1234',
});
assert.equal(result.data.view.camera.pitch_deg, -40);
- const camera = await catalog.call('open_in_gods_eye_view', {
+ const camera = await catalog.call('show_on_globe', {
camera: { lat: 48.8584, lon: 2.2945, altitude_m: 1200, pitch_deg: -30 },
});
assert.equal(camera.data.view.camera.altitude_m, 1200);
assert.match(camera.summary, /^Open 48\.858, 2\.295 in God's Eye View: /);
await assert.rejects(
- catalog.call('open_in_gods_eye_view', { layers: ['flights'] }),
- /Give an area, or a camera with lat and lon/,
+ catalog.call('show_on_globe', { layers: ['flights'] }),
+ /Give a view, an area, or a camera with lat and lon/,
);
});
diff --git a/src/tools/surfaces.js b/src/tools/surfaces.js
index 7b1030f88..b04388bd4 100644
--- a/src/tools/surfaces.js
+++ b/src/tools/surfaces.js
@@ -15,7 +15,7 @@ const mcpOff = Object.freeze({ mcp: false });
export const TOOL_SURFACES = Object.freeze({
// Voice runs inside the app, so a link to it adds nothing.
- open_in_gods_eye_view: voiceOff,
+ show_on_globe: voiceOff,
// Voice has an app action of the same name.
next_satellite_pass: voiceOff,
// Voice receives text only, so tools that answer with an image are off.
diff --git a/src/tools/surfaces.test.mjs b/src/tools/surfaces.test.mjs
index 255b7c9fd..915547e1a 100644
--- a/src/tools/surfaces.test.mjs
+++ b/src/tools/surfaces.test.mjs
@@ -20,11 +20,7 @@ test('each surface offers the tools the table does not turn off', () => {
assert.deepEqual(offered, expected, surface);
}
const mcp = names(toolsForSurface(coreTools, 'mcp'));
- for (const name of [
- 'open_in_gods_eye_view',
- 'situation_brief',
- 'aircraft_in_area',
- ])
+ for (const name of ['show_on_globe', 'situation_brief', 'aircraft_in_area'])
assert.ok(mcp.includes(name), name);
for (const name of [
'search_places',
From 47ef168815fc98d8ebaf67845297853c2b9ae9c7 Mon Sep 17 00:00:00 2001
From: Sameh Khamis
Date: Thu, 1 Oct 2026 22:47:32 -0700
Subject: [PATCH 068/115] refactor: name the view tool show_in_gods_eye_view
The tool and its panel now say "Show in God's Eye View" rather than
"show on the globe", in tool names, descriptions, server instructions and
docs.
---
docs/CURRENT-STATE.md | 8 ++++----
docs/TOOLS.md | 6 +++---
server/mcp/server.js | 6 +++---
src/tooling/localServices.test.mjs | 2 +-
src/tools/functions.test.mjs | 4 ++--
src/tools/globePanel.js | 10 +++++-----
src/tools/globePanel.test.mjs | 8 ++++----
src/tools/index.js | 4 ++--
src/tools/queries/share.js | 18 +++++++++---------
src/tools/queries/share.test.mjs | 18 +++++++++---------
src/tools/surfaces.js | 2 +-
src/tools/surfaces.test.mjs | 6 +++++-
12 files changed, 48 insertions(+), 44 deletions(-)
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index da089a934..1aefb801c 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1,11 +1,11 @@
# God's Eye View Current State
-## The globe in conversations — October 2, 2026
+## God's Eye View in conversations — October 2, 2026
-Tool answers that can be seen on the globe include a view: camera, layers,
+Tool answers that can be shown in God's Eye View include a view: camera, layers,
style, map, marks and an aircraft or satellite to follow, written in the
-share-link format (`gods-eye-view/view`). `show_on_globe` shows a view as the
-live globe inside clients that display MCP Apps, and as a link everywhere.
+share-link format (`gods-eye-view/view`). `show_in_gods_eye_view` shows a view as live
+God's Eye View inside clients that display MCP Apps, and as a link everywhere.
`?embed=1` shows the app as the globe alone and takes new views from the page
that frames it. MCP leads with the tools that find what to show. See
[tools and the MCP server](TOOLS.md).
diff --git a/docs/TOOLS.md b/docs/TOOLS.md
index b11d518b3..4aba0a727 100644
--- a/docs/TOOLS.md
+++ b/docs/TOOLS.md
@@ -104,9 +104,9 @@ tilted camera over an area looks at its center from behind; camera fields
given with an area override its framing. `resolveViewArguments` turns them
into a view.
-### The globe panel
+### The God's Eye View panel
-`show_on_globe` names an MCP Apps view (`io.modelcontextprotocol/ui`):
+`show_in_gods_eye_view` names an MCP Apps view (`io.modelcontextprotocol/ui`):
`_meta.ui.resourceUri` is `ui://gods-eye-view/globe`, a `text/html;profile=mcp-app`
resource from `createGlobePanelResource({ appUrl })` in
`src/tools/globePanel.js`. Clients that display apps render it inside the
@@ -250,4 +250,4 @@ public feeds the sources already use.
| `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, by section |
| `military_awareness` | `military` | Military and other aircraft, ships and military installations within 250 km of a point, by section |
| `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area |
-| `show_on_globe` | `app` | A view on the globe: the live globe panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks and something to follow |
+| `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks and something to follow |
diff --git a/server/mcp/server.js b/server/mcp/server.js
index a9a8b5ea1..aa11e8ef1 100644
--- a/server/mcp/server.js
+++ b/server/mcp/server.js
@@ -14,9 +14,9 @@ const INSTRUCTIONS =
"Tools answer questions from God's Eye View's live public data. Location " +
'tools take an area: a place name, a bbox, or lat/lon with radius_km. ' +
'Results are capped; check truncated and total before concluding there is nothing more. ' +
- 'Answers that can be seen on the globe include data.view; to show one, call ' +
- 'show_on_globe with that view, adding layers, style, a camera or marks as ' +
- 'needed. It shows the live globe where the client displays apps and ' +
+ "Answers that can be shown in God's Eye View include data.view; to show one, call " +
+ 'show_in_gods_eye_view with that view, adding layers, style, a camera or marks as ' +
+ "needed. It shows live God's Eye View where the client displays apps and " +
'returns a link everywhere.';
/** Construct the local MCP server for Core's tools. */
diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs
index c26de99a0..61f25271a 100644
--- a/src/tooling/localServices.test.mjs
+++ b/src/tooling/localServices.test.mjs
@@ -220,7 +220,7 @@ test('Realtime sessions carry supplied tools, and the standalone voice adds the
);
assert.ok(names.includes('get_weather'));
assert.ok(names.includes('military_awareness'));
- assert.ok(!names.includes('show_on_globe'));
+ assert.ok(!names.includes('show_in_gods_eye_view'));
assert.ok(!names.includes('aircraft_in_area'));
assert.ok(!names.includes('get_weather_map'));
// The action of the same name answers satellite passes.
diff --git a/src/tools/functions.test.mjs b/src/tools/functions.test.mjs
index 51776ea75..439d7aeef 100644
--- a/src/tools/functions.test.mjs
+++ b/src/tools/functions.test.mjs
@@ -4,10 +4,10 @@ import { coreTools, toFunctionOutput, toFunctionTools } from './index.js';
test('catalog tools become independent function records', () => {
const records = toFunctionTools(coreTools, {
- exclude: ['show_on_globe'],
+ exclude: ['show_in_gods_eye_view'],
});
assert.equal(records.length, coreTools.length - 1);
- assert.ok(!records.some((tool) => tool.name === 'show_on_globe'));
+ assert.ok(!records.some((tool) => tool.name === 'show_in_gods_eye_view'));
const weather = records.find((tool) => tool.name === 'get_weather');
const source = coreTools.find((tool) => tool.name === 'get_weather');
assert.deepEqual(Object.keys(weather), [
diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js
index 96b20620a..f87179222 100644
--- a/src/tools/globePanel.js
+++ b/src/tools/globePanel.js
@@ -1,7 +1,7 @@
/**
- * The globe panel: an MCP Apps view (`io.modelcontextprotocol/ui`) that shows
+ * 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_on_globe tool
+ * the app in embed mode and sends it each view the show_in_gods_eye_view tool
* returns; see docs/TOOLS.md.
*/
@@ -33,7 +33,7 @@ function panelHtml(appOrigin) {
-
Waiting for a view of the globe…
+
Waiting for a view…
diff --git a/src/tools/globePanel.test.mjs b/src/tools/globePanel.test.mjs
index 7500a580a..b231dc9bd 100644
--- a/src/tools/globePanel.test.mjs
+++ b/src/tools/globePanel.test.mjs
@@ -21,6 +21,9 @@ test('the globe panel is an MCP Apps resource that may frame only the app', () =
assert.match(resource.text, /'ui\/initialize'/);
assert.match(resource.text, /'ui\/notifications\/tool-result'/);
assert.match(resource.text, /protocolVersion: "2026-01-26"/);
+ // The MCP Apps SDK's initialize parameters; hosts reject anything else.
+ assert.match(resource.text, /appInfo: \{ name: 'gods-eye-view'/);
+ assert.doesNotMatch(resource.text, /clientInfo/);
});
test('show_in_gods_eye_view names the panel and shows a view another answer returned', async () => {
From cb04fd5b19d7be3c1f9e7c55d3079e11d10bc2f9 Mon Sep 17 00:00:00 2001
From: Sameh Khamis
Date: Fri, 2 Oct 2026 09:40:12 -0700
Subject: [PATCH 072/115] feat: say in the panel what a host's security policy
blocked
When a host's content security policy refuses part of the panel, such as
its frame, the panel now names the blocked directive and address instead of
waiting until its load timeout.
---
src/tools/globePanel.js | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js
index 982914e3d..dbe886902 100644
--- a/src/tools/globePanel.js
+++ b/src/tools/globePanel.js
@@ -40,6 +40,14 @@ function panelHtml(appOrigin) {
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();
@@ -88,7 +96,9 @@ function panelHtml(appOrigin) {
setTimeout(() => {
if (!frameReady)
status.textContent =
- "God's Eye View did not load here. Use Open in God's Eye View above.";
+ "God's Eye View did not load here" +
+ (blocked ? ' (' + blocked + ')' : '') +
+ ". Use Open in God's Eye View above.";
}, LOAD_TIMEOUT_MS);
return;
}
From 63341b947e84015dabb161e1a65e01438bb4538b Mon Sep 17 00:00:00 2001
From: Sameh Khamis
Date: Fri, 2 Oct 2026 09:58:26 -0700
Subject: [PATCH 073/115] 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, /