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 { applicationHtmlPlugin } from './application-html.js';
import cesium from 'vite-plugin-cesium'; import cesium from 'vite-plugin-cesium';
import { embedFramingPlugin } from './embed-framing.js'; import { embedFramingPlugin } from './embed-framing.js';
import { panelBuildPlugin } from './panel.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,
};
}
/** Build browser assets with explicit inputs; never load environment or providers. */ /** Build browser assets with explicit inputs; never load environment or providers. */
export function createBrowserViteConfig({ export function createBrowserViteConfig({
@@ -54,11 +15,11 @@ export function createBrowserViteConfig({
} = {}) { } = {}) {
return { return {
plugins: [ plugins: [
panelCorsPlugin(),
cesium(), cesium(),
applicationHtmlPlugin(), applicationHtmlPlugin(),
...plugins, ...plugins,
embedFramingPlugin(), embedFramingPlugin(),
panelBuildPlugin(),
], ],
...(publicDir === undefined ? {} : { publicDir }), ...(publicDir === undefined ? {} : { publicDir }),
// A production build must not clean the dependency cache a running dev // 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`): `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` `_meta.ui.resourceUri` is `ui://gods-eye-view/globe`, a `text/html;profile=mcp-app`
resource from `createGlobePanelResource({ appUrl })` in resource from `createGlobePanelResource({ runtime })` in
`src/tools/globePanel.js`. Clients that display apps render it inside the `src/tools/globePanel.js`, with the panel's script from
conversation. The panel completes the MCP Apps handshake (`ui/initialize`, `src/app/globePanelRuntime.js`. Clients that display apps render it inside
`ui/notifications/initialized`, `ui/notifications/size-changed`), and for the conversation. The panel completes the MCP Apps handshake
each `ui/notifications/tool-result` carrying a view it loads the app in embed (`ui/initialize`, `ui/notifications/initialized`,
mode the first time and posts later views to that same app, so the globe `ui/notifications/size-changed`), and for each
changes without reloading. Its resource allows framing only the app's origin `ui/notifications/tool-result` carrying a view it loads the app in inline
(`csp.frameDomains`), and its "Open in God's Eye View" button asks the host embed mode the first time and posts later views to that same app, so the
to open the link (`ui/open-link`). Tools declare a UI resource with globe changes without reloading. Its "Open in God's Eye View" button asks
`defineTool({ ui: { resourceUri } })`; `createMcpServer({ resources })` the host to open the link (`ui/open-link`).
serves `resources/list` and `resources/read`.
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 ### 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 | | `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 | | `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 | | `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", "dev:secure": "./scripts/dev-secure.sh",
"opensky:import": "./scripts/opensky-import-client.sh", "opensky:import": "./scripts/opensky-import-client.sh",
"build": "vite build", "build": "vite build",
"build:panel": "node scripts/build-panel.mjs",
"preview": "vite preview", "preview": "vite preview",
"test": "node scripts/run-unit-tests.mjs", "test": "node scripts/run-unit-tests.mjs",
"test:track": "node scripts/track-regression.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": [ "modules": [
"build/vite.js", "build/vite.js",
"build/application-html.js", "build/application-html.js",
"build/embed-framing.js" "build/embed-framing.js",
"build/panel.js"
], ],
"external": ["vite-plugin-cesium"] "external": ["vite-plugin-cesium"]
}, },
@@ -3140,7 +3141,8 @@
"src/data/installationFeedback.js", "src/data/installationFeedback.js",
"src/view/index.js", "src/view/index.js",
"src/tools/views.js", "src/tools/views.js",
"src/tools/globePanel.js" "src/tools/globePanel.js",
"src/tools/queries/panelRequest.js"
], ],
"external": ["satellite.js"] "external": ["satellite.js"]
}, },
+2 -1
View File
@@ -6,6 +6,7 @@ import {
coreTools, coreTools,
catalogForSurface, catalogForSurface,
} from '../../src/tools/index.js'; } from '../../src/tools/index.js';
import { panelRuntime } from '../../src/app/globePanelRuntime.js';
import { createGlobePanelResource } from '../../src/tools/globePanel.js'; import { createGlobePanelResource } from '../../src/tools/globePanel.js';
import { createMcpServer } from '../../src/tools/mcp/index.js'; import { createMcpServer } from '../../src/tools/mcp/index.js';
import { DEFAULT_API_BASE, createLocalToolServices } from './services.js'; import { DEFAULT_API_BASE, createLocalToolServices } from './services.js';
@@ -38,6 +39,6 @@ export function createLocalMcpServer({
name: 'gods-eye-view', name: 'gods-eye-view',
version, version,
instructions: INSTRUCTIONS, 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', googleApiKey: 'browser-fixture',
cesiumToken: 'ion-fixture', cesiumToken: 'ion-fixture',
}); });
assert.equal(config.plugins[0].name, 'panel-cors'); assert.equal(config.plugins[2], plugin);
assert.equal(config.plugins[3], plugin);
assert.equal(config.server.host, 'localhost'); assert.equal(config.server.host, 'localhost');
assert.equal(config.server.port, 4173); assert.equal(config.server.port, 4173);
assert.deepEqual(config.server.allowedHosts, [ 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', () => { test('build helper does not discover environment values or construct local providers', () => {
const before = process.env.GOOGLE_MAPS_API_KEY; const before = process.env.GOOGLE_MAPS_API_KEY;
process.env.GOOGLE_MAPS_API_KEY = 'environment-fixture'; 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, undefined,
); );
assert.deepEqual( assert.deepEqual(
config.plugins.slice(3).map((plugin) => plugin.name), config.plugins.slice(2).map((plugin) => plugin.name),
['embed-framing'], ['embed-framing', 'panel-build'],
); );
} finally { } finally {
if (before === undefined) delete process.env.GOOGLE_MAPS_API_KEY; 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); assert.equal(compatibility[name], value, name);
const config = standaloneConfig({ mode: 'test' }); const config = standaloneConfig({ mode: 'test' });
assert.deepEqual( 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), 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. // 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(-4).name, 'local-mcp');
assert.equal(config.plugins.at(-2).name, 'api-not-found'); assert.equal(config.plugins.at(-3).name, 'api-not-found');
assert.equal(config.plugins.at(-1).name, 'embed-framing'); 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 () => { 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 TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
const KINDS = new Set(['query', 'action']); 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. */ /** Error codes a tool may report. Each is safe to show to a model or person. */
export const TOOL_ERROR_CODES = Object.freeze([ export const TOOL_ERROR_CODES = Object.freeze([
@@ -93,11 +95,26 @@ export function defineTool({
) )
throw new TypeError(`${name}.requires must list service names`); throw new TypeError(`${name}.requires must list service names`);
if (typeof run !== 'function') throw new TypeError(`${name} needs run()`); if (typeof run !== 'function') throw new TypeError(`${name} needs run()`);
if ( if (ui !== null) {
ui !== null && if (
!(typeof ui?.resourceUri === 'string' && ui.resourceUri.startsWith('ui://')) ui?.resourceUri !== undefined &&
) !(
throw new TypeError(`${name}.ui needs a ui:// resourceUri`); 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({ return Object.freeze({
name, name,
kind, kind,
@@ -109,7 +126,14 @@ export function defineTool({
...annotations, ...annotations,
}), }),
requires: Object.freeze([...requires]), 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, run,
}); });
} }
+42 -296
View File
@@ -1,15 +1,21 @@
/** /**
* The God's Eye View 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 runs * 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 * the app's panel build inside itself in embed mode, loading it through the
* show_in_gods_eye_view tool returns; see docs/TOOLS.md. * 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'; 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, * 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([ const PROVIDER_ORIGINS = Object.freeze([
'https://tile.googleapis.com', 'https://tile.googleapis.com',
@@ -31,19 +37,31 @@ const PROVIDER_ORIGINS = Object.freeze([
'https://fonts.gstatic.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'; export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
const MCP_APPS_PROTOCOL_VERSION = '2026-01-26'; const MCP_APPS_PROTOCOL_VERSION = '2026-01-26';
const PANEL_HEIGHT_PX = 520; 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 * The panel page: its status line, the Open in God's Eye View button, and
* for each tool result that carries a view: loads the app in embed mode the * the panel's script, `runtime`, which loads the app through the MCP server
* first time, and posts the view to the app once it is ready, so later views * and shows each view a tool returns.
* change the globe without reloading.
*/ */
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> return `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -53,7 +71,7 @@ function panelHtml(appOrigin) {
html, body { margin: 0; height: ${PANEL_HEIGHT_PX}px; background: #05070a; color: #b8c4cc; html, body { margin: 0; height: ${PANEL_HEIGHT_PX}px; background: #05070a; color: #b8c4cc;
font: 13px/1.4 system-ui, sans-serif; overflow: hidden; } font: 13px/1.4 system-ui, sans-serif; overflow: hidden; }
#status { position: absolute; inset: 0; z-index: 10000; display: flex; align-items: center; #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; #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; } border-radius: 6px; background: rgba(5, 7, 10, 0.75); color: #dfe8ee; font: inherit; cursor: pointer; }
#open[hidden] { display: none; } #open[hidden] { display: none; }
@@ -62,310 +80,38 @@ function panelHtml(appOrigin) {
<body> <body>
<div id="status">Waiting for a view…</div> <div id="status">Waiting for a view…</div>
<button id="open" type="button" hidden>Open in God's Eye View</button> <button id="open" type="button" hidden>Open in God's Eye View</button>
<script> <script>${script}</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>
</body> </body>
</html> </html>
`; `;
} }
/** /**
* The panel as an MCP resource for an app served at `appUrl`. Its security * The panel as an MCP resource. `runtime` is the panel's script, a function
* policy lets the panel load the app's code from that origin and reach the * of its configuration that runs in the panel page; the application supplies
* map and imagery providers the app uses directly. * 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 }) { export function createGlobePanelResource({ runtime }) {
const appOrigin = new URL(appUrl).origin; if (typeof runtime !== 'function')
throw new TypeError('The globe panel needs its runtime script');
return Object.freeze({ return Object.freeze({
uri: GLOBE_PANEL_URI, uri: GLOBE_PANEL_URI,
name: 'globe', name: 'globe',
title: "God's Eye View globe", title: "God's Eye View globe",
description: "Live God's Eye View, showing the view a tool returns.", description: "Live God's Eye View, showing the view a tool returns.",
mimeType: MCP_APP_MIME_TYPE, mimeType: MCP_APP_MIME_TYPE,
text: panelHtml(appOrigin), text: panelHtml(runtime),
_meta: { _meta: {
ui: { ui: {
csp: { csp: {
resourceDomains: [appOrigin, ...PROVIDER_ORIGINS], resourceDomains: [...PROVIDER_ORIGINS],
connectDomains: [ connectDomains: [...PROVIDER_ORIGINS],
appOrigin,
socketOrigin(appOrigin),
...PROVIDER_ORIGINS,
],
}, },
prefersBorder: true, 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, MCP_APP_MIME_TYPE,
createGlobePanelResource, createGlobePanelResource,
} from './globePanel.js'; } from './globePanel.js';
import { panelRuntime } from '../app/globePanelRuntime.js';
import { composeCatalog, coreTools } from './index.js'; import { composeCatalog, coreTools } from './index.js';
test('the globe panel is an MCP Apps resource that runs the app inside itself', () => { test('the globe panel is an MCP Apps resource that loads the app through its server', () => {
const resource = createGlobePanelResource({ const resource = createGlobePanelResource({ runtime: panelRuntime });
appUrl: 'http://localhost:5173/some/path',
});
assert.equal(resource.uri, GLOBE_PANEL_URI); assert.equal(resource.uri, GLOBE_PANEL_URI);
assert.equal(resource.mimeType, MCP_APP_MIME_TYPE); assert.equal(resource.mimeType, MCP_APP_MIME_TYPE);
assert.equal(MCP_APP_MIME_TYPE, 'text/html;profile=mcp-app'); assert.equal(MCP_APP_MIME_TYPE, 'text/html;profile=mcp-app');
const { csp } = resource._meta.ui; const { csp } = resource._meta.ui;
// Hosts may not let a panel frame other sites, so it declares no frames // The panel reaches only map and font providers; the app's own files
// and instead loads the app's code and data from the app's origin. // and data come through the MCP server, so no app address is declared.
assert.equal(csp.frameDomains, undefined); assert.deepEqual(csp.connectDomains, csp.resourceDomains);
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.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.equal(csp.baseUriDomains, undefined);
assert.deepEqual(resource._meta['openai/widgetCSP'], { assert.match(resource.text, /"toolName":"panel_request"/);
connect_domains: csp.connectDomains, assert.match(resource.text, /"panelBase":"\/panel\/"/);
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, /'ui\/initialize'/); assert.match(resource.text, /'ui\/initialize'/);
assert.match(resource.text, /'ui\/notifications\/tool-result'/); 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. // 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, /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 () => { 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, getTransitVehicles,
} from './queries/mobility.js'; } from './queries/mobility.js';
import { placesNearby, planRoute, searchPlaces } from './queries/places.js'; import { placesNearby, planRoute, searchPlaces } from './queries/places.js';
import { panelRequest } from './queries/panelRequest.js';
import { showInGodsEyeView } from './queries/share.js'; import { showInGodsEyeView } from './queries/share.js';
import { findAlprCameras } from './queries/surveillance.js'; import { findAlprCameras } from './queries/surveillance.js';
import { import {
@@ -122,4 +123,5 @@ export const coreTools = Object.freeze([
militaryAwareness, militaryAwareness,
getHudCaption, getHudCaption,
showInGodsEyeView, 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' }, ui: { resourceUri: 'https://a.example' },
run: async () => ({ summary: '', data: {} }), 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 }, annotations: { title, ...tool.annotations },
...(tool.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.
_meta: { _meta: {
ui: { resourceUri: tool.ui.resourceUri }, ui: { ...tool.ui },
'ui/resourceUri': tool.ui.resourceUri, // 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 ?? {}, { const result = await catalog.call(params.name, params.arguments ?? {}, {
signal, 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 { return {
// The data is repeated as JSON text for clients that do not read // The data is repeated as JSON text for clients that do not read
// structuredContent. // structuredContent.
content: [ content: [
{ type: 'text', text: result.summary }, { type: 'text', text: result.summary },
{ type: 'text', text: JSON.stringify(result.data) }, ...(appOnly
? []
: [{ type: 'text', text: JSON.stringify(result.data) }]),
...(result.images || []).map((item) => ({ ...(result.images || []).map((item) => ({
type: 'image', type: 'image',
data: item.data, 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 * factories and the application request services. `fetchImpl` must resolve
* the sources' relative `/api/...` paths; in a browser the page's own fetch * 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 * 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'; import { createAlprTileSource } from '../layers/alpr/source.js';
@@ -45,7 +45,7 @@ export function createToolServices({ fetchImpl, appUrl }) {
throw new TypeError('A fetch implementation is required'); throw new TypeError('A fetch implementation is required');
const requests = createApplicationRequestServices({ fetchImpl }); const requests = createApplicationRequestServices({ fetchImpl });
return { return {
app: { baseUrl: appUrl }, app: { baseUrl: appUrl, fetch: fetchImpl },
earthquakes: createUsgsEarthquakeSource({ fetchImpl }), earthquakes: createUsgsEarthquakeSource({ fetchImpl }),
fires: createFirmsSource({ fetchImpl }), fires: createFirmsSource({ fetchImpl }),
launches: createLaunchSource({ fetchImpl }), launches: createLaunchSource({ fetchImpl }),
+2
View File
@@ -16,6 +16,8 @@ const mcpOff = Object.freeze({ mcp: false });
export const TOOL_SURFACES = Object.freeze({ export const TOOL_SURFACES = Object.freeze({
// Voice runs inside the app, so a link to it adds nothing. // Voice runs inside the app, so a link to it adds nothing.
show_in_gods_eye_view: voiceOff, 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. // Voice has an app action of the same name.
next_satellite_pass: voiceOff, next_satellite_pass: voiceOff,
// Voice receives text only, so tools that answer with an image are off. // Voice receives text only, so tools that answer with an image are off.