diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7ed18026a..da089a934 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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, diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 9c01cfbdf..b11d518b3 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -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 | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index e7a4caa3b..a0a7820a9 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 73b86600b..fa5685bd4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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"] }, diff --git a/server/mcp/server.js b/server/mcp/server.js index 0ddb79795..a9a8b5ea1 100644 --- a/server/mcp/server.js +++ b/server/mcp/server.js @@ -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 })], }); } diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index 3eddb1b40..c26de99a0 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -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. diff --git a/src/tools/catalog.js b/src/tools/catalog.js index e525bbce0..59cd6f0c9 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -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, }); } diff --git a/src/tools/functions.test.mjs b/src/tools/functions.test.mjs index 0965233a3..51776ea75 100644 --- a/src/tools/functions.test.mjs +++ b/src/tools/functions.test.mjs @@ -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), [ diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js new file mode 100644 index 000000000..96b20620a --- /dev/null +++ b/src/tools/globePanel.js @@ -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 ` + +
+ +view
', + _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: 'view
', + _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/, + ); +}); diff --git a/src/tools/mcp/protocol.js b/src/tools/mcp/protocol.js index 44e3e778c..67858833e 100644 --- a/src/tools/mcp/protocol.js +++ b/src/tools/mcp/protocol.js @@ -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'); diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index 941fd4616..71f28422a 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -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, { diff --git a/src/tools/queries/share.test.mjs b/src/tools/queries/share.test.mjs index 06e4c29fe..dc4d5247a 100644 --- a/src/tools/queries/share.test.mjs +++ b/src/tools/queries/share.test.mjs @@ -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/, ); }); diff --git a/src/tools/surfaces.js b/src/tools/surfaces.js index 7b1030f88..b04388bd4 100644 --- a/src/tools/surfaces.js +++ b/src/tools/surfaces.js @@ -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. diff --git a/src/tools/surfaces.test.mjs b/src/tools/surfaces.test.mjs index 255b7c9fd..915547e1a 100644 --- a/src/tools/surfaces.test.mjs +++ b/src/tools/surfaces.test.mjs @@ -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',