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

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