mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
b11d54d8b8
commit
996b95eb45
20 files changed
+1265
-437
No files matched your search
+30
-6
@@ -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
@@ -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],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user