mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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), [
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
]);
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user