feat: show views as the live globe in MCP Apps clients

show_on_globe replaces open_in_gods_eye_view and names an MCP Apps view, a
ui:// panel that loads the app in embed mode and posts each later view to it,
so the globe in the conversation changes without reloading. It also takes a
view another answer returned. Tools can declare a UI resource, and the MCP
adapter serves resources/list and resources/read and lists each tool's
resource in _meta.ui.resourceUri.
This commit is contained in:
Sameh Khamis committed 2026-10-02 15:21:36 -07:00
1 parent 60c40865e9
commit 216a75ac71
17 files changed
+417 -41

No files matched your search

+1 -1
View File
@@ -220,7 +220,7 @@ test('Realtime sessions carry supplied tools, and the standalone voice adds the
);
assert.ok(names.includes('get_weather'));
assert.ok(names.includes('military_awareness'));
assert.ok(!names.includes('open_in_gods_eye_view'));
assert.ok(!names.includes('show_on_globe'));
assert.ok(!names.includes('aircraft_in_area'));
assert.ok(!names.includes('get_weather_map'));
// The action of the same name answers satellite passes.
+7
View File
@@ -74,6 +74,7 @@ export function defineTool({
inputSchema,
annotations = {},
requires = [],
ui = null,
run,
}) {
if (!TOOL_NAME.test(String(name)))
@@ -92,6 +93,11 @@ 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`);
return Object.freeze({
name,
kind,
@@ -103,6 +109,7 @@ export function defineTool({
...annotations,
}),
requires: Object.freeze([...requires]),
ui: ui ? Object.freeze({ resourceUri: ui.resourceUri }) : null,
run,
});
}
+2 -2
View File
@@ -4,10 +4,10 @@ import { coreTools, toFunctionOutput, toFunctionTools } from './index.js';
test('catalog tools become independent function records', () => {
const records = toFunctionTools(coreTools, {
exclude: ['open_in_gods_eye_view'],
exclude: ['show_on_globe'],
});
assert.equal(records.length, coreTools.length - 1);
assert.ok(!records.some((tool) => tool.name === 'open_in_gods_eye_view'));
assert.ok(!records.some((tool) => tool.name === 'show_on_globe'));
const weather = records.find((tool) => tool.name === 'get_weather');
const source = coreTools.find((tool) => tool.name === 'get_weather');
assert.deepEqual(Object.keys(weather), [
+158
View File
@@ -0,0 +1,158 @@
/**
* The globe panel: an MCP Apps view (`io.modelcontextprotocol/ui`) that shows
* God's Eye View inside a conversation. The panel is a small page that loads
* the app in embed mode and sends it each view the show_on_globe tool
* returns; see docs/TOOLS.md.
*/
export const GLOBE_PANEL_URI = 'ui://gods-eye-view/globe';
export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
const MCP_APPS_PROTOCOL_VERSION = '2026-01-26';
const PANEL_HEIGHT_PX = 520;
/**
* The panel page. It completes the MCP Apps handshake with its host, then
* for each tool result that carries a view: loads the app at that view in
* embed mode the first time, and afterwards posts the view to the loaded app
* so the globe changes without reloading.
*/
function panelHtml(appOrigin) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>God's Eye View</title>
<style>
html, body { margin: 0; height: ${PANEL_HEIGHT_PX}px; background: #05070a; color: #b8c4cc;
font: 13px/1.4 system-ui, sans-serif; overflow: hidden; }
#globe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
#status { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
#open { position: absolute; right: 10px; top: 10px; z-index: 1; 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; }
</style>
</head>
<body>
<div id="status">Waiting for a view of the globe…</div>
<button id="open" type="button" hidden>Open in God's Eye View</button>
<script>
(() => {
const APP_ORIGIN = ${JSON.stringify(appOrigin)};
const status = document.getElementById('status');
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 frame = null;
let frameReady = false;
let queued = null;
let currentUrl = null;
const embedUrl = (url) => {
const target = new URL(url);
target.searchParams.set('embed', '1');
return target.href;
};
const postView = (view) =>
frame.contentWindow.postMessage(
{ type: 'gev:view', id: nextId++, view },
APP_ORIGIN,
);
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 (!frame) {
frame = document.createElement('iframe');
frame.id = 'globe';
frame.title = "God's Eye View";
frame.allow = 'fullscreen';
frame.src = embedUrl(url);
document.body.appendChild(frame);
status.textContent = 'Loading the globe…';
return;
}
if (frameReady) postView(view);
else queued = view;
}
window.addEventListener('message', (event) => {
const message = event.data;
if (frame && event.source === frame.contentWindow) {
if (message && message.type === 'gev:ready') {
frameReady = 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'),
);
});
request('ui/initialize', {
protocolVersion: ${JSON.stringify(MCP_APPS_PROTOCOL_VERSION)},
clientInfo: { name: 'gods-eye-view-globe', version: '1' },
capabilities: {},
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
}).then(() => {
notify('ui/notifications/initialized', {});
notify('ui/notifications/size-changed', {
width: document.body.clientWidth,
height: ${PANEL_HEIGHT_PX},
});
});
})();
</script>
</body>
</html>
`;
}
/**
* The panel as an MCP resource for an app served at `appUrl`. Only that
* origin may be framed inside the panel.
*/
export function createGlobePanelResource({ appUrl }) {
const appOrigin = new URL(appUrl).origin;
return Object.freeze({
uri: GLOBE_PANEL_URI,
name: 'globe',
title: "God's Eye View globe",
description: 'The live globe, showing the view a tool returns.',
mimeType: MCP_APP_MIME_TYPE,
text: panelHtml(appOrigin),
_meta: {
ui: { csp: { frameDomains: [appOrigin] }, prefersBorder: true },
},
});
}
+61
View File
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
GLOBE_PANEL_URI,
MCP_APP_MIME_TYPE,
createGlobePanelResource,
} from './globePanel.js';
import { composeCatalog, coreTools } from './index.js';
test('the globe panel is an MCP Apps resource that may frame only the app', () => {
const resource = createGlobePanelResource({
appUrl: 'http://localhost:5173/some/path',
});
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');
assert.deepEqual(resource._meta.ui.csp, {
frameDomains: ['http://localhost:5173'],
});
assert.match(resource.text, /const APP_ORIGIN = "http:\/\/localhost:5173";/);
assert.match(resource.text, /'ui\/initialize'/);
assert.match(resource.text, /'ui\/notifications\/tool-result'/);
assert.match(resource.text, /protocolVersion: "2026-01-26"/);
});
test('show_on_globe names the panel and shows a view another answer returned', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:5173/' } },
});
assert.deepEqual(catalog.get('show_on_globe').ui, {
resourceUri: GLOBE_PANEL_URI,
});
const earlier = {
camera: {
lat: 25,
lon: 121,
altitude_m: 300000,
heading_deg: 0,
pitch_deg: -90,
},
layers: ['ais-live-vessels'],
style: null,
map: null,
follow: null,
annotations: [],
url: 'http://localhost:5173/#v=2',
};
const shown = await catalog.call('show_on_globe', {
view: earlier,
layers: ['ais-live-vessels', 'military'],
style: 'thermal',
});
assert.deepEqual(shown.data.view.camera, earlier.camera);
assert.deepEqual(shown.data.view.layers, ['ais-live-vessels', 'military']);
assert.equal(shown.data.view.style, 'thermal');
await assert.rejects(
catalog.call('show_on_globe', { view: { camera: { lat: 'x' } } }),
(error) => error.code === 'invalid_arguments',
);
});
+2 -2
View File
@@ -40,7 +40,7 @@ import {
getTransitVehicles,
} from './queries/mobility.js';
import { placesNearby, planRoute, searchPlaces } from './queries/places.js';
import { openInGodsEyeView } from './queries/share.js';
import { showOnGlobe } from './queries/share.js';
import { findAlprCameras } from './queries/surveillance.js';
import {
findCctvCameras,
@@ -121,5 +121,5 @@ export const coreTools = Object.freeze([
situationBrief,
militaryAwareness,
getHudCaption,
openInGodsEyeView,
showOnGlobe,
]);
+76
View File
@@ -200,3 +200,79 @@ test('the HTTP transport accepts one JSON message per POST', async () => {
assert.equal(get.status, 405);
assert.equal(get.headers.get('allow'), 'POST');
});
test('resources are listed and read, and tools name their UI resource', async () => {
const shown = defineTool({
name: 'show',
title: 'Show',
description: 'Shows something in a view.',
inputSchema: { type: 'object', properties: {} },
ui: { resourceUri: 'ui://fixture/view' },
run: async () => ({ summary: 'shown', data: {} }),
});
const withUi = createMcpServer({
catalog: composeCatalog({ tools: [shown] }),
name: 'fixture',
version: '1',
resources: [
{
uri: 'ui://fixture/view',
name: 'view',
mimeType: 'text/html;profile=mcp-app',
text: '<!doctype html><p>view</p>',
_meta: { ui: { csp: { frameDomains: ['https://a.example'] } } },
},
],
});
const call = async (method, params) =>
(await withUi.handle({ jsonrpc: '2.0', id: 1, method, params })).result ??
(await withUi.handle({ jsonrpc: '2.0', id: 1, method, params })).error;
assert.deepEqual((await call('initialize', {})).capabilities, {
tools: { listChanged: false },
resources: { listChanged: false },
});
assert.deepEqual((await call('tools/list')).tools[0]._meta, {
ui: { resourceUri: 'ui://fixture/view' },
});
assert.deepEqual((await call('resources/list')).resources, [
{
uri: 'ui://fixture/view',
name: 'view',
mimeType: 'text/html;profile=mcp-app',
_meta: { ui: { csp: { frameDomains: ['https://a.example'] } } },
},
]);
assert.deepEqual(
(await call('resources/read', { uri: 'ui://fixture/view' })).contents,
[
{
uri: 'ui://fixture/view',
mimeType: 'text/html;profile=mcp-app',
text: '<!doctype html><p>view</p>',
_meta: { ui: { csp: { frameDomains: ['https://a.example'] } } },
},
],
);
assert.equal(
(await call('resources/read', { uri: 'ui://fixture/other' })).code,
-32002,
);
// A server without resources does not advertise them.
assert.equal(
(await server.handle({ jsonrpc: '2.0', id: 1, method: 'initialize' }))
.result.capabilities.resources,
undefined,
);
assert.throws(
() =>
defineTool({
name: 'bad',
title: 'Bad',
description: 'Points at a web page.',
inputSchema: { type: 'object' },
ui: { resourceUri: 'https://a.example' },
run: async () => ({ summary: '', data: {} }),
}),
/ui:\/\/ resourceUri/,
);
});
+27 -2
View File
@@ -1,6 +1,8 @@
/**
* Model Context Protocol server for a tool catalog: JSON-RPC 2.0 handling of
* initialize, ping, tools/list and tools/call. Transports (HTTP, stdio) pass
* initialize, ping, tools/list and tools/call, and resources/list and
* resources/read for the resources an application supplies, such as MCP Apps
* views (`ui://` resources) that tools name in `_meta.ui.resourceUri`. Transports (HTTP, stdio) pass
* parsed messages in and send the returned responses out.
*/
@@ -18,6 +20,7 @@ const INVALID_REQUEST = -32600;
const METHOD_NOT_FOUND = -32601;
const INVALID_PARAMS = -32602;
const INTERNAL_ERROR = -32603;
const RESOURCE_NOT_FOUND = -32002;
/**
* Describe catalog tools as MCP tool definitions.
@@ -36,6 +39,9 @@ export function toMcpTools(catalog, { descriptions = {}, decorate } = {}) {
description: override.description ?? tool.description,
inputSchema: tool.inputSchema,
annotations: { title, ...tool.annotations },
...(tool.ui
? { _meta: { ui: { resourceUri: tool.ui.resourceUri } } }
: {}),
};
return decorate
? { ...definition, ...decorate(definition, tool) }
@@ -54,8 +60,12 @@ export function createMcpServer({
instructions,
descriptions,
decorate,
resources = [],
}) {
const tools = () => toMcpTools(catalog, { descriptions, decorate });
const resourceByUri = new Map(
resources.map((resource) => [resource.uri, resource]),
);
const methods = {
initialize(params) {
const requested = params?.protocolVersion;
@@ -63,13 +73,28 @@ export function createMcpServer({
protocolVersion: MCP_PROTOCOL_VERSIONS.includes(requested)
? requested
: MCP_PROTOCOL_VERSIONS[0],
capabilities: { tools: { listChanged: false } },
capabilities: {
tools: { listChanged: false },
...(resources.length ? { resources: { listChanged: false } } : {}),
},
serverInfo: { name, version },
...(instructions ? { instructions } : {}),
};
},
ping: () => ({}),
'tools/list': () => ({ tools: tools() }),
'resources/list': () => ({
resources: resources.map(({ text, ...entry }) => entry),
}),
'resources/read'(params) {
const resource = resourceByUri.get(params?.uri);
if (!resource)
throw rpcError(RESOURCE_NOT_FOUND, `Unknown resource: ${params?.uri}`);
const { uri, mimeType, text, _meta } = resource;
return {
contents: [{ uri, mimeType, text, ...(_meta ? { _meta } : {}) }],
};
},
async 'tools/call'(params, { signal }) {
if (typeof params?.name !== 'string')
throw rpcError(INVALID_PARAMS, 'tools/call needs a tool name');
+33 -14
View File
@@ -2,11 +2,18 @@
import { VIEW_PROPERTIES, createView, viewUrl } from '../../view/index.js';
import { defineTool, ToolError } from '../catalog.js';
import { GLOBE_PANEL_URI } from '../globePanel.js';
import { AREA_SCHEMA, resolveArea } from '../area.js';
import { cameraForArea, cameraLookingAt } from '../views.js';
/** Tool arguments that describe a view: an area to frame, or a camera. */
export const VIEW_ARGUMENTS = Object.freeze({
view: Object.freeze({
type: 'object',
description:
'A view another answer returned (its data.view), shown as it is; ' +
'other arguments given with it change that part of it.',
}),
area: AREA_SCHEMA,
...VIEW_PROPERTIES,
});
@@ -16,7 +23,10 @@ export const VIEW_ARGUMENTS = Object.freeze({
* down over it; camera fields given alongside it override the framing.
*/
export async function resolveViewArguments(args, { services, signal }) {
let camera = { ...args.camera };
const base = args.view && typeof args.view === 'object' ? args.view : {};
let camera = args.area
? { ...args.camera }
: { ...base.camera, ...args.camera };
let label = null;
if (args.area) {
const area = await resolveArea(args.area, { services, signal });
@@ -36,16 +46,22 @@ export async function resolveViewArguments(args, { services, signal }) {
if (!Number.isFinite(camera.lat) || !Number.isFinite(camera.lon))
throw new ToolError(
'invalid_arguments',
'Give an area, or a camera with lat and lon',
'Give a view, an area, or a camera with lat and lon',
);
const view = createView({
camera,
layers: args.layers,
style: args.style ?? null,
map: args.map ?? null,
follow: args.follow ?? null,
annotations: args.annotations ?? [],
});
let view;
try {
view = createView({
camera,
layers: args.layers ?? base.layers,
style: args.style ?? base.style ?? null,
map: args.map ?? base.map ?? null,
follow: args.follow ?? base.follow ?? null,
annotations: args.annotations ?? base.annotations ?? [],
});
} catch (error) {
// A view passed through can hold anything; report it as bad arguments.
throw new ToolError('invalid_arguments', error.message);
}
return {
view,
label:
@@ -61,11 +77,13 @@ function appBase(services) {
}
}
export const openInGodsEyeView = defineTool({
name: 'open_in_gods_eye_view',
title: "Open in God's Eye View",
export const showOnGlobe = defineTool({
name: 'show_on_globe',
title: 'Show on the globe',
description:
"A link that opens God's Eye View at a view: an area framed from above " +
"Shows a view of God's Eye View's globe: in clients that display apps, " +
'the live globe in the conversation; everywhere, a link that opens it. ' +
'Pass the view another answer returned, or describe one: an area framed from above ' +
'or a camera position, with chosen data layers, visual style and map, ' +
'optionally following an aircraft or satellite and with marks drawn on ' +
'the globe.',
@@ -75,6 +93,7 @@ export const openInGodsEyeView = defineTool({
additionalProperties: false,
},
requires: ['app'],
ui: { resourceUri: GLOBE_PANEL_URI },
async run(args, { services, signal }) {
const base = appBase(services);
const { view, label } = await resolveViewArguments(args, {
+10 -10
View File
@@ -7,7 +7,7 @@ test('links open the app looking straight down on the area', async () => {
tools: coreTools,
services: { app: { baseUrl: 'https://maps.example/view?x=1' } },
});
const result = await catalog.call('open_in_gods_eye_view', {
const result = await catalog.call('show_on_globe', {
area: { lat: 30.2672, lon: -97.7431, radius_km: 5 },
});
const url = new URL(result.data.url);
@@ -35,11 +35,11 @@ test('altitude is bounded for tiny and planet-sized areas', async () => {
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:4173/' } },
});
const tiny = await catalog.call('open_in_gods_eye_view', {
const tiny = await catalog.call('show_on_globe', {
area: { lat: 0, lon: 0, radius_km: 0.1 },
});
assert.equal(tiny.data.view.camera.altitude_m, 500);
const world = await catalog.call('open_in_gods_eye_view', {
const world = await catalog.call('show_on_globe', {
area: { bbox: [-180, -85, 180, 85] },
});
assert.equal(world.data.view.camera.altitude_m, 15000000);
@@ -48,7 +48,7 @@ test('altitude is bounded for tiny and planet-sized areas', async () => {
services: { app: { baseUrl: 'not a url' } },
});
await assert.rejects(
broken.call('open_in_gods_eye_view', {
broken.call('show_on_globe', {
area: { lat: 0, lon: 0, radius_km: 1 },
}),
(error) => error.code === 'unavailable',
@@ -61,7 +61,7 @@ test('links turn on the requested layers with the app share-link codec', async (
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:4173/' } },
});
const result = await catalog.call('open_in_gods_eye_view', {
const result = await catalog.call('show_on_globe', {
area: { lat: 30.27, lon: -97.74, radius_km: 10 },
layers: ['earthquakes', 'flights', 'flights'],
});
@@ -72,7 +72,7 @@ test('links turn on the requested layers with the app share-link codec', async (
'flights',
]);
await assert.rejects(
catalog.call('open_in_gods_eye_view', {
catalog.call('show_on_globe', {
area: { lat: 0, lon: 0, radius_km: 1 },
layers: ['not-a-layer'],
}),
@@ -86,7 +86,7 @@ test('links carry a camera, style, map and an entity to follow', async () => {
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:4173/' } },
});
const result = await catalog.call('open_in_gods_eye_view', {
const result = await catalog.call('show_on_globe', {
area: { lat: 25, lon: 121, radius_km: 200 },
camera: { pitch_deg: -40, heading_deg: 350 },
layers: ['ais-live-vessels'],
@@ -103,13 +103,13 @@ test('links carry a camera, style, map and an entity to follow', async () => {
id: 'ae1234',
});
assert.equal(result.data.view.camera.pitch_deg, -40);
const camera = await catalog.call('open_in_gods_eye_view', {
const camera = await catalog.call('show_on_globe', {
camera: { lat: 48.8584, lon: 2.2945, altitude_m: 1200, pitch_deg: -30 },
});
assert.equal(camera.data.view.camera.altitude_m, 1200);
assert.match(camera.summary, /^Open 48\.858, 2\.295 in God's Eye View: /);
await assert.rejects(
catalog.call('open_in_gods_eye_view', { layers: ['flights'] }),
/Give an area, or a camera with lat and lon/,
catalog.call('show_on_globe', { layers: ['flights'] }),
/Give a view, an area, or a camera with lat and lon/,
);
});
+1 -1
View File
@@ -15,7 +15,7 @@ const mcpOff = Object.freeze({ mcp: false });
export const TOOL_SURFACES = Object.freeze({
// Voice runs inside the app, so a link to it adds nothing.
open_in_gods_eye_view: voiceOff,
show_on_globe: 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.
+1 -5
View File
@@ -20,11 +20,7 @@ test('each surface offers the tools the table does not turn off', () => {
assert.deepEqual(offered, expected, surface);
}
const mcp = names(toolsForSurface(coreTools, 'mcp'));
for (const name of [
'open_in_gods_eye_view',
'situation_brief',
'aircraft_in_area',
])
for (const name of ['show_on_globe', 'situation_brief', 'aircraft_in_area'])
assert.ok(mcp.includes(name), name);
for (const name of [
'search_places',