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

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

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

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
/**
* The God's Eye View panel's requests. A panel cannot reach the app's server
* itself: hosts serve panels from their own sites and may refuse other
* addresses, such as a server on the user's machine. The panel asks this
* tool instead, which only an app may call, and it requests the path from
* the app's server. Large responses come back in parts.
*/
import { defineTool, ToolError } from '../catalog.js';
/** Bytes of response body per call, before base64. */
export const PANEL_PART_BYTES = 512 * 1024;
const HELD_MS = 2 * 60 * 1000;
const HELD_LIMIT_BYTES = 256 * 1024 * 1024;
const METHODS = new Set(['GET', 'HEAD', 'POST']);
// Provider Settings write the app's keys; only the app's own page may.
const REFUSED_PATHS = [/^\/api\/setup(?:\/|$)/];
const FORWARDED_REQUEST_HEADERS = ['accept', 'content-type'];
const DROPPED_RESPONSE_HEADERS = new Set([
'connection',
'content-encoding',
'content-length',
'keep-alive',
'set-cookie',
'transfer-encoding',
]);
// Already compressed, so gzip would only cost time.
const INCOMPRESSIBLE =
/^(?:image\/(?!svg)|video\/|audio\/|font\/woff2)|zip|compressed/;
const COMPRESS_MIN_BYTES = 1024;
// Bodies too large for one call, kept for the calls that read the rest.
const held = new Map();
let heldBytes = 0;
function base64(bytes) {
let binary = '';
for (let start = 0; start < bytes.length; start += 0x8000)
binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));
return btoa(binary);
}
function fromBase64(text) {
return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
}
async function gzip(bytes) {
const stream = new Blob([bytes])
.stream()
.pipeThrough(new CompressionStream('gzip'));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
function forget(now = Date.now()) {
for (const [id, entry] of held) {
if (entry.expires > now && heldBytes <= HELD_LIMIT_BYTES) continue;
held.delete(id);
heldBytes -= entry.bytes.length;
}
}
function part(response, bytes, offset) {
const end = Math.min(bytes.length, offset + PANEL_PART_BYTES);
return {
...response,
offset,
body: base64(bytes.subarray(offset, end)),
...(end < bytes.length ? { nextOffset: end } : {}),
};
}
function checkedPath(path) {
if (
typeof path !== 'string' ||
!path.startsWith('/') ||
path.startsWith('//')
)
throw new ToolError('invalid_arguments', 'path must start with one /');
const { pathname } = new URL(path, 'http://app.invalid');
if (REFUSED_PATHS.some((pattern) => pattern.test(pathname)))
throw new ToolError('invalid_arguments', `${pathname} is not available`);
return path;
}
export const panelRequest = defineTool({
name: 'panel_request',
title: "God's Eye View panel request",
description:
"Loads a file or data for the God's Eye View panel from the app's " +
'server. Only the panel calls this; it does not answer questions.',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: "A path on the app's server, starting with /.",
},
method: { type: 'string', enum: [...METHODS] },
headers: {
type: 'object',
description: 'Request headers; only Accept and Content-Type are sent.',
},
body: { type: 'string', description: 'The request body, base64.' },
id: {
type: 'string',
description: 'Continue a response an earlier call returned in parts.',
},
offset: { type: 'integer', minimum: 0 },
},
additionalProperties: false,
},
requires: ['app'],
ui: { visibility: ['app'] },
async run(args, { services, signal }) {
forget();
if (args.id !== undefined) {
const entry = held.get(args.id);
if (!entry)
throw new ToolError(
'invalid_arguments',
'That response is no longer held; request the path again',
);
return {
summary: `Part of ${entry.path}`,
data: part(entry.response, entry.bytes, args.offset ?? 0),
};
}
const path = checkedPath(args.path);
const method = args.method ?? 'GET';
if (!METHODS.has(method))
throw new ToolError('invalid_arguments', `Unsupported method ${method}`);
const headers = {};
for (const [name, value] of Object.entries(args.headers ?? {})) {
if (FORWARDED_REQUEST_HEADERS.includes(name.toLowerCase()))
headers[name] = String(value);
}
let answer;
try {
answer = await services.app.fetch(path, {
method,
headers,
...(args.body !== undefined && method === 'POST'
? { body: fromBase64(args.body) }
: {}),
signal,
});
} catch (error) {
if (signal?.aborted) throw error;
throw new ToolError('unavailable', "The app's server did not answer");
}
let bytes = new Uint8Array(await answer.arrayBuffer());
const type = answer.headers.get('content-type') || '';
let encoding = 'identity';
if (bytes.length >= COMPRESS_MIN_BYTES && !INCOMPRESSIBLE.test(type)) {
bytes = await gzip(bytes);
encoding = 'gzip';
}
const responseHeaders = {};
answer.headers.forEach((value, name) => {
if (!DROPPED_RESPONSE_HEADERS.has(name)) responseHeaders[name] = value;
});
const response = {
status: answer.status,
statusText: answer.statusText,
headers: responseHeaders,
encoding,
totalBytes: bytes.length,
};
if (bytes.length > PANEL_PART_BYTES) {
response.id = crypto.randomUUID();
held.set(response.id, {
path,
response,
bytes,
expires: Date.now() + HELD_MS,
});
heldBytes += bytes.length;
}
return {
summary: `${answer.status} ${method} ${path}`,
data: part(response, bytes, 0),
};
},
});
+109
View File
@@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
import { PANEL_PART_BYTES } from './panelRequest.js';
const fromBase64 = (text) => Buffer.from(text, 'base64');
const gunzip = async (bytes) =>
new Uint8Array(
await new Response(
new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')),
).arrayBuffer(),
);
function catalogWith(handler) {
const requests = [];
const fetchImpl = async (path, init) => {
requests.push({ path, ...init });
return handler(path, init);
};
return {
requests,
catalog: composeCatalog({
tools: coreTools,
services: {
app: { baseUrl: 'http://localhost:4173/', fetch: fetchImpl },
},
}),
};
}
test('panel_request is for the panel only', () => {
const { catalog } = catalogWith(() => new Response('x'));
assert.deepEqual(catalog.get('panel_request').ui, { visibility: ['app'] });
});
test('a small response returns whole, compressed when that helps', async () => {
const text = 'body { color: red; }\n'.repeat(200);
const { catalog, requests } = catalogWith(
() =>
new Response(text, {
status: 200,
headers: { 'content-type': 'text/css', 'set-cookie': 'a=b' },
}),
);
const { data } = await catalog.call('panel_request', {
path: '/panel/assets/style.css',
headers: { Accept: 'text/css', Cookie: 'secret', 'X-Other': '1' },
});
assert.deepEqual(requests[0].headers, { Accept: 'text/css' });
assert.equal(data.status, 200);
assert.equal(data.encoding, 'gzip');
assert.equal(data.nextOffset, undefined);
assert.equal(data.headers['content-type'], 'text/css');
assert.equal(data.headers['set-cookie'], undefined);
const body = await gunzip(fromBase64(data.body));
assert.equal(new TextDecoder().decode(body), text);
});
test('a large response comes in parts that join to the original', async () => {
const bytes = new Uint8Array(PANEL_PART_BYTES * 2 + 10);
for (let index = 0; index < bytes.length; index++)
bytes[index] = (index * 7919) % 251;
const { catalog } = catalogWith(
() => new Response(bytes, { headers: { 'content-type': 'image/png' } }),
);
let { data } = await catalog.call('panel_request', { path: '/a.png' });
assert.equal(data.encoding, 'identity');
assert.equal(data.totalBytes, bytes.length);
const parts = [fromBase64(data.body)];
while (data.nextOffset !== undefined) {
({ data } = await catalog.call('panel_request', {
id: data.id,
offset: data.nextOffset,
}));
parts.push(fromBase64(data.body));
}
assert.equal(parts.length, 3);
assert.deepEqual(new Uint8Array(Buffer.concat(parts)), bytes);
});
test('a request body and method pass through; settings and other sites do not', async () => {
const { catalog, requests } = catalogWith(
() => new Response('{}', { status: 201 }),
);
const { data } = await catalog.call('panel_request', {
path: '/api/openai/hud-summary',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: Buffer.from('{"a":1}').toString('base64'),
});
assert.equal(data.status, 201);
assert.equal(new TextDecoder().decode(requests[0].body), '{"a":1}');
for (const path of [
'/api/setup/status',
'/api/setup',
'//evil.example/x',
'http://evil.example/',
])
await assert.rejects(
catalog.call('panel_request', { path }),
(error) => error.code === 'invalid_arguments',
path,
);
await assert.rejects(
catalog.call('panel_request', { id: 'unknown' }),
(error) => error.code === 'invalid_arguments',
);
assert.equal(requests.length, 1);
});