mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
60c40865e9
commit
216a75ac71
17 files changed
+417
-41
No files matched your search
+33
-14
@@ -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, {
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user