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

+10
View File
@@ -1,5 +1,15 @@
# God's Eye View Current State
## The globe in conversations — October 2, 2026
Tool answers that can be seen on the globe include a view: camera, layers,
style, map, marks and an aircraft or satellite to follow, written in the
share-link format (`gods-eye-view/view`). `show_on_globe` shows a view as the
live globe inside clients that display MCP Apps, and as a link everywhere.
`?embed=1` shows the app as the globe alone and takes new views from the page
that frames it. MCP leads with the tools that find what to show. See
[tools and the MCP server](TOOLS.md).
## Tools and local MCP server — October 1, 2026
`gods-eye-view/tools` defines queries that answer questions from the app's data,
+17 -1
View File
@@ -104,6 +104,22 @@ tilted camera over an area looks at its center from behind; camera fields
given with an area override its framing. `resolveViewArguments` turns them
into a view.
### The globe panel
`show_on_globe` 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`.
### Embed mode
`?embed=1` shows only the globe: clean view, with the HUD, panels, welcome
@@ -234,4 +250,4 @@ public feeds the sources already use.
| `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, by section |
| `military_awareness` | `military` | Military and other aircraft, ships and military installations within 250 km of a point, by section |
| `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area |
| `open_in_gods_eye_view` | `app` | A share link to a view: an area or camera, layers, style, map, and an aircraft or satellite to follow |
| `show_on_globe` | `app` | A view on the globe: the live globe 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 |
+2 -1
View File
@@ -891,5 +891,6 @@
"src/tools/places.test.mjs",
"src/view/index.test.mjs",
"src/tools/views.test.mjs",
"src/app/embed.test.mjs"
"src/app/embed.test.mjs",
"src/tools/globePanel.test.mjs"
]
+2 -1
View File
@@ -3139,7 +3139,8 @@
"src/loadingFeedback.js",
"src/data/installationFeedback.js",
"src/view/index.js",
"src/tools/views.js"
"src/tools/views.js",
"src/tools/globePanel.js"
],
"external": ["satellite.js"]
},
+7 -1
View File
@@ -6,13 +6,18 @@ import {
coreTools,
catalogForSurface,
} from '../../src/tools/index.js';
import { createGlobePanelResource } from '../../src/tools/globePanel.js';
import { createMcpServer } from '../../src/tools/mcp/index.js';
import { DEFAULT_API_BASE, createLocalToolServices } from './services.js';
const INSTRUCTIONS =
"Tools answer questions from God's Eye View's live public data. Location " +
'tools take an area: a place name, a bbox, or lat/lon with radius_km. ' +
'Results are capped; check truncated and total before concluding there is nothing more.';
'Results are capped; check truncated and total before concluding there is nothing more. ' +
'Answers that can be seen on the globe include data.view; to show one, call ' +
'show_on_globe with that view, adding layers, style, a camera or marks as ' +
'needed. It shows the live globe where the client displays apps and ' +
'returns a link everywhere.';
/** Construct the local MCP server for Core's tools. */
export function createLocalMcpServer({
@@ -33,5 +38,6 @@ export function createLocalMcpServer({
name: 'gods-eye-view',
version,
instructions: INSTRUCTIONS,
resources: [createGlobePanelResource({ appUrl: apiBase })],
});
}
+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',