From 996b95eb4591e4a406b69b7458f6f38563c9888c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 13:42:06 -0700 Subject: [PATCH] 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. --- build/panel.js | 96 +++++ build/vite.js | 43 +- docs/TOOLS.md | 44 +- package.json | 1 + scripts/build-panel.mjs | 52 +++ scripts/package-boundaries.json | 6 +- server/mcp/server.js | 3 +- src/app/globePanelRuntime.js | 546 ++++++++++++++++++++++++ src/tooling/panelBuild.test.mjs | 82 ++++ src/tooling/viteBuild.test.mjs | 57 +-- src/tools/catalog.js | 36 +- src/tools/globePanel.js | 338 ++------------- src/tools/globePanel.test.mjs | 41 +- src/tools/index.js | 2 + src/tools/mcp/mcp.test.mjs | 38 +- src/tools/mcp/protocol.js | 18 +- src/tools/queries/panelRequest.js | 184 ++++++++ src/tools/queries/panelRequest.test.mjs | 109 +++++ src/tools/services.js | 4 +- src/tools/surfaces.js | 2 + 20 files changed, 1265 insertions(+), 437 deletions(-) create mode 100644 build/panel.js create mode 100644 scripts/build-panel.mjs create mode 100644 src/app/globePanelRuntime.js create mode 100644 src/tooling/panelBuild.test.mjs create mode 100644 src/tools/queries/panelRequest.js create mode 100644 src/tools/queries/panelRequest.test.mjs diff --git a/build/panel.js b/build/panel.js new file mode 100644 index 000000000..4198713f1 --- /dev/null +++ b/build/panel.js @@ -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', + ); + } + }); + }, + }; +} diff --git a/build/vite.js b/build/vite.js index 53b7e92e2..bd18aece9 100644 --- a/build/vite.js +++ b/build/vite.js @@ -1,46 +1,7 @@ import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; import { embedFramingPlugin } from './embed-framing.js'; - -/** Origins allowed to read the dev and preview servers' responses. */ -export const PANEL_CORS_ORIGINS = Object.freeze([ - /^https?:\/\/(?:(?:[^:]+\.)?localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/, - /^https:\/\/[a-z0-9]+\.claudemcpcontent\.com$/, - /^(?:https|codex-sandbox):\/\/[a-z0-9-]+\.web-sandbox\.oaiusercontent\.com$/, -]); - -/** - * Vite plugin answering cross-origin reads from PANEL_CORS_ORIGINS on every - * path. It runs ahead of the API middlewares, which Vite's own `cors` option - * would not cover. - */ -function panelCorsPlugin() { - const install = (server) => { - server.middlewares.use((req, res, next) => { - const origin = req.headers.origin; - if ( - !origin || - !PANEL_CORS_ORIGINS.some((allowed) => allowed.test(origin)) - ) - return next(); - res.setHeader('Access-Control-Allow-Origin', origin); - res.setHeader('Vary', 'Origin'); - if (req.method !== 'OPTIONS') return next(); - res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, POST, OPTIONS'); - res.setHeader( - 'Access-Control-Allow-Headers', - req.headers['access-control-request-headers'] || '', - ); - res.statusCode = 204; - res.end(); - }); - }; - return { - name: 'panel-cors', - configureServer: install, - configurePreviewServer: install, - }; -} +import { panelBuildPlugin } from './panel.js'; /** Build browser assets with explicit inputs; never load environment or providers. */ export function createBrowserViteConfig({ @@ -54,11 +15,11 @@ export function createBrowserViteConfig({ } = {}) { return { plugins: [ - panelCorsPlugin(), cesium(), applicationHtmlPlugin(), ...plugins, embedFramingPlugin(), + panelBuildPlugin(), ], ...(publicDir === undefined ? {} : { publicDir }), // A production build must not clean the dependency cache a running dev diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 4aba0a727..8a3f55985 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -108,17 +108,38 @@ into a view. `show_in_gods_eye_view` names an MCP Apps view (`io.modelcontextprotocol/ui`): `_meta.ui.resourceUri` is `ui://gods-eye-view/globe`, a `text/html;profile=mcp-app` -resource from `createGlobePanelResource({ appUrl })` in -`src/tools/globePanel.js`. Clients that display apps render it inside the -conversation. The panel completes the MCP Apps handshake (`ui/initialize`, -`ui/notifications/initialized`, `ui/notifications/size-changed`), and for -each `ui/notifications/tool-result` carrying a view it loads the app in embed -mode the first time and posts later views to that same app, so the globe -changes without reloading. Its resource allows framing only the app's origin -(`csp.frameDomains`), and its "Open in God's Eye View" button asks the host -to open the link (`ui/open-link`). Tools declare a UI resource with -`defineTool({ ui: { resourceUri } })`; `createMcpServer({ resources })` -serves `resources/list` and `resources/read`. +resource from `createGlobePanelResource({ runtime })` in +`src/tools/globePanel.js`, with the panel's script from +`src/app/globePanelRuntime.js`. Clients that display apps render it inside +the conversation. The panel completes the MCP Apps handshake +(`ui/initialize`, `ui/notifications/initialized`, +`ui/notifications/size-changed`), and for each +`ui/notifications/tool-result` carrying a view it loads the app in inline +embed mode the first time and posts later views to that same app, so the +globe changes without reloading. Its "Open in God's Eye View" button asks +the host to open the link (`ui/open-link`). + +Hosts serve panels from their own sites and may refuse other addresses; +Codex, for one, refuses any address on the user's machine. So the panel +never requests the app's server itself. It loads everything from the app's +own paths through `panel_request`, a tool only the panel may call +(`_meta.ui.visibility: ["app"]`): the MCP server requests the path from the +app's server and returns the response, compressed and in parts when large. +The same path works for a local server, a hosted one, and every host. Only +map imagery, tiles and fonts load directly, from the providers the +resource's `csp` lists. `panel_request` refuses `/api/setup`, which writes +provider keys. + +The panel loads the app's panel build, which `npm run build:panel` writes to +`dist/panel` and the servers serve at `/panel/`: one app script, one +stylesheet, Cesium's script, and Cesium's workers combined into one script +that Cesium runs from memory (`CESIUM_WORKERS`). Rebuild it after changing +the app. + +Tools declare a UI resource with `defineTool({ ui: { resourceUri } })`, and +an app-only tool with `ui: { visibility: ['app'] }`; +`createMcpServer({ resources })` serves `resources/list` and +`resources/read`. ### Embed mode @@ -251,3 +272,4 @@ public feeds the sources already use. | `military_awareness` | `military` | Military and other aircraft, ships and military installations within 250 km of a point, by section | | `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area | | `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks and something to follow | +| `panel_request` | `app` | Panel only: loads a path from the app's server for the God's Eye View panel | diff --git a/package.json b/package.json index 3b5e18094..dda83c1ec 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "dev:secure": "./scripts/dev-secure.sh", "opensky:import": "./scripts/opensky-import-client.sh", "build": "vite build", + "build:panel": "node scripts/build-panel.mjs", "preview": "vite preview", "test": "node scripts/run-unit-tests.mjs", "test:track": "node scripts/track-regression.mjs", diff --git a/scripts/build-panel.mjs b/scripts/build-panel.mjs new file mode 100644 index 000000000..ad4cc8bd2 --- /dev/null +++ b/scripts/build-panel.mjs @@ -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}`); diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index fa5685bd4..a880f5596 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -31,7 +31,8 @@ "modules": [ "build/vite.js", "build/application-html.js", - "build/embed-framing.js" + "build/embed-framing.js", + "build/panel.js" ], "external": ["vite-plugin-cesium"] }, @@ -3140,7 +3141,8 @@ "src/data/installationFeedback.js", "src/view/index.js", "src/tools/views.js", - "src/tools/globePanel.js" + "src/tools/globePanel.js", + "src/tools/queries/panelRequest.js" ], "external": ["satellite.js"] }, diff --git a/server/mcp/server.js b/server/mcp/server.js index aa11e8ef1..a2acdba27 100644 --- a/server/mcp/server.js +++ b/server/mcp/server.js @@ -6,6 +6,7 @@ import { coreTools, catalogForSurface, } from '../../src/tools/index.js'; +import { panelRuntime } from '../../src/app/globePanelRuntime.js'; import { createGlobePanelResource } from '../../src/tools/globePanel.js'; import { createMcpServer } from '../../src/tools/mcp/index.js'; import { DEFAULT_API_BASE, createLocalToolServices } from './services.js'; @@ -38,6 +39,6 @@ export function createLocalMcpServer({ name: 'gods-eye-view', version, instructions: INSTRUCTIONS, - resources: [createGlobePanelResource({ appUrl: apiBase })], + resources: [createGlobePanelResource({ runtime: panelRuntime })], }); } diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js new file mode 100644 index 000000000..5b150ab3d --- /dev/null +++ b/src/app/globePanelRuntime.js @@ -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}` : '.'), + ), + ); +} diff --git a/src/tooling/panelBuild.test.mjs b/src/tooling/panelBuild.test.mjs new file mode 100644 index 000000000..a7a2ab15b --- /dev/null +++ b/src/tooling/panelBuild.test.mjs @@ -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'), '

panel

'); + 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), '

panel

'); + 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 }); + } +}); diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 6550c5235..a7c79e53f 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -12,8 +12,7 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop googleApiKey: 'browser-fixture', cesiumToken: 'ion-fixture', }); - assert.equal(config.plugins[0].name, 'panel-cors'); - assert.equal(config.plugins[3], plugin); + assert.equal(config.plugins[2], plugin); assert.equal(config.server.host, 'localhost'); assert.equal(config.server.port, 4173); assert.deepEqual(config.server.allowedHosts, [ @@ -43,45 +42,6 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop ); }); -test('panel origins may read every dev server path, other sites may not', () => { - let middleware; - createBrowserViteConfig().plugins[0].configureServer({ - middlewares: { use: (fn) => (middleware = fn) }, - }); - const request = (origin, method = 'GET') => { - const headers = {}; - let ended = false; - let passed = false; - middleware( - { method, headers: origin ? { origin } : {} }, - { - setHeader: (name, value) => (headers[name] = value), - end: () => (ended = true), - }, - () => (passed = true), - ); - return { headers, ended, passed }; - }; - const panel = request('https://abc123.claudemcpcontent.com'); - assert.equal( - panel.headers['Access-Control-Allow-Origin'], - 'https://abc123.claudemcpcontent.com', - ); - assert.equal(panel.passed, true); - assert.equal( - request('https://abc123.claudemcpcontent.com', 'OPTIONS').ended, - true, - ); - assert.equal( - request('codex-sandbox://mcp-app-ab12.web-sandbox.oaiusercontent.com') - .headers['Access-Control-Allow-Origin'], - 'codex-sandbox://mcp-app-ab12.web-sandbox.oaiusercontent.com', - ); - const other = request('https://example.com'); - assert.deepEqual(other.headers, {}); - assert.equal(other.passed, true); -}); - test('build helper does not discover environment values or construct local providers', () => { const before = process.env.GOOGLE_MAPS_API_KEY; process.env.GOOGLE_MAPS_API_KEY = 'environment-fixture'; @@ -92,8 +52,8 @@ test('build helper does not discover environment values or construct local provi undefined, ); assert.deepEqual( - config.plugins.slice(3).map((plugin) => plugin.name), - ['embed-framing'], + config.plugins.slice(2).map((plugin) => plugin.name), + ['embed-framing', 'panel-build'], ); } finally { if (before === undefined) delete process.env.GOOGLE_MAPS_API_KEY; @@ -106,14 +66,15 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }); assert.deepEqual( - config.plugins.slice(3, -3).map((plugin) => plugin.name), + config.plugins.slice(2, -4).map((plugin) => plugin.name), providers.localProviderPlugins().map((plugin) => plugin.name), ); - assert.equal(config.plugins.at(-4).name, 'gev-key-setup'); + assert.equal(config.plugins.at(-5).name, 'gev-key-setup'); // The local MCP route follows every provider and precedes the API fallback. - assert.equal(config.plugins.at(-3).name, 'local-mcp'); - assert.equal(config.plugins.at(-2).name, 'api-not-found'); - assert.equal(config.plugins.at(-1).name, 'embed-framing'); + assert.equal(config.plugins.at(-4).name, 'local-mcp'); + assert.equal(config.plugins.at(-3).name, 'api-not-found'); + assert.equal(config.plugins.at(-2).name, 'embed-framing'); + assert.equal(config.plugins.at(-1).name, 'panel-build'); }); test('build export resolves in Node and has no browser fallback', async () => { diff --git a/src/tools/catalog.js b/src/tools/catalog.js index 59cd6f0c9..4d5106b6e 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -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, }); } diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index 7f0a29138..a9f2cd9a3 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -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 ` @@ -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) {
Waiting for a view…
- + `; } /** - * 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], - }, }, }); } diff --git a/src/tools/globePanel.test.mjs b/src/tools/globePanel.test.mjs index 350316d38..bc7504081 100644 --- a/src/tools/globePanel.test.mjs +++ b/src/tools/globePanel.test.mjs @@ -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 ; 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, /([\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 () => { diff --git a/src/tools/index.js b/src/tools/index.js index dc6d19b67..0d0802a46 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -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, ]); diff --git a/src/tools/mcp/mcp.test.mjs b/src/tools/mcp/mcp.test.mjs index 0df9bb955..23f251f75 100644 --- a/src/tools/mcp/mcp.test.mjs +++ b/src/tools/mcp/mcp.test.mjs @@ -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' }); +}); diff --git a/src/tools/mcp/protocol.js b/src/tools/mcp/protocol.js index 446d01869..ad5c39bf8 100644 --- a/src/tools/mcp/protocol.js +++ b/src/tools/mcp/protocol.js @@ -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, diff --git a/src/tools/queries/panelRequest.js b/src/tools/queries/panelRequest.js new file mode 100644 index 000000000..3d5b2b94e --- /dev/null +++ b/src/tools/queries/panelRequest.js @@ -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), + }; + }, +}); diff --git a/src/tools/queries/panelRequest.test.mjs b/src/tools/queries/panelRequest.test.mjs new file mode 100644 index 000000000..b95613f8e --- /dev/null +++ b/src/tools/queries/panelRequest.test.mjs @@ -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); +}); diff --git a/src/tools/services.js b/src/tools/services.js index 422207629..43517fb9e 100644 --- a/src/tools/services.js +++ b/src/tools/services.js @@ -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 }), diff --git a/src/tools/surfaces.js b/src/tools/surfaces.js index 4d95ac540..7c8de61bc 100644 --- a/src/tools/surfaces.js +++ b/src/tools/surfaces.js @@ -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.