Load the God's Eye View panel through the MCP server

Hosts serve panels from their own sites and may refuse other addresses;
Codex refuses any address on the user's machine. The panel now never
requests the app's server: it loads the app's panel build and data
through panel_request, a tool only the panel may call, which requests
the path from the app's server. One path in every host; only map
providers load directly.

npm run build:panel writes the panel build to dist/panel, served at
/panel/: one app script, one stylesheet, Cesium's script, and Cesium's
workers as one script Cesium runs from memory. Tools may now be marked
app-only. The panel CORS rules are gone.
This commit is contained in:
Sameh Khamis committed 2026-10-02 18:29:42 -07:00
1 parent b11d54d8b8
commit 996b95eb45
20 files changed
+1265 -437

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
/**
* The MCP Apps panel build. A panel loads the app through its MCP server,
* one tool call per file (see src/tools/globePanel.js), so the app must be
* few files: one app script, one stylesheet, Cesium's own script, and
* Cesium's workers combined into one script that Cesium runs from memory.
* The build is served at PANEL_BASE next to the app.
*/
// The panel loads these paths (src/tools/globePanel.js); a test keeps the
// two in step.
export const PANEL_BASE = '/panel/';
/** Cesium's workers as one script, relative to the panel build. */
export const PANEL_WORKERS_PATH = 'cesium/workers.js';
export const PANEL_OUT_DIR = 'dist/panel';
/** A browser Vite config changed to produce the panel build. */
export function panelBuildConfig(config) {
return {
...config,
base: PANEL_BASE,
build: {
...config.build,
outDir: PANEL_OUT_DIR,
emptyOutDir: true,
modulePreload: false,
cssCodeSplit: false,
rollupOptions: {
...config.build?.rollupOptions,
output: { inlineDynamicImports: true },
},
},
};
}
/**
* Source for Cesium's `CESIUM_WORKERS` script: every worker module, each
* evaluated only when Cesium starts that worker. `workerNames` are the
* module names in Cesium's Workers directory, without `.js`.
*/
export function workersEntrySource(workerNames, workersDir) {
const entries = workerNames.map(
(name) =>
` ${JSON.stringify(name)}: () => import(${JSON.stringify(`${workersDir}/${name}.js`)}),`,
);
return `self.CesiumWorkers = {\n${entries.join('\n')}\n};\n`;
}
const CONTENT_TYPES = {
'.css': 'text/css',
'.geojsonl': 'application/geo+json-seq',
'.glb': 'model/gltf-binary',
'.html': 'text/html',
'.jpg': 'image/jpeg',
'.js': 'text/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.wasm': 'application/wasm',
'.woff2': 'font/woff2',
'.xml': 'application/xml',
};
/**
* Vite plugin serving the panel build at PANEL_BASE on the dev server, as
* the preview server and hosted deployments serve it from the build output.
*/
export function panelBuildPlugin({ outDir = PANEL_OUT_DIR } = {}) {
return {
name: 'panel-build',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const { pathname } = new URL(req.url || '/', 'http://localhost');
if (!pathname.startsWith(PANEL_BASE)) return next();
const { readFile } = await import('node:fs/promises');
const { extname, resolve, sep } = await import('node:path');
const root = resolve(server.config.root, outDir);
const relative = decodeURIComponent(pathname.slice(PANEL_BASE.length));
const file = resolve(root, relative || 'index.html');
if (file !== root && !file.startsWith(root + sep)) return next();
try {
const body = await readFile(file);
res.setHeader(
'Content-Type',
CONTENT_TYPES[extname(file)] || 'application/octet-stream',
);
res.end(body);
} catch {
res.statusCode = 404;
res.end(
relative ? 'Not found' : 'No panel build; run npm run build:panel',
);
}
});
},
};
}
+2 -41
View File
@@ -1,46 +1,7 @@
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$/,
/^(?:https|codex-sandbox):\/\/[a-z0-9-]+\.web-sandbox\.oaiusercontent\.com$/,
]);
/**
* Vite plugin answering cross-origin reads from PANEL_CORS_ORIGINS on every
* path. It runs ahead of the API middlewares, which Vite's own `cors` option
* would not cover.
*/
function panelCorsPlugin() {
const install = (server) => {
server.middlewares.use((req, res, next) => {
const origin = req.headers.origin;
if (
!origin ||
!PANEL_CORS_ORIGINS.some((allowed) => allowed.test(origin))
)
return next();
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
if (req.method !== 'OPTIONS') return next();
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, POST, OPTIONS');
res.setHeader(
'Access-Control-Allow-Headers',
req.headers['access-control-request-headers'] || '',
);
res.statusCode = 204;
res.end();
});
};
return {
name: 'panel-cors',
configureServer: install,
configurePreviewServer: install,
};
}
import { panelBuildPlugin } from './panel.js';
/** Build browser assets with explicit inputs; never load environment or providers. */
export function createBrowserViteConfig({
@@ -54,11 +15,11 @@ export function createBrowserViteConfig({
} = {}) {
return {
plugins: [
panelCorsPlugin(),
cesium(),
applicationHtmlPlugin(),
...plugins,
embedFramingPlugin(),
panelBuildPlugin(),
],
...(publicDir === undefined ? {} : { publicDir }),
// A production build must not clean the dependency cache a running dev
+33 -11
View File
@@ -108,17 +108,38 @@ into a view.
`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
conversation. The panel completes the MCP Apps handshake (`ui/initialize`,
`ui/notifications/initialized`, `ui/notifications/size-changed`), and for
each `ui/notifications/tool-result` carrying a view it loads the app in embed
mode the first time and posts later views to that same app, so the globe
changes without reloading. Its resource allows framing only the app's origin
(`csp.frameDomains`), and its "Open in God's Eye View" button asks the host
to open the link (`ui/open-link`). Tools declare a UI resource with
`defineTool({ ui: { resourceUri } })`; `createMcpServer({ resources })`
serves `resources/list` and `resources/read`.
resource from `createGlobePanelResource({ runtime })` in
`src/tools/globePanel.js`, with the panel's script from
`src/app/globePanelRuntime.js`. Clients that display apps render it inside
the conversation. The panel completes the MCP Apps handshake
(`ui/initialize`, `ui/notifications/initialized`,
`ui/notifications/size-changed`), and for each
`ui/notifications/tool-result` carrying a view it loads the app in inline
embed mode the first time and posts later views to that same app, so the
globe changes without reloading. Its "Open in God's Eye View" button asks
the host to open the link (`ui/open-link`).
Hosts serve panels from their own sites and may refuse other addresses;
Codex, for one, refuses any address on the user's machine. So the panel
never requests the app's server itself. It loads everything from the app's
own paths through `panel_request`, a tool only the panel may call
(`_meta.ui.visibility: ["app"]`): the MCP server requests the path from the
app's server and returns the response, compressed and in parts when large.
The same path works for a local server, a hosted one, and every host. Only
map imagery, tiles and fonts load directly, from the providers the
resource's `csp` lists. `panel_request` refuses `/api/setup`, which writes
provider keys.
The panel loads the app's panel build, which `npm run build:panel` writes to
`dist/panel` and the servers serve at `/panel/`: one app script, one
stylesheet, Cesium's script, and Cesium's workers combined into one script
that Cesium runs from memory (`CESIUM_WORKERS`). Rebuild it after changing
the app.
Tools declare a UI resource with `defineTool({ ui: { resourceUri } })`, and
an app-only tool with `ui: { visibility: ['app'] }`;
`createMcpServer({ resources })` serves `resources/list` and
`resources/read`.
### Embed mode
@@ -251,3 +272,4 @@ public feeds the sources already use.
| `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_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 |
| `panel_request` | `app` | Panel only: loads a path from the app's server for the God's Eye View panel |
+1
View File
@@ -41,6 +41,7 @@
"dev:secure": "./scripts/dev-secure.sh",
"opensky:import": "./scripts/opensky-import-client.sh",
"build": "vite build",
"build:panel": "node scripts/build-panel.mjs",
"preview": "vite preview",
"test": "node scripts/run-unit-tests.mjs",
"test:track": "node scripts/track-regression.mjs",
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* Build the MCP Apps panel: the app under dist/panel, served at /panel/.
* See build/panel.js.
*/
import { readdir, rename, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build as esbuild } from 'esbuild';
import { build } from 'vite';
import standaloneConfig from '../server/standalone/vite.config.js';
import {
PANEL_BASE,
PANEL_OUT_DIR,
PANEL_WORKERS_PATH,
panelBuildConfig,
workersEntrySource,
} from '../build/panel.js';
const root = fileURLToPath(new URL('../', import.meta.url));
const outDir = join(root, PANEL_OUT_DIR);
const cesiumWorkers = join(root, 'node_modules/cesium/Build/Cesium/Workers');
const config = standaloneConfig({ command: 'build', mode: 'production' });
await build({
configFile: false,
root,
...panelBuildConfig(config),
});
// Cesium's plugin copies its files under the base path inside outDir.
const nested = join(outDir, PANEL_BASE, 'cesium');
await rm(join(outDir, 'cesium'), { recursive: true, force: true });
await rename(nested, join(outDir, 'cesium'));
await rm(join(outDir, PANEL_BASE.split('/')[1]), { recursive: true });
const names = (await readdir(cesiumWorkers))
.filter((file) => file.endsWith('.js') && !file.startsWith('chunk-'))
.map((file) => file.slice(0, -3));
const entry = join(outDir, 'cesium', 'workers-entry.js');
await writeFile(entry, workersEntrySource(names, cesiumWorkers));
await esbuild({
entryPoints: [entry],
bundle: true,
format: 'iife',
minify: true,
outfile: join(outDir, PANEL_WORKERS_PATH),
logLevel: 'warning',
});
await rm(entry);
console.log(`Panel build written to ${PANEL_OUT_DIR}`);
+4 -2
View File
@@ -31,7 +31,8 @@
"modules": [
"build/vite.js",
"build/application-html.js",
"build/embed-framing.js"
"build/embed-framing.js",
"build/panel.js"
],
"external": ["vite-plugin-cesium"]
},
@@ -3140,7 +3141,8 @@
"src/data/installationFeedback.js",
"src/view/index.js",
"src/tools/views.js",
"src/tools/globePanel.js"
"src/tools/globePanel.js",
"src/tools/queries/panelRequest.js"
],
"external": ["satellite.js"]
},
+2 -1
View File
@@ -6,6 +6,7 @@ import {
coreTools,
catalogForSurface,
} from '../../src/tools/index.js';
import { panelRuntime } from '../../src/app/globePanelRuntime.js';
import { createGlobePanelResource } from '../../src/tools/globePanel.js';
import { createMcpServer } from '../../src/tools/mcp/index.js';
import { DEFAULT_API_BASE, createLocalToolServices } from './services.js';
@@ -38,6 +39,6 @@ export function createLocalMcpServer({
name: 'gods-eye-view',
version,
instructions: INSTRUCTIONS,
resources: [createGlobePanelResource({ appUrl: apiBase })],
resources: [createGlobePanelResource({ runtime: panelRuntime })],
});
}
+546
View File
@@ -0,0 +1,546 @@
/**
* The God's Eye View panel's script, run inside the panel page a host shows.
* It is sent as source text (see src/tools/globePanel.js), so it must not use
* imports or anything outside the function.
*
* The page lives on the host's site, and hosts may refuse other addresses,
* such as a server on the user's machine. So the panel never requests the
* app's server itself: every request for the app's own paths goes to the
* panel's MCP server as a call to `config.toolName`, which requests the path
* from the app's server. The app's built page, scripts, styles, images and
* data all arrive that way; map imagery from providers loads directly.
*/
export function panelRuntime(config) {
const status = document.getElementById('status');
const open = document.getElementById('open');
const say = (text) => {
if (status.isConnected) status.textContent = text;
};
let blocked = null;
document.addEventListener('securitypolicyviolation', (event) => {
blocked = `${event.effectiveDirective} blocked ${event.blockedURI || 'a resource'}`;
});
// JSON-RPC with the host.
let nextId = 1;
const pending = new Map();
const send = (message) =>
window.parent.postMessage({ jsonrpc: '2.0', ...message }, '*');
const request = (method, params) =>
new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
send({ id, method, params });
});
const notify = (method, params) => send({ method, params });
// Requests to the app's server, through the MCP server.
const MAX_CALLS = 6;
let calls = 0;
const waiting = [];
async function callTool(args) {
if (calls >= MAX_CALLS) await new Promise((go) => waiting.push(go));
calls += 1;
try {
const result = await request('tools/call', {
name: config.toolName,
arguments: args,
});
if (result?.isError || !result?.structuredContent)
throw new Error(result?.content?.[0]?.text || 'The request failed');
return result.structuredContent;
} finally {
calls -= 1;
waiting.shift()?.();
}
}
const toBase64 = (bytes) => {
let binary = '';
for (let start = 0; start < bytes.length; start += 0x8000)
binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));
return btoa(binary);
};
const fromBase64 = (text) =>
Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
async function bodyBytes(body) {
if (body == null) return null;
if (typeof body === 'string') return new TextEncoder().encode(body);
if (body instanceof ArrayBuffer) return new Uint8Array(body);
if (ArrayBuffer.isView(body))
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
return new Uint8Array(await new Response(body).arrayBuffer());
}
const NULL_BODY = new Set([101, 204, 205, 304]);
const held = new Map();
/** Request a path on the app's server; resolves to a Response. */
function appFetch(path, init = {}) {
const method = (init.method || 'GET').toUpperCase();
// Built files never change while the panel runs, so each loads once.
const shared = method === 'GET' && path.startsWith(config.panelBase);
if (shared && held.has(path)) return held.get(path).then(respond);
const loading = (async () => {
const headers = {};
new Headers(init.headers || {}).forEach((value, name) => {
headers[name] = value;
});
const bytes = await bodyBytes(init.body);
let part = await callTool({
path,
method,
headers,
...(bytes ? { body: toBase64(bytes) } : {}),
});
const parts = [fromBase64(part.body)];
while (part.nextOffset !== undefined) {
if (init.signal?.aborted) break;
part = await callTool({ id: part.id, offset: part.nextOffset });
parts.push(fromBase64(part.body));
}
let body = new Blob(parts);
if (part.encoding === 'gzip')
body = await new Response(
body.stream().pipeThrough(new DecompressionStream('gzip')),
).blob();
return {
body: await body.arrayBuffer(),
status: part.status,
statusText: part.statusText,
headers: part.headers,
};
})();
if (shared) held.set(path, loading);
const result = loading.then(respond);
if (!init.signal) return result;
return Promise.race([
result,
new Promise((_, reject) => {
const abort = () =>
reject(new DOMException('The request was aborted', 'AbortError'));
if (init.signal.aborted) abort();
init.signal.addEventListener('abort', abort, { once: true });
}),
]);
}
function respond({ body, status, statusText, headers }) {
return new Response(NULL_BODY.has(status) ? null : body.slice(0), {
status,
statusText,
headers,
});
}
// The app's own paths: relative ones, and absolute ones on this page's
// site, matched by scheme and host since a host may use its own scheme.
const page = new URL(document.baseURI);
function appPath(value) {
let url;
try {
url = new URL(String(value), document.baseURI);
} catch {
return null;
}
if (url.protocol !== page.protocol || url.host !== page.host) return null;
return url.pathname + url.search;
}
const blobUrls = new Map();
/** A blob: URL holding an app file, for elements that load by URL. */
function appObjectUrl(path) {
if (!blobUrls.has(path))
blobUrls.set(
path,
appFetch(path)
.then((response) => response.blob())
.then((blob) => URL.createObjectURL(blob)),
);
return blobUrls.get(path);
}
function installRequests() {
const nativeFetch = window.fetch.bind(window);
window.fetch = async (input, init = {}) => {
const isRequest = input instanceof Request;
const path = appPath(isRequest ? input.url : input);
if (path === null) return nativeFetch(input, init);
if (!isRequest) return appFetch(path, init);
return appFetch(path, {
method: init.method || input.method,
headers: init.headers || input.headers,
body:
init.body ??
(['GET', 'HEAD'].includes(input.method)
? undefined
: await input.arrayBuffer()),
signal: init.signal || input.signal,
});
};
// XMLHttpRequest for app paths: answered from appFetch, with the
// instance's state replaced, and the usual events dispatched.
const xhr = XMLHttpRequest.prototype;
const nativeOpen = xhr.open;
const nativeSend = xhr.send;
const nativeHeader = xhr.setRequestHeader;
const nativeAbort = xhr.abort;
xhr.open = function (method, url, ...rest) {
const path = appPath(url);
if (path === null) {
this.__gev = null;
return nativeOpen.call(this, method, url, ...rest);
}
this.__gev = { method, path, headers: {}, controller: null };
setState(this, { readyState: 1 });
this.dispatchEvent(new Event('readystatechange'));
};
xhr.setRequestHeader = function (name, value) {
if (!this.__gev) return nativeHeader.call(this, name, value);
this.__gev.headers[name] = value;
};
xhr.abort = function () {
if (!this.__gev) return nativeAbort.call(this);
this.__gev.controller?.abort();
};
xhr.send = function (body) {
const state = this.__gev;
if (!state) return nativeSend.call(this, body);
state.controller = new AbortController();
appFetch(state.path, {
method: state.method,
headers: state.headers,
body,
signal: state.controller.signal,
})
.then(async (response) => {
const type = this.responseType;
const value =
type === 'arraybuffer'
? await response.arrayBuffer()
: type === 'blob'
? await response.blob()
: await response.text();
const text = typeof value === 'string' ? value : null;
let parsed = value;
if (type === 'json') {
try {
parsed = JSON.parse(text);
} catch {
parsed = null;
}
} else if (type === 'document')
parsed = new DOMParser().parseFromString(text, 'text/html');
const headers = [...response.headers]
.map(([name, val]) => `${name}: ${val}`)
.join('\r\n');
setState(this, {
readyState: 4,
status: response.status,
statusText: response.statusText,
response: parsed,
responseText: type === '' || type === 'text' ? text : '',
responseURL: new URL(state.path, document.baseURI).href,
getAllResponseHeaders: () => headers,
getResponseHeader: (name) => response.headers.get(name),
});
for (const name of ['readystatechange', 'load', 'loadend'])
this.dispatchEvent(
name === 'readystatechange'
? new Event(name)
: new ProgressEvent(name),
);
})
.catch((error) => {
setState(this, { readyState: 4, status: 0 });
const name = error?.name === 'AbortError' ? 'abort' : 'error';
for (const event of ['readystatechange', name, 'loadend'])
this.dispatchEvent(
event === 'readystatechange'
? new Event(event)
: new ProgressEvent(event),
);
});
};
function setState(target, values) {
for (const [name, value] of Object.entries(values))
Object.defineProperty(target, name, {
configurable: true,
...(typeof value === 'function' ? { value } : { get: () => value }),
});
}
const nativeBeacon = navigator.sendBeacon?.bind(navigator);
if (nativeBeacon)
navigator.sendBeacon = (url, data) => {
const path = appPath(url);
if (path === null) return nativeBeacon(url, data);
appFetch(path, { method: 'POST', body: data }).catch(() => {});
return true;
};
// Images name app files by URL; give them the file as a blob: URL.
const image = Object.getOwnPropertyDescriptor(
HTMLImageElement.prototype,
'src',
);
Object.defineProperty(HTMLImageElement.prototype, 'src', {
...image,
set(value) {
const path = appPath(value);
if (path === null) return image.set.call(this, value);
appObjectUrl(path).then(
(url) => image.set.call(this, url),
() => this.dispatchEvent(new Event('error')),
);
},
});
// Workers on the app's server start from a small worker here that is
// handed the script once it has loaded; messages wait until then.
const NativeWorker = window.Worker;
const BOOT =
'const early=[];let ready=false;' +
"addEventListener('message',e=>{if(e.data&&e.data.__gevScript){" +
'const go=()=>{ready=true;for(const d of early)dispatchEvent(new MessageEvent("message",{data:d}))};' +
'if(e.data.module)import(e.data.__gevScript).then(go);else{importScripts(e.data.__gevScript);go()}' +
'e.stopImmediatePropagation();return}' +
'if(!ready){early.push(e.data);e.stopImmediatePropagation()}});';
window.Worker = function Worker(url, options) {
const path = appPath(url);
if (path === null || String(url).startsWith('blob:'))
return new NativeWorker(url, options);
const worker = new NativeWorker(
URL.createObjectURL(new Blob([BOOT], { type: 'text/javascript' })),
options,
);
appObjectUrl(path).then((script) =>
worker.postMessage({
__gevScript: script,
module: options?.type === 'module',
}),
);
return worker;
};
window.Worker.prototype = NativeWorker.prototype;
// Markup and styles the app adds later name app files too.
new MutationObserver((records) => {
for (const record of records)
for (const node of record.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.nodeName === 'STYLE') void inlineStyleUrls(node);
for (const element of [node, ...node.querySelectorAll('img[src]')]) {
if (element.nodeName !== 'IMG') continue;
const path = appPath(element.getAttribute('src'));
if (path !== null)
appObjectUrl(path).then((url) =>
element.setAttribute('src', url),
);
}
}
}).observe(document.documentElement, { childList: true, subtree: true });
}
/** Replace url() references to app files in CSS with blob: URLs. */
async function cssWithFiles(text, baseUrl) {
const found = new Map();
for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)) {
const reference = match[2];
if (/^(data|blob):/.test(reference)) continue;
const path = appPath(new URL(reference, baseUrl).href);
if (path !== null && !found.has(reference))
found.set(
reference,
appObjectUrl(path).catch(() => null),
);
}
let result = text;
for (const [reference, loading] of found) {
const url = await loading;
if (url) result = result.split(reference).join(url);
}
return result;
}
async function inlineStyleUrls(style) {
if (!/url\(/.test(style.textContent)) return;
style.textContent = await cssWithFiles(style.textContent, document.baseURI);
}
async function addStylesheet(href) {
const path = appPath(href);
const style = document.createElement('style');
document.head.appendChild(style);
if (path === null) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
style.replaceWith(link);
return;
}
const text = await (await appFetch(path)).text();
style.textContent = await cssWithFiles(
text,
new URL(path, document.baseURI).href,
);
}
function runScript(text, type) {
return new Promise((resolve) => {
const script = document.createElement('script');
if (type) script.type = type;
script.textContent = text;
document.body.appendChild(script);
resolve();
});
}
/**
* Load God's Eye View into this page from the app's panel build, in
* inline embed mode, starting at the first view as its link would.
*/
async function startApp(url) {
window.GEV_EMBED_INLINE = true;
try {
history.replaceState(
null,
'',
location.href.split('#')[0] + new URL(url).hash,
);
} catch {
// The view still arrives once the app is ready.
}
installRequests();
const response = await appFetch(config.panelBase);
if (!response.ok)
throw new Error(
response.status === 404
? 'the app has no panel build (npm run build:panel)'
: `the app answered ${response.status}`,
);
const built = new DOMParser().parseFromString(
await response.text(),
'text/html',
);
window.CESIUM_BASE_URL = `${config.panelBase}cesium/`;
const [workers] = await Promise.all([
appFetch(config.panelBase + config.workersPath).then((r) => r.text()),
...[...built.head.querySelectorAll('link[rel="stylesheet"]')].map(
(link) => addStylesheet(link.getAttribute('href')),
),
]);
// Cesium starts its workers from this script instead of from files.
window.CESIUM_WORKERS = workers;
// Images in the markup load their files once added; give them the
// files instead of letting them request this page's site.
const images = [];
for (const image of built.body.querySelectorAll('img[src]')) {
const path = appPath(image.getAttribute('src'));
if (path === null) continue;
image.removeAttribute('src');
images.push([image, path]);
}
for (const node of [...built.body.childNodes]) {
if (node.nodeName !== 'SCRIPT')
document.body.insertBefore(document.adoptNode(node), status);
}
for (const [image, path] of images)
appObjectUrl(path).then((url) => image.setAttribute('src', url));
const scripts = await Promise.all(
[...built.querySelectorAll('script')].map(async (script) => ({
type: script.type,
text: script.getAttribute('src')
? await (await appFetch(appPath(script.getAttribute('src')))).text()
: script.textContent,
})),
);
for (const { type, text } of scripts) await runScript(text, type);
}
// Tool results: the first view starts the app, later ones move it.
let started = false;
let ready = false;
let queued = null;
let currentUrl = null;
const postView = (view) =>
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
function show(result) {
const data = result?.structuredContent;
const view = data?.view;
const url = data?.url || view?.url;
if (!view || !url) return;
currentUrl = url;
open.hidden = false;
if (ready) return postView(view);
queued = view;
if (started) return;
started = true;
say("Loading God's Eye View…");
startApp(url).catch((error) =>
say(`God's Eye View could not load here: ${error?.message || error}.`),
);
setTimeout(() => {
if (!ready)
say(
"God's Eye View did not load here" +
(blocked ? ` (${blocked})` : '') +
". Use Open in God's Eye View above.",
);
}, config.loadTimeoutMs);
}
window.addEventListener('message', (event) => {
const message = event.data;
if (event.source === window) {
if (message?.type === 'gev:ready') {
ready = true;
status.remove();
if (queued) postView(queued);
queued = null;
}
return;
}
if (event.source !== window.parent || message?.jsonrpc !== '2.0') return;
if (
message.id !== undefined &&
pending.has(message.id) &&
!message.method
) {
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) reject(message.error);
else resolve(message.result);
return;
}
if (message.method === 'ui/notifications/tool-result') show(message.params);
});
open.addEventListener('click', () => {
if (!currentUrl) return;
request('ui/open-link', { url: currentUrl }).catch(() =>
window.open(currentUrl, '_blank', 'noopener'),
);
});
// The handshake the MCP Apps SDK's App.connect performs: hosts validate
// these parameters, and keep a view hidden until it is initialized.
request('ui/initialize', {
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
appInfo: {
name: 'gods-eye-view',
title: "God's Eye View",
version: '1.0.0',
},
protocolVersion: config.protocolVersion,
}).then(
() => {
send({ method: 'ui/notifications/initialized' });
notify('ui/notifications/size-changed', {
width: document.body.clientWidth,
height: config.panelHeight,
});
},
(error) =>
say(
"This client did not accept the God's Eye View panel" +
(error?.message ? `: ${error.message}` : '.'),
),
);
}
+82
View File
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import * as PANEL_PATHS from '../tools/globePanel.js';
import {
PANEL_BASE,
PANEL_WORKERS_PATH,
panelBuildConfig,
panelBuildPlugin,
workersEntrySource,
} from '../../build/panel.js';
test('the panel loads the paths the panel build writes', () => {
assert.equal(PANEL_BASE, PANEL_PATHS.PANEL_BASE);
assert.equal(PANEL_WORKERS_PATH, PANEL_PATHS.PANEL_WORKERS_PATH);
});
test('the panel build is one script and one stylesheet under /panel/', () => {
const config = panelBuildConfig({
plugins: [],
build: { chunkSizeWarningLimit: 1500 },
});
assert.equal(config.base, PANEL_BASE);
assert.equal(config.build.outDir, 'dist/panel');
assert.equal(config.build.cssCodeSplit, false);
assert.equal(config.build.rollupOptions.output.inlineDynamicImports, true);
assert.equal(config.build.chunkSizeWarningLimit, 1500);
});
test("Cesium's workers load lazily from one script", () => {
const source = workersEntrySource(['createGeometry', 'decodeDraco'], '/w');
assert.match(source, /^self\.CesiumWorkers = \{/);
assert.match(
source,
/"createGeometry": \(\) => import\("\/w\/createGeometry\.js"\)/,
);
assert.match(
source,
/"decodeDraco": \(\) => import\("\/w\/decodeDraco\.js"\)/,
);
});
test('the dev server serves the panel build and nothing outside it', async () => {
const root = await mkdtemp(join(tmpdir(), 'panel-build-'));
try {
await mkdir(join(root, 'out', 'assets'), { recursive: true });
await writeFile(join(root, 'out', 'index.html'), '<p>panel</p>');
await writeFile(join(root, 'out', 'assets', 'a.js'), 'x');
await writeFile(join(root, 'secret.txt'), 'secret');
let middleware;
panelBuildPlugin({ outDir: 'out' }).configureServer({
config: { root },
middlewares: { use: (fn) => (middleware = fn) },
});
const get = (url) =>
new Promise((resolve) => {
const res = {
statusCode: 200,
headers: {},
setHeader(name, value) {
this.headers[name] = value;
},
end(body) {
resolve({ status: this.statusCode, headers: this.headers, body });
},
};
middleware({ url }, res, () => resolve({ passed: true }));
});
const page = await get('/panel/');
assert.equal(String(page.body), '<p>panel</p>');
assert.equal(page.headers['Content-Type'], 'text/html');
const script = await get('/panel/assets/a.js');
assert.equal(script.headers['Content-Type'], 'text/javascript');
assert.equal((await get('/panel/missing.js')).status, 404);
assert.deepEqual(await get('/panel/..%2Fsecret.txt'), { passed: true });
assert.deepEqual(await get('/other'), { passed: true });
} finally {
await rm(root, { recursive: true, force: true });
}
});
+9 -48
View File
@@ -12,8 +12,7 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
googleApiKey: 'browser-fixture',
cesiumToken: 'ion-fixture',
});
assert.equal(config.plugins[0].name, 'panel-cors');
assert.equal(config.plugins[3], plugin);
assert.equal(config.plugins[2], plugin);
assert.equal(config.server.host, 'localhost');
assert.equal(config.server.port, 4173);
assert.deepEqual(config.server.allowedHosts, [
@@ -43,45 +42,6 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
);
});
test('panel origins may read every dev server path, other sites may not', () => {
let middleware;
createBrowserViteConfig().plugins[0].configureServer({
middlewares: { use: (fn) => (middleware = fn) },
});
const request = (origin, method = 'GET') => {
const headers = {};
let ended = false;
let passed = false;
middleware(
{ method, headers: origin ? { origin } : {} },
{
setHeader: (name, value) => (headers[name] = value),
end: () => (ended = true),
},
() => (passed = true),
);
return { headers, ended, passed };
};
const panel = request('https://abc123.claudemcpcontent.com');
assert.equal(
panel.headers['Access-Control-Allow-Origin'],
'https://abc123.claudemcpcontent.com',
);
assert.equal(panel.passed, true);
assert.equal(
request('https://abc123.claudemcpcontent.com', 'OPTIONS').ended,
true,
);
assert.equal(
request('codex-sandbox://mcp-app-ab12.web-sandbox.oaiusercontent.com')
.headers['Access-Control-Allow-Origin'],
'codex-sandbox://mcp-app-ab12.web-sandbox.oaiusercontent.com',
);
const other = request('https://example.com');
assert.deepEqual(other.headers, {});
assert.equal(other.passed, true);
});
test('build helper does not discover environment values or construct local providers', () => {
const before = process.env.GOOGLE_MAPS_API_KEY;
process.env.GOOGLE_MAPS_API_KEY = 'environment-fixture';
@@ -92,8 +52,8 @@ test('build helper does not discover environment values or construct local provi
undefined,
);
assert.deepEqual(
config.plugins.slice(3).map((plugin) => plugin.name),
['embed-framing'],
config.plugins.slice(2).map((plugin) => plugin.name),
['embed-framing', 'panel-build'],
);
} finally {
if (before === undefined) delete process.env.GOOGLE_MAPS_API_KEY;
@@ -106,14 +66,15 @@ test('root config retains existing named exports and standalone provider order',
assert.equal(compatibility[name], value, name);
const config = standaloneConfig({ mode: 'test' });
assert.deepEqual(
config.plugins.slice(3, -3).map((plugin) => plugin.name),
config.plugins.slice(2, -4).map((plugin) => plugin.name),
providers.localProviderPlugins().map((plugin) => plugin.name),
);
assert.equal(config.plugins.at(-4).name, 'gev-key-setup');
assert.equal(config.plugins.at(-5).name, 'gev-key-setup');
// The local MCP route follows every provider and precedes the API fallback.
assert.equal(config.plugins.at(-3).name, 'local-mcp');
assert.equal(config.plugins.at(-2).name, 'api-not-found');
assert.equal(config.plugins.at(-1).name, 'embed-framing');
assert.equal(config.plugins.at(-4).name, 'local-mcp');
assert.equal(config.plugins.at(-3).name, 'api-not-found');
assert.equal(config.plugins.at(-2).name, 'embed-framing');
assert.equal(config.plugins.at(-1).name, 'panel-build');
});
test('build export resolves in Node and has no browser fallback', async () => {
+30 -6
View File
@@ -11,6 +11,8 @@ import { assertSupportedSchema, validateValue } from './schema.js';
const TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
const KINDS = new Set(['query', 'action']);
/** Who may call a tool, as MCP Apps names them: the model, the app's panel. */
const VISIBILITY = new Set(['model', 'app']);
/** Error codes a tool may report. Each is safe to show to a model or person. */
export const TOOL_ERROR_CODES = Object.freeze([
@@ -93,11 +95,26 @@ export function defineTool({
)
throw new TypeError(`${name}.requires must list service names`);
if (typeof run !== 'function') throw new TypeError(`${name} needs run()`);
if (
ui !== null &&
!(typeof ui?.resourceUri === 'string' && ui.resourceUri.startsWith('ui://'))
)
throw new TypeError(`${name}.ui needs a ui:// resourceUri`);
if (ui !== null) {
if (
ui?.resourceUri !== undefined &&
!(
typeof ui.resourceUri === 'string' && ui.resourceUri.startsWith('ui://')
)
)
throw new TypeError(`${name}.ui.resourceUri must be a ui:// URI`);
if (
ui?.visibility !== undefined &&
!(
Array.isArray(ui.visibility) &&
ui.visibility.length > 0 &&
ui.visibility.every((who) => VISIBILITY.has(who))
)
)
throw new TypeError(`${name}.ui.visibility must list model and/or app`);
if (ui?.resourceUri === undefined && ui?.visibility === undefined)
throw new TypeError(`${name}.ui needs a resourceUri or a visibility`);
}
return Object.freeze({
name,
kind,
@@ -109,7 +126,14 @@ export function defineTool({
...annotations,
}),
requires: Object.freeze([...requires]),
ui: ui ? Object.freeze({ resourceUri: ui.resourceUri }) : null,
ui: ui
? Object.freeze({
...(ui.resourceUri ? { resourceUri: ui.resourceUri } : {}),
...(ui.visibility
? { visibility: Object.freeze([...ui.visibility]) }
: {}),
})
: null,
run,
});
}
+42 -296
View File
@@ -1,15 +1,21 @@
/**
* 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 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.
* the app's panel build inside itself in embed mode, loading it through the
* MCP server, 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';
/** Where the app's server serves its panel build (see build/panel.js). */
export const PANEL_BASE = '/panel/';
/** Cesium's workers as one script, relative to the panel build. */
export const PANEL_WORKERS_PATH = 'cesium/workers.js';
/**
* 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.
* terrain and fonts. Everything from the app's own server comes through the
* MCP server.
*/
const PROVIDER_ORIGINS = Object.freeze([
'https://tile.googleapis.com',
@@ -31,19 +37,31 @@ const PROVIDER_ORIGINS = Object.freeze([
'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;
const LOAD_TIMEOUT_MS = 90_000;
/** The panel-only tool the panel loads the app through. */
const PANEL_REQUEST_TOOL = 'panel_request';
/**
* The panel page. It completes the MCP Apps handshake with its host, then
* 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.
* The panel page: its status line, the Open in God's Eye View button, and
* the panel's script, `runtime`, which loads the app through the MCP server
* and shows each view a tool returns.
*/
function panelHtml(appOrigin) {
function panelHtml(runtime) {
const config = {
loadTimeoutMs: LOAD_TIMEOUT_MS,
panelBase: PANEL_BASE,
panelHeight: PANEL_HEIGHT_PX,
protocolVersion: MCP_APPS_PROTOCOL_VERSION,
toolName: PANEL_REQUEST_TOOL,
workersPath: PANEL_WORKERS_PATH,
};
const script = `(${runtime})(${JSON.stringify(config)});`.replace(
/<\/script/gi,
'<\\/script',
);
return `<!doctype html>
<html lang="en">
<head>
@@ -53,7 +71,7 @@ function panelHtml(appOrigin) {
html, body { margin: 0; height: ${PANEL_HEIGHT_PX}px; background: #05070a; color: #b8c4cc;
font: 13px/1.4 system-ui, sans-serif; overflow: hidden; }
#status { position: absolute; inset: 0; z-index: 10000; display: flex; align-items: center;
justify-content: center; }
justify-content: center; padding: 0 24px; text-align: 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; }
@@ -62,310 +80,38 @@ function panelHtml(appOrigin) {
<body>
<div id="status">Waiting for a view…</div>
<button id="open" type="button" hidden>Open in God's Eye View</button>
<script>
(() => {
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();
const send = (message) =>
window.parent.postMessage({ jsonrpc: '2.0', ...message }, '*');
const request = (method, params) =>
new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
send({ id, method, params });
});
const notify = (method, params) => send({ method, params });
let started = false;
let ready = false;
let queued = null;
let currentUrl = null;
const postView = (view) =>
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
// The panel page lives on the host's site, which serves none of the app.
// Hosts may refuse <base>, so the app's own addresses (relative ones, and
// absolute ones on this page's site) are sent to the app's server instead.
// The page's own site, by scheme and host: location.origin is "null" in a
// srcdoc document, and a host may serve the page from its own scheme.
const PAGE = new URL(document.baseURI);
const toApp = (value) => {
let url;
try {
url = new URL(String(value), document.baseURI);
} catch {
return value;
}
if (url.protocol !== PAGE.protocol || url.host !== PAGE.host) return value;
return APP_ORIGIN + url.pathname + url.search + url.hash;
};
const isRemote = (value) => {
try {
return new URL(String(value), document.baseURI).origin === APP_ORIGIN;
} catch {
return false;
}
};
function redirectRequests() {
const nativeFetch = window.fetch;
window.fetch = (input, init) =>
nativeFetch.call(
window,
input instanceof Request
? new Request(toApp(input.url), input)
: typeof input === 'string' || input instanceof URL
? toApp(input)
: input,
init,
);
const nativeOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
return nativeOpen.call(this, method, toApp(url), ...rest);
};
const nativeBeacon = navigator.sendBeacon?.bind(navigator);
if (nativeBeacon)
navigator.sendBeacon = (url, data) => nativeBeacon(toApp(url), data);
for (const [type, attribute] of [
[HTMLImageElement, 'src'],
[HTMLMediaElement, 'src'],
[HTMLSourceElement, 'src'],
[HTMLLinkElement, 'href'],
]) {
const property = Object.getOwnPropertyDescriptor(type.prototype, attribute);
Object.defineProperty(type.prototype, attribute, {
...property,
set(value) {
const target = toApp(value);
// The app treated the address as its own site; now that it is
// another site, images must load with CORS or WebGL refuses them.
if (target !== value && 'crossOrigin' in this && this.crossOrigin === null)
this.crossOrigin = 'anonymous';
property.set.call(this, target);
},
});
}
// Workers must come from this page's site; one on the app's server
// starts through a small worker here that imports it.
const NativeWorker = window.Worker;
window.Worker = function Worker(url, options) {
const target = toApp(url);
if (!isRemote(target)) return new NativeWorker(url, options);
const source =
options && options.type === 'module'
? 'import ' + JSON.stringify(String(target)) + ';'
: 'importScripts(' + JSON.stringify(String(target)) + ');';
return new NativeWorker(
URL.createObjectURL(new Blob([source], { type: 'text/javascript' })),
options,
);
};
window.Worker.prototype = NativeWorker.prototype;
// Styles the app adds while it runs name images by root-relative url().
const rewriteStyle = (style) => {
const text = style.textContent;
const next = text.replace(/url\\((['"]?)\\//g, 'url($1' + APP_ORIGIN + '/');
if (next !== text) style.textContent = next;
};
new MutationObserver((records) => {
for (const record of records) {
const target = record.target.nodeName === 'STYLE' ? [record.target] : [];
for (const node of [...target, ...record.addedNodes]) {
if (node.nodeName === 'STYLE') rewriteStyle(node);
// Markup the app adds as HTML text sets src without the setters.
if (node.nodeType !== 1) continue;
for (const element of [node, ...node.querySelectorAll('[src]')]) {
const value = element.getAttribute('src');
if (!value || toApp(value) === value) continue;
if ('crossOrigin' in element && element.crossOrigin === null)
element.crossOrigin = 'anonymous';
element.setAttribute('src', toApp(value));
}
}
}
}).observe(document.documentElement, {
childList: true,
subtree: true,
characterData: true,
});
}
/** Point an imported element's own addresses at the app's server. */
function absolutize(root) {
for (const element of [root, ...root.querySelectorAll('*')]) {
for (const attribute of ['src', 'href', 'poster']) {
const value = element.getAttribute(attribute);
if (value && !value.startsWith('#'))
element.setAttribute(attribute, new URL(value, APP_ORIGIN + '/').href);
}
}
return root;
}
/**
* Load God's Eye View into this page. Hosts may not frame other sites, so
* the panel runs the app itself: its requests go to the app's server, its
* styles and markup are copied in, and its scripts run here with the app
* in inline embed mode.
*/
async function startApp(url) {
window.GEV_EMBED_INLINE = true;
redirectRequests();
// Start at the first view, as its link would, instead of the default
// place; the app reads the view from this page's address.
try {
history.replaceState(null, '', location.href.split('#')[0] + new URL(url).hash);
} catch {
// The view still arrives once the app is ready.
}
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(absolutize(document.importNode(node, true)));
for (const node of [...page.body.childNodes]) {
if (node.nodeName === 'SCRIPT') continue;
const copy = document.importNode(node, true);
document.body.insertBefore(copy.nodeType === 1 ? absolutize(copy) : copy, 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'), APP_ORIGIN + '/').href;
else script.textContent = original.textContent;
document.body.appendChild(script);
}
}
function show(result) {
const data = result && result.structuredContent;
const view = data && data.view;
const url = (data && data.url) || (view && view.url);
if (!view || !url || new URL(url).origin !== APP_ORIGIN) return;
currentUrl = url;
open.hidden = false;
if (ready) return postView(view);
queued = view;
if (started) return;
started = true;
status.textContent = "Loading God's Eye View…";
startApp(url).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 (event.source === window) {
if (message && message.type === 'gev:ready') {
ready = true;
status.remove();
if (queued) postView(queued);
queued = null;
}
return;
}
if (event.source !== window.parent || !message || message.jsonrpc !== '2.0')
return;
if (message.id !== undefined && pending.has(message.id) && !message.method) {
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) reject(message.error);
else resolve(message.result);
return;
}
if (message.method === 'ui/notifications/tool-result') show(message.params);
});
open.addEventListener('click', () => {
if (!currentUrl) return;
request('ui/open-link', { url: currentUrl }).catch(() =>
window.open(currentUrl, '_blank', 'noopener'),
);
});
// The handshake the MCP Apps SDK's App.connect performs: hosts validate
// these parameters, and keep a view hidden until it is initialized.
request('ui/initialize', {
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
appInfo: { name: 'gods-eye-view', title: "God's Eye View", version: '1.0.0' },
protocolVersion: ${JSON.stringify(MCP_APPS_PROTOCOL_VERSION)},
}).then(() => {
send({ method: 'ui/notifications/initialized' });
notify('ui/notifications/size-changed', {
width: document.body.clientWidth,
height: ${PANEL_HEIGHT_PX},
});
}, (error) => {
status.textContent =
"This client did not accept the God's Eye View panel" +
(error && error.message ? ': ' + error.message : '.');
});
})();
</script>
<script>${script}</script>
</body>
</html>
`;
}
/**
* 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.
* The panel as an MCP resource. `runtime` is the panel's script, a function
* of its configuration that runs in the panel page; the application supplies
* it (src/app/globePanelRuntime.js), as it is browser code. The security
* policy lets the panel load map imagery, tiles and fonts from their
* providers; everything from the app's own server arrives through the MCP
* server instead.
*/
export function createGlobePanelResource({ appUrl }) {
const appOrigin = new URL(appUrl).origin;
export function createGlobePanelResource({ runtime }) {
if (typeof runtime !== 'function')
throw new TypeError('The globe panel needs its runtime script');
return Object.freeze({
uri: GLOBE_PANEL_URI,
name: 'globe',
title: "God's Eye View globe",
description: "Live God's Eye View, showing the view a tool returns.",
mimeType: MCP_APP_MIME_TYPE,
text: panelHtml(appOrigin),
text: panelHtml(runtime),
_meta: {
ui: {
csp: {
resourceDomains: [appOrigin, ...PROVIDER_ORIGINS],
connectDomains: [
appOrigin,
socketOrigin(appOrigin),
...PROVIDER_ORIGINS,
],
resourceDomains: [...PROVIDER_ORIGINS],
connectDomains: [...PROVIDER_ORIGINS],
},
prefersBorder: true,
},
// The same policy under the key OpenAI's clients read.
'openai/widgetCSP': {
connect_domains: [
appOrigin,
socketOrigin(appOrigin),
...PROVIDER_ORIGINS,
],
resource_domains: [appOrigin, ...PROVIDER_ORIGINS],
},
},
});
}
+17 -24
View File
@@ -5,42 +5,35 @@ import {
MCP_APP_MIME_TYPE,
createGlobePanelResource,
} from './globePanel.js';
import { panelRuntime } from '../app/globePanelRuntime.js';
import { composeCatalog, coreTools } from './index.js';
test('the globe panel is an MCP Apps resource that runs the app inside itself', () => {
const resource = createGlobePanelResource({
appUrl: 'http://localhost:5173/some/path',
});
test('the globe panel is an MCP Apps resource that loads the app through its server', () => {
const resource = createGlobePanelResource({ runtime: panelRuntime });
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');
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',
]);
// The panel reaches only map and font providers; the app's own files
// and data come through the MCP server, so no app address is declared.
assert.deepEqual(csp.connectDomains, csp.resourceDomains);
assert.ok(csp.resourceDomains.includes('https://tile.googleapis.com'));
// Hosts may refuse <base>; the panel sends the app's requests itself.
assert.ok(
csp.resourceDomains.every((origin) => origin.startsWith('https://')),
);
assert.equal(csp.frameDomains, undefined);
assert.equal(csp.baseUriDomains, undefined);
assert.deepEqual(resource._meta['openai/widgetCSP'], {
connect_domains: csp.connectDomains,
resource_domains: csp.resourceDomains,
});
assert.doesNotMatch(resource.text, /createElement\('base'\)/);
assert.match(resource.text, /window\.GEV_EMBED_INLINE = true/);
assert.match(resource.text, /fetch\(APP_ORIGIN \+ '\/\?embed=1'\)/);
assert.doesNotMatch(resource.text, /<iframe|createElement\('iframe'\)/);
assert.match(resource.text, /const APP_ORIGIN = "http:\/\/localhost:5173";/);
assert.match(resource.text, /"toolName":"panel_request"/);
assert.match(resource.text, /"panelBase":"\/panel\/"/);
assert.match(resource.text, /'ui\/initialize'/);
assert.match(resource.text, /'ui\/notifications\/tool-result'/);
assert.match(resource.text, /protocolVersion: "2026-01-26"/);
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.match(resource.text, /appInfo: \{/);
assert.doesNotMatch(resource.text, /clientInfo/);
assert.doesNotMatch(resource.text, /<iframe|createElement\('base'\)/);
const script = resource.text.match(/<script>([\s\S]*)<\/script>/)[1];
assert.doesNotThrow(() => new Function(script));
});
test('show_in_gods_eye_view names the panel and shows a view another answer returned', async () => {
+2
View File
@@ -40,6 +40,7 @@ import {
getTransitVehicles,
} from './queries/mobility.js';
import { placesNearby, planRoute, searchPlaces } from './queries/places.js';
import { panelRequest } from './queries/panelRequest.js';
import { showInGodsEyeView } from './queries/share.js';
import { findAlprCameras } from './queries/surveillance.js';
import {
@@ -122,4 +123,5 @@ export const coreTools = Object.freeze([
militaryAwareness,
getHudCaption,
showInGodsEyeView,
panelRequest,
]);
+37 -1
View File
@@ -274,6 +274,42 @@ test('resources are listed and read, and tools name their UI resource', async ()
ui: { resourceUri: 'https://a.example' },
run: async () => ({ summary: '', data: {} }),
}),
/ui:\/\/ resourceUri/,
/ui:\/\/ URI/,
);
assert.throws(
() =>
defineTool({
name: 'bad',
title: 'Bad',
description: 'Visible to nobody.',
inputSchema: { type: 'object' },
ui: { visibility: ['user'] },
run: async () => ({ summary: '', data: {} }),
}),
/visibility/,
);
});
test('a tool only an app may call says so, and answers it without JSON text', async () => {
const loader = defineTool({
name: 'load',
title: 'Load',
description: 'Loads a file for the view.',
inputSchema: { type: 'object', properties: {} },
ui: { visibility: ['app'] },
run: async () => ({ summary: 'loaded', data: { body: 'abc' } }),
});
const appServer = createMcpServer({
catalog: composeCatalog({ tools: [loader] }),
name: 'fixture',
version: '1',
});
const call = async (method, params) =>
(await appServer.handle({ jsonrpc: '2.0', id: 1, method, params })).result;
assert.deepEqual((await call('tools/list')).tools[0]._meta, {
ui: { visibility: ['app'] },
});
const result = await call('tools/call', { name: 'load', arguments: {} });
assert.deepEqual(result.content, [{ type: 'text', text: 'loaded' }]);
assert.deepEqual(result.structuredContent, { body: 'abc' });
});
+13 -5
View File
@@ -41,11 +41,13 @@ export function toMcpTools(catalog, { descriptions = {}, decorate } = {}) {
annotations: { title, ...tool.annotations },
...(tool.ui
? {
// The flat key is the earlier form of the same field, which some
// hosts still read; the MCP Apps SDK sends both.
_meta: {
ui: { resourceUri: tool.ui.resourceUri },
'ui/resourceUri': tool.ui.resourceUri,
ui: { ...tool.ui },
// The flat key is the earlier form of the same field, which
// some hosts still read; the MCP Apps SDK sends both.
...(tool.ui.resourceUri
? { 'ui/resourceUri': tool.ui.resourceUri }
: {}),
},
}
: {}),
@@ -111,12 +113,18 @@ export function createMcpServer({
const result = await catalog.call(params.name, params.arguments ?? {}, {
signal,
});
// Only an app reads a tool the model cannot call, and apps read
// structuredContent.
const appOnly =
catalog.get(params.name).ui?.visibility?.includes('model') === false;
return {
// The data is repeated as JSON text for clients that do not read
// structuredContent.
content: [
{ type: 'text', text: result.summary },
{ type: 'text', text: JSON.stringify(result.data) },
...(appOnly
? []
: [{ type: 'text', text: JSON.stringify(result.data) }]),
...(result.images || []).map((item) => ({
type: 'image',
data: item.data,
+184
View File
@@ -0,0 +1,184 @@
/**
* The God's Eye View panel's requests. A panel cannot reach the app's server
* itself: hosts serve panels from their own sites and may refuse other
* addresses, such as a server on the user's machine. The panel asks this
* tool instead, which only an app may call, and it requests the path from
* the app's server. Large responses come back in parts.
*/
import { defineTool, ToolError } from '../catalog.js';
/** Bytes of response body per call, before base64. */
export const PANEL_PART_BYTES = 512 * 1024;
const HELD_MS = 2 * 60 * 1000;
const HELD_LIMIT_BYTES = 256 * 1024 * 1024;
const METHODS = new Set(['GET', 'HEAD', 'POST']);
// Provider Settings write the app's keys; only the app's own page may.
const REFUSED_PATHS = [/^\/api\/setup(?:\/|$)/];
const FORWARDED_REQUEST_HEADERS = ['accept', 'content-type'];
const DROPPED_RESPONSE_HEADERS = new Set([
'connection',
'content-encoding',
'content-length',
'keep-alive',
'set-cookie',
'transfer-encoding',
]);
// Already compressed, so gzip would only cost time.
const INCOMPRESSIBLE =
/^(?:image\/(?!svg)|video\/|audio\/|font\/woff2)|zip|compressed/;
const COMPRESS_MIN_BYTES = 1024;
// Bodies too large for one call, kept for the calls that read the rest.
const held = new Map();
let heldBytes = 0;
function base64(bytes) {
let binary = '';
for (let start = 0; start < bytes.length; start += 0x8000)
binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));
return btoa(binary);
}
function fromBase64(text) {
return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
}
async function gzip(bytes) {
const stream = new Blob([bytes])
.stream()
.pipeThrough(new CompressionStream('gzip'));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
function forget(now = Date.now()) {
for (const [id, entry] of held) {
if (entry.expires > now && heldBytes <= HELD_LIMIT_BYTES) continue;
held.delete(id);
heldBytes -= entry.bytes.length;
}
}
function part(response, bytes, offset) {
const end = Math.min(bytes.length, offset + PANEL_PART_BYTES);
return {
...response,
offset,
body: base64(bytes.subarray(offset, end)),
...(end < bytes.length ? { nextOffset: end } : {}),
};
}
function checkedPath(path) {
if (
typeof path !== 'string' ||
!path.startsWith('/') ||
path.startsWith('//')
)
throw new ToolError('invalid_arguments', 'path must start with one /');
const { pathname } = new URL(path, 'http://app.invalid');
if (REFUSED_PATHS.some((pattern) => pattern.test(pathname)))
throw new ToolError('invalid_arguments', `${pathname} is not available`);
return path;
}
export const panelRequest = defineTool({
name: 'panel_request',
title: "God's Eye View panel request",
description:
"Loads a file or data for the God's Eye View panel from the app's " +
'server. Only the panel calls this; it does not answer questions.',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: "A path on the app's server, starting with /.",
},
method: { type: 'string', enum: [...METHODS] },
headers: {
type: 'object',
description: 'Request headers; only Accept and Content-Type are sent.',
},
body: { type: 'string', description: 'The request body, base64.' },
id: {
type: 'string',
description: 'Continue a response an earlier call returned in parts.',
},
offset: { type: 'integer', minimum: 0 },
},
additionalProperties: false,
},
requires: ['app'],
ui: { visibility: ['app'] },
async run(args, { services, signal }) {
forget();
if (args.id !== undefined) {
const entry = held.get(args.id);
if (!entry)
throw new ToolError(
'invalid_arguments',
'That response is no longer held; request the path again',
);
return {
summary: `Part of ${entry.path}`,
data: part(entry.response, entry.bytes, args.offset ?? 0),
};
}
const path = checkedPath(args.path);
const method = args.method ?? 'GET';
if (!METHODS.has(method))
throw new ToolError('invalid_arguments', `Unsupported method ${method}`);
const headers = {};
for (const [name, value] of Object.entries(args.headers ?? {})) {
if (FORWARDED_REQUEST_HEADERS.includes(name.toLowerCase()))
headers[name] = String(value);
}
let answer;
try {
answer = await services.app.fetch(path, {
method,
headers,
...(args.body !== undefined && method === 'POST'
? { body: fromBase64(args.body) }
: {}),
signal,
});
} catch (error) {
if (signal?.aborted) throw error;
throw new ToolError('unavailable', "The app's server did not answer");
}
let bytes = new Uint8Array(await answer.arrayBuffer());
const type = answer.headers.get('content-type') || '';
let encoding = 'identity';
if (bytes.length >= COMPRESS_MIN_BYTES && !INCOMPRESSIBLE.test(type)) {
bytes = await gzip(bytes);
encoding = 'gzip';
}
const responseHeaders = {};
answer.headers.forEach((value, name) => {
if (!DROPPED_RESPONSE_HEADERS.has(name)) responseHeaders[name] = value;
});
const response = {
status: answer.status,
statusText: answer.statusText,
headers: responseHeaders,
encoding,
totalBytes: bytes.length,
};
if (bytes.length > PANEL_PART_BYTES) {
response.id = crypto.randomUUID();
held.set(response.id, {
path,
response,
bytes,
expires: Date.now() + HELD_MS,
});
heldBytes += bytes.length;
}
return {
summary: `${answer.status} ${method} ${path}`,
data: part(response, bytes, 0),
};
},
});
+109
View File
@@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
import { PANEL_PART_BYTES } from './panelRequest.js';
const fromBase64 = (text) => Buffer.from(text, 'base64');
const gunzip = async (bytes) =>
new Uint8Array(
await new Response(
new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')),
).arrayBuffer(),
);
function catalogWith(handler) {
const requests = [];
const fetchImpl = async (path, init) => {
requests.push({ path, ...init });
return handler(path, init);
};
return {
requests,
catalog: composeCatalog({
tools: coreTools,
services: {
app: { baseUrl: 'http://localhost:4173/', fetch: fetchImpl },
},
}),
};
}
test('panel_request is for the panel only', () => {
const { catalog } = catalogWith(() => new Response('x'));
assert.deepEqual(catalog.get('panel_request').ui, { visibility: ['app'] });
});
test('a small response returns whole, compressed when that helps', async () => {
const text = 'body { color: red; }\n'.repeat(200);
const { catalog, requests } = catalogWith(
() =>
new Response(text, {
status: 200,
headers: { 'content-type': 'text/css', 'set-cookie': 'a=b' },
}),
);
const { data } = await catalog.call('panel_request', {
path: '/panel/assets/style.css',
headers: { Accept: 'text/css', Cookie: 'secret', 'X-Other': '1' },
});
assert.deepEqual(requests[0].headers, { Accept: 'text/css' });
assert.equal(data.status, 200);
assert.equal(data.encoding, 'gzip');
assert.equal(data.nextOffset, undefined);
assert.equal(data.headers['content-type'], 'text/css');
assert.equal(data.headers['set-cookie'], undefined);
const body = await gunzip(fromBase64(data.body));
assert.equal(new TextDecoder().decode(body), text);
});
test('a large response comes in parts that join to the original', async () => {
const bytes = new Uint8Array(PANEL_PART_BYTES * 2 + 10);
for (let index = 0; index < bytes.length; index++)
bytes[index] = (index * 7919) % 251;
const { catalog } = catalogWith(
() => new Response(bytes, { headers: { 'content-type': 'image/png' } }),
);
let { data } = await catalog.call('panel_request', { path: '/a.png' });
assert.equal(data.encoding, 'identity');
assert.equal(data.totalBytes, bytes.length);
const parts = [fromBase64(data.body)];
while (data.nextOffset !== undefined) {
({ data } = await catalog.call('panel_request', {
id: data.id,
offset: data.nextOffset,
}));
parts.push(fromBase64(data.body));
}
assert.equal(parts.length, 3);
assert.deepEqual(new Uint8Array(Buffer.concat(parts)), bytes);
});
test('a request body and method pass through; settings and other sites do not', async () => {
const { catalog, requests } = catalogWith(
() => new Response('{}', { status: 201 }),
);
const { data } = await catalog.call('panel_request', {
path: '/api/openai/hud-summary',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: Buffer.from('{"a":1}').toString('base64'),
});
assert.equal(data.status, 201);
assert.equal(new TextDecoder().decode(requests[0].body), '{"a":1}');
for (const path of [
'/api/setup/status',
'/api/setup',
'//evil.example/x',
'http://evil.example/',
])
await assert.rejects(
catalog.call('panel_request', { path }),
(error) => error.code === 'invalid_arguments',
path,
);
await assert.rejects(
catalog.call('panel_request', { id: 'unknown' }),
(error) => error.code === 'invalid_arguments',
);
assert.equal(requests.length, 1);
});
+2 -2
View File
@@ -3,7 +3,7 @@
* factories and the application request services. `fetchImpl` must resolve
* the sources' relative `/api/...` paths; in a browser the page's own fetch
* does, and elsewhere a caller supplies a resolving fetch. `appUrl` is the
* address the app is served from.
* address the app is served from; `app.fetch` requests the app's own paths.
*/
import { createAlprTileSource } from '../layers/alpr/source.js';
@@ -45,7 +45,7 @@ export function createToolServices({ fetchImpl, appUrl }) {
throw new TypeError('A fetch implementation is required');
const requests = createApplicationRequestServices({ fetchImpl });
return {
app: { baseUrl: appUrl },
app: { baseUrl: appUrl, fetch: fetchImpl },
earthquakes: createUsgsEarthquakeSource({ fetchImpl }),
fires: createFirmsSource({ fetchImpl }),
launches: createLaunchSource({ fetchImpl }),
+2
View File
@@ -16,6 +16,8 @@ const mcpOff = Object.freeze({ mcp: false });
export const TOOL_SURFACES = Object.freeze({
// Voice runs inside the app, so a link to it adds nothing.
show_in_gods_eye_view: voiceOff,
// Only the God's Eye View panel calls this, to load the app.
panel_request: 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.