From c3dfa8e853c06b20692e9d7026b6f91a4c1db510 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 11:51:59 -0700 Subject: [PATCH 001/111] feat: add a tool catalog and local MCP server Define tools that answer questions from the app's data once, independent of the surface that exposes them. A tool declares its arguments and the services it reads; applications compose tools with their own services and optional call interceptors. Queries reuse the layers' portable source factories, so they run wherever an application routes /api requests. The first queries are get_earthquakes, get_active_fires and get_recent_launches, with a shared area argument (place name, bounding box, or point and radius) and capped lists. gods-eye-view/tools/mcp adapts a catalog to the Model Context Protocol with a small JSON-RPC handler and a stateless HTTP transport, adding no dependencies. npm run mcp serves Core's tools over stdio to local clients such as Claude Code, reading from a running app. Both exports are portable and checked by check:boundaries; the app does not import them. --- CHANGELOG.md | 6 + docs/CURRENT-STATE.md | 12 ++ docs/TOOLS.md | 89 ++++++++++++ package.json | 7 +- scripts/check-import-directions.mjs | 2 + scripts/format-scope.json | 7 +- scripts/package-boundaries.json | 25 ++++ server/mcp/server.js | 30 +++++ server/mcp/services.js | 39 ++++++ server/mcp/stdio.js | 67 +++++++++ src/tools/area.js | 120 +++++++++++++++++ src/tools/area.test.mjs | 102 ++++++++++++++ src/tools/catalog.js | 143 ++++++++++++++++++++ src/tools/catalog.test.mjs | 166 +++++++++++++++++++++++ src/tools/index.js | 24 ++++ src/tools/localServer.test.mjs | 87 ++++++++++++ src/tools/mcp/http.js | 50 +++++++ src/tools/mcp/index.js | 9 ++ src/tools/mcp/mcp.test.mjs | 179 ++++++++++++++++++++++++ src/tools/mcp/protocol.js | 167 +++++++++++++++++++++++ src/tools/places.js | 51 +++++++ src/tools/queries/hazards.js | 98 ++++++++++++++ src/tools/queries/queries.test.mjs | 202 ++++++++++++++++++++++++++++ src/tools/queries/space.js | 49 +++++++ src/tools/results.js | 33 +++++ src/tools/schema.js | 125 +++++++++++++++++ 26 files changed, 1886 insertions(+), 3 deletions(-) create mode 100644 docs/TOOLS.md create mode 100644 server/mcp/server.js create mode 100644 server/mcp/services.js create mode 100644 server/mcp/stdio.js create mode 100644 src/tools/area.js create mode 100644 src/tools/area.test.mjs create mode 100644 src/tools/catalog.js create mode 100644 src/tools/catalog.test.mjs create mode 100644 src/tools/index.js create mode 100644 src/tools/localServer.test.mjs create mode 100644 src/tools/mcp/http.js create mode 100644 src/tools/mcp/index.js create mode 100644 src/tools/mcp/mcp.test.mjs create mode 100644 src/tools/mcp/protocol.js create mode 100644 src/tools/places.js create mode 100644 src/tools/queries/hazards.js create mode 100644 src/tools/queries/queries.test.mjs create mode 100644 src/tools/queries/space.js create mode 100644 src/tools/results.js create mode 100644 src/tools/schema.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 58103d04d..e8915b417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +- Add tools for language-model clients and a local MCP server. `npm run mcp` + serves earthquake, active-fire and recent-launch queries over stdio to + clients such as Claude Code, reading from a running app. Tools are defined + once in `gods-eye-view/tools`, reuse the layers' source factories, and are + exposed through the protocol adapter in `gods-eye-view/tools/mcp`. + - Public Overpass instances are no longer used by default. Street Traffic roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen on the layer row (TomTom / OSM / Hybrid) or with `?trafficRoads=`. With a diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..bd68e4879 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,17 @@ # God's Eye View Current State +## Tools and local MCP server — October 1, 2026 + +`gods-eye-view/tools` defines queries that answer questions from the app's data, +and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context +Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client, +reading from a running app's `/api` routes (default `http://localhost:4173`). +The first queries are `get_earthquakes`, `get_active_fires` and +`get_recent_launches`. Tools reuse the layers' portable source factories, take a +shared `area` argument (place name, bounding box, or point and radius) and cap +lists at 25 rows by default. The app does not import the tools, so its runtime +behavior is unchanged. See [tools and the MCP server](TOOLS.md). + ## Cyber HUD — September 23, 2026 Display > HUD > Layout includes Cyber, also available through the HUD voice diff --git a/docs/TOOLS.md b/docs/TOOLS.md new file mode 100644 index 000000000..f955f32ba --- /dev/null +++ b/docs/TOOLS.md @@ -0,0 +1,89 @@ +# Tools and the MCP server + +Tools answer questions from God's Eye View data for language-model clients. +They are defined once and exposed through adapters; the Model Context Protocol +(MCP) is the first. + +## Layers + +| Owner | Responsibility | +| -------------------- | ------------------------------------------------------------------------------------------------- | +| `src/tools/` | Tool definitions, catalog composition, argument validation, shared `area` and result helpers | +| `src/tools/queries/` | Queries, one file per domain, reading only portable source contracts | +| `src/tools/mcp/` | MCP protocol (JSON-RPC) and a stateless HTTP transport; knows the catalog interface, not queries | +| `server/mcp/` | Node composition: points the sources at a running app's `/api` routes and serves stdio | + +Dependencies point downward only. `gods-eye-view/tools` and +`gods-eye-view/tools/mcp` are portable exports: they reach no application, +rendering, Node, Cesium or browser-global code, which +`npm run check:boundaries` enforces. Nothing in the application imports them. + +## Definitions and composition + +`defineTool({ name, kind, title, description, inputSchema, requires, run })` +validates and freezes a tool. `kind` is `query` (answers from data, read-only) +or `action`. `inputSchema` uses a JSON Schema subset that `src/tools/schema.js` +checks completely; unsupported keywords are rejected at definition time. +`run(args, { services, signal })` resolves to `{ summary, data }`: one sentence +for people and a structured object for programs. + +`composeCatalog({ tools, services, replace, interceptors })` builds a catalog: + +- **Tools**: an application adds its own tools to `coreTools`. Reusing a name + fails unless the name is listed in `replace`. +- **Services**: each tool names the services it reads in `requires`. Tools + whose services are not supplied are left out. +- **Interceptors**: `(call, next) => next(call)` functions wrap every call, + outermost first. They can observe, reject or change a call. + +Expected failures throw `ToolError` with one of `invalid_arguments`, +`unavailable`, `unsupported`, `malformed` or `retry_later`. Other errors are +reported to clients without details. + +## Services + +Services are the portable source factories the layers already use, such as +`createUsgsEarthquakeSource`, `createFirmsSource` and `createLaunchSource`, +plus a `places` service with `resolve(name, { signal })`. Sources request +relative `/api/...` paths through an injected `fetchImpl`, so the same tool +code runs wherever an application routes those paths. +`createGeocodePlaceService` resolves place names through `/api/geocode`. + +## The `area` argument + +Location-scoped tools take `area` as exactly one of a `place` name, a `bbox` +(`[west, south, east, north]`, crossing the antimeridian when west exceeds +east), or `lat`, `lon` and `radius_km`. Lists default to 25 rows, at most 200, +and report `total`, `returned` and `truncated`. + +## MCP + +`createMcpServer({ catalog, name, version, instructions, descriptions, decorate })` +implements `initialize`, `ping`, `tools/list` and `tools/call` for protocol +revisions 2025-11-25, 2025-06-18 and 2025-03-26. `descriptions` overrides a +tool's title or description for this surface; `decorate(definition, tool)` +merges extra fields into each listed definition. `createMcpHttpHandler(server)` +returns a `Request`-to-`Response` handler for stateless Streamable HTTP: one +JSON-RPC message per POST, answered with JSON. The host owns routing and any +access control in front of it. + +## Running locally + +Start the app (`npm run dev` or `npm run preview`), then register the stdio +server with an MCP client, for example Claude Code: + +```bash +claude mcp add gods-eye-view -- npm --prefix /path/to/gods-eye-view run --silent mcp +``` + +`npm run mcp -- --api-base http://localhost:4173` selects another server. The +local server makes no requests other than to the app's `/api` routes and the +public feeds the sources already use. + +## Tools + +| Tool | Reads | Returns | +| --------------------- | ------------- | ------------------------------------------------------------- | +| `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first | +| `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first | +| `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first | diff --git a/package.json b/package.json index e48881c6d..13b7bd8fd 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "test:track": "node scripts/track-regression.mjs", "qa:map-source-tray": "node scripts/qa-map-source-tray.mjs", "check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs", - "qa:transit": "node scripts/qa-transit.mjs" + "qa:transit": "node scripts/qa-transit.mjs", + "mcp": "node server/mcp/stdio.js" }, "dependencies": { "@jtarrio/signals": "^0.10.0", @@ -250,6 +251,8 @@ "./layers/transit/standalone": "./src/data/transit.js", "./layers/transit/source": "./src/layers/transit/source.js", "./sources/transit-service": "./src/sources/transitService.js", - "./sources/map-features": "./src/sources/overpassFeatures.js" + "./sources/map-features": "./src/sources/overpassFeatures.js", + "./tools": "./src/tools/index.js", + "./tools/mcp": "./src/tools/mcp/index.js" } } diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index a5e61f039..72e163988 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -37,6 +37,8 @@ const portableExport = (key) => ) || [ './director', + './tools', + './tools/mcp', './voice/action-schemas', './voice/session', './data/lifecycle', diff --git a/scripts/format-scope.json b/scripts/format-scope.json index cd836a5cb..b2358d5ca 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -864,5 +864,10 @@ "src/layers/traffic/motion.test.mjs", "scripts/qa-journey-recorder.mjs", "scripts/qa-alpr-journey.mjs", - "scripts/qa-context-arrivals.mjs" + "scripts/qa-context-arrivals.mjs", + "src/tools/catalog.test.mjs", + "src/tools/area.test.mjs", + "src/tools/queries/queries.test.mjs", + "src/tools/mcp/mcp.test.mjs", + "src/tools/localServer.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 276b9e0db..636004286 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3072,5 +3072,30 @@ "src/layers/perimeters/source.js" ], "external": [] + }, + "tools": { + "exports": ["./tools"], + "modules": [ + "src/tools/index.js", + "src/tools/catalog.js", + "src/tools/schema.js", + "src/tools/area.js", + "src/tools/results.js", + "src/tools/places.js", + "src/tools/queries/hazards.js", + "src/tools/queries/space.js" + ], + "external": [] + }, + "tools-mcp": { + "exports": ["./tools/mcp"], + "modules": [ + "src/tools/mcp/index.js", + "src/tools/mcp/protocol.js", + "src/tools/mcp/http.js", + "src/tools/catalog.js", + "src/tools/schema.js" + ], + "external": [] } } diff --git a/server/mcp/server.js b/server/mcp/server.js new file mode 100644 index 000000000..daacd3d3c --- /dev/null +++ b/server/mcp/server.js @@ -0,0 +1,30 @@ +/** The local MCP server: Core's tools over services backed by a running app. */ + +import { readFileSync } from 'node:fs'; +import { composeCatalog, coreTools } from '../../src/tools/index.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.'; + +/** Construct the local MCP server for Core's tools. */ +export function createLocalMcpServer({ + apiBase = DEFAULT_API_BASE, + fetchImpl, +} = {}) { + const { version } = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), + ); + return createMcpServer({ + catalog: composeCatalog({ + tools: coreTools, + services: createLocalToolServices({ apiBase, fetchImpl }), + }), + name: 'gods-eye-view', + version, + instructions: INSTRUCTIONS, + }); +} diff --git a/server/mcp/services.js b/server/mcp/services.js new file mode 100644 index 000000000..07a492692 --- /dev/null +++ b/server/mcp/services.js @@ -0,0 +1,39 @@ +/** + * Local composition of tool services: the portable source factories, pointed + * at a running God's Eye View server's `/api` routes. + */ + +import { createUsgsEarthquakeSource } from '../../src/layers/earthquakes/source.js'; +import { createFirmsSource } from '../../src/layers/firms/source.js'; +import { createLaunchSource } from '../../src/layers/launches/source.js'; +import { createGeocodePlaceService } from '../../src/tools/places.js'; + +export const DEFAULT_API_BASE = 'http://localhost:4173'; + +/** Resolve the sources' relative `/api/...` requests against `apiBase`. */ +export function createApiFetch({ + apiBase = DEFAULT_API_BASE, + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + const base = new URL(apiBase); + if (!['http:', 'https:'].includes(base.protocol)) + throw new TypeError(`apiBase must be an http(s) URL: ${apiBase}`); + return (input, init) => + fetchImpl( + typeof input === 'string' && input.startsWith('/') + ? new URL(input, base) + : input, + init, + ); +} + +/** Construct every service Core's tools read, backed by the local server. */ +export function createLocalToolServices(options = {}) { + const fetchImpl = createApiFetch(options); + return { + earthquakes: createUsgsEarthquakeSource({ fetchImpl }), + fires: createFirmsSource({ fetchImpl }), + launches: createLaunchSource({ fetchImpl }), + places: createGeocodePlaceService({ fetchImpl }), + }; +} diff --git a/server/mcp/stdio.js b/server/mcp/stdio.js new file mode 100644 index 000000000..c372e1f6c --- /dev/null +++ b/server/mcp/stdio.js @@ -0,0 +1,67 @@ +/** + * Local MCP server over stdio for agents such as Claude Code and Codex. + * + * npm run mcp -- [--api-base http://localhost:4173] + * + * Reads newline-delimited JSON-RPC from stdin and writes responses to stdout. + * Tools read data from a running God's Eye View server (`npm run dev` or + * `npm run preview`) at the API base. Diagnostics go to stderr. + */ + +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; +import { parseErrorResponse } from '../../src/tools/mcp/index.js'; +import { createLocalMcpServer } from './server.js'; +import { DEFAULT_API_BASE } from './services.js'; + +/** Serve newline-delimited JSON-RPC until `input` ends. */ +export async function serveStdio(server, { input, output }) { + const lines = createInterface({ input, crlfDelay: Infinity }); + const pending = new Set(); + for await (const line of lines) { + if (!line.trim()) continue; + const work = respond(server, line).then((response) => { + if (response) output.write(`${JSON.stringify(response)}\n`); + }); + pending.add(work); + work.finally(() => pending.delete(work)); + } + await Promise.all(pending); +} + +async function respond(server, line) { + let message; + try { + message = JSON.parse(line); + } catch { + return parseErrorResponse(); + } + return server.handle(message); +} + +/** Read `--api-base ` from command-line arguments. */ +export function parseArgs(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === '--api-base' && argv[index + 1]) + options.apiBase = argv[(index += 1)]; + else throw new Error(`Unknown argument: ${argv[index]}`); + } + return options; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + const options = parseArgs(process.argv.slice(2)); + console.error( + `God's Eye View MCP server reading ${options.apiBase || DEFAULT_API_BASE}`, + ); + await serveStdio(createLocalMcpServer(options), { + input: process.stdin, + output: process.stdout, + }); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/src/tools/area.js b/src/tools/area.js new file mode 100644 index 000000000..35b27315f --- /dev/null +++ b/src/tools/area.js @@ -0,0 +1,120 @@ +/** + * The `area` argument shared by location-scoped tools: a bounding box, a + * center point with radius, or a place name resolved through the `places` + * service. + */ + +import { ToolError } from './catalog.js'; + +const EARTH_RADIUS_KM = 6371.0088; +const MAX_RADIUS_KM = 2000; + +/** JSON Schema for the `area` argument. */ +export const AREA_SCHEMA = Object.freeze({ + type: 'object', + description: + 'Where to look. Give exactly one of: place (a name such as "Taiwan" or ' + + '"San Francisco Bay"), bbox ([west, south, east, north] in degrees), or ' + + 'lat and lon with radius_km.', + properties: { + place: { type: 'string', minLength: 1, maxLength: 200 }, + bbox: { + type: 'array', + items: { type: 'number' }, + minItems: 4, + maxItems: 4, + }, + lat: { type: 'number', minimum: -90, maximum: 90 }, + lon: { type: 'number', minimum: -180, maximum: 180 }, + radius_km: { type: 'number', minimum: 0.1, maximum: MAX_RADIUS_KM }, + }, + additionalProperties: false, +}); + +/** + * Resolve an `area` argument to `{ label, west, south, east, north, center? }`. + * A box whose west edge exceeds its east edge crosses the antimeridian. + */ +export async function resolveArea(area, { services, signal } = {}) { + const given = ['place', 'bbox', 'lat'].filter((key) => area?.[key] != null); + if (given.length !== 1) + throw new ToolError( + 'invalid_arguments', + 'area needs exactly one of place, bbox, or lat/lon with radius_km', + ); + if (area.bbox) { + const [west, south, east, north] = area.bbox; + if ( + [west, east].some((value) => Math.abs(value) > 180) || + [south, north].some((value) => Math.abs(value) > 90) || + south >= north + ) + throw new ToolError( + 'invalid_arguments', + 'area.bbox must be [west, south, east, north] with south below north', + ); + return { label: 'the requested box', west, south, east, north }; + } + if (area.lat != null) { + if (area.lon == null || area.radius_km == null) + throw new ToolError( + 'invalid_arguments', + 'area with lat also needs lon and radius_km', + ); + return circleArea(area.lat, area.lon, area.radius_km); + } + const places = services?.places; + if (!places) + throw new ToolError( + 'unsupported', + 'Place names cannot be resolved here; give a bbox or lat/lon instead', + ); + const place = await places.resolve(area.place, { signal }); + if (!place) + throw new ToolError( + 'invalid_arguments', + `No place matched "${area.place}"; try a more specific name or a bbox`, + ); + return { label: place.name || area.place, ...place.bounds }; +} + +function circleArea(lat, lon, radiusKm) { + const latSpan = (radiusKm / EARTH_RADIUS_KM) * (180 / Math.PI); + const cosLat = Math.cos((lat * Math.PI) / 180); + const lonSpan = cosLat < 1e-6 ? 180 : Math.min(180, latSpan / cosLat); + const wrap = (value) => ((((value + 180) % 360) + 360) % 360) - 180; + return { + label: `${radiusKm} km around ${lat.toFixed(3)}, ${lon.toFixed(3)}`, + west: lonSpan >= 180 ? -180 : wrap(lon - lonSpan), + east: lonSpan >= 180 ? 180 : wrap(lon + lonSpan), + south: Math.max(-90, lat - latSpan), + north: Math.min(90, lat + latSpan), + center: { lat, lon, radiusKm }, + }; +} + +/** Great-circle distance in kilometers. */ +export function distanceKm(a, b) { + const toRad = Math.PI / 180; + const dLat = (b.lat - a.lat) * toRad; + const dLon = (b.lon - a.lon) * toRad; + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(a.lat * toRad) * Math.cos(b.lat * toRad) * Math.sin(dLon / 2) ** 2; + return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h))); +} + +/** Whether a point lies inside a resolved area, including radius areas. */ +export function areaContains(area, point) { + if (!Number.isFinite(point?.lat) || !Number.isFinite(point?.lon)) + return false; + if (point.lat < area.south || point.lat > area.north) return false; + const inLongitude = + area.west <= area.east + ? point.lon >= area.west && point.lon <= area.east + : point.lon >= area.west || point.lon <= area.east; + if (!inLongitude) return false; + return area.center + ? distanceKm(area.center, point) <= area.center.radiusKm + : true; +} diff --git a/src/tools/area.test.mjs b/src/tools/area.test.mjs new file mode 100644 index 000000000..fac64690a --- /dev/null +++ b/src/tools/area.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { areaContains, distanceKm, resolveArea } from './area.js'; +import { placeFromGeocodeResult } from './places.js'; + +test('an area needs exactly one form', async () => { + for (const area of [ + {}, + { place: 'Oslo', bbox: [0, 0, 1, 1] }, + { lat: 1, lon: 2 }, + ]) { + await assert.rejects( + resolveArea(area), + (error) => error.code === 'invalid_arguments', + ); + } + await assert.rejects( + resolveArea({ bbox: [0, 10, 1, 5] }), + /south below north/, + ); +}); + +test('boxes, including antimeridian boxes, contain the expected points', async () => { + const box = await resolveArea({ bbox: [-10, 40, 10, 50] }); + assert.equal(areaContains(box, { lat: 45, lon: 0 }), true); + assert.equal(areaContains(box, { lat: 45, lon: 20 }), false); + const pacific = await resolveArea({ bbox: [170, -20, -170, 0] }); + assert.equal(areaContains(pacific, { lat: -10, lon: 179 }), true); + assert.equal(areaContains(pacific, { lat: -10, lon: -175 }), true); + assert.equal(areaContains(pacific, { lat: -10, lon: 0 }), false); + assert.equal(areaContains(box, { lat: Number.NaN, lon: 0 }), false); +}); + +test('radius areas use great-circle distance', async () => { + const circle = await resolveArea({ lat: 37.77, lon: -122.42, radius_km: 50 }); + assert.equal(circle.center.radiusKm, 50); + assert.equal(areaContains(circle, { lat: 37.8, lon: -122.27 }), true); + assert.equal(areaContains(circle, { lat: 38.58, lon: -121.49 }), false); + assert.ok( + Math.abs(distanceKm({ lat: 0, lon: 0 }, { lat: 0, lon: 1 }) - 111.2) < 0.1, + ); + // A circle across the antimeridian wraps its box. + const dateline = await resolveArea({ lat: 0, lon: 179.9, radius_km: 100 }); + assert.ok(dateline.west > dateline.east); + assert.equal(areaContains(dateline, { lat: 0, lon: -179.9 }), true); +}); + +test('place names resolve through the places service', async () => { + const places = { + resolve: async (name) => + name === 'Taiwan' + ? { + name: 'Taiwan', + bounds: { west: 119, south: 21, east: 123, north: 26 }, + } + : null, + }; + const taiwan = await resolveArea( + { place: 'Taiwan' }, + { services: { places } }, + ); + assert.deepEqual(taiwan, { + label: 'Taiwan', + west: 119, + south: 21, + east: 123, + north: 26, + }); + await assert.rejects( + resolveArea({ place: 'Nowhere' }, { services: { places } }), + /No place matched "Nowhere"/, + ); + await assert.rejects( + resolveArea({ place: 'Taiwan' }), + (error) => error.code === 'unsupported', + ); +}); + +test('geocode results become bounded places', () => { + assert.deepEqual( + placeFromGeocodeResult({ + formatted_address: 'Oslo, Norway', + geometry: { + location: { lat: 59.9, lng: 10.7 }, + viewport: { + southwest: { lat: 59.8, lng: 10.5 }, + northeast: { lat: 60, lng: 10.9 }, + }, + }, + }), + { + name: 'Oslo, Norway', + bounds: { west: 10.5, south: 59.8, east: 10.9, north: 60 }, + }, + ); + assert.deepEqual( + placeFromGeocodeResult({ geometry: { location: { lat: 0, lng: 179.9 } } }) + .bounds, + { west: 179.65, south: -0.25, east: 180, north: 0.25 }, + ); + assert.equal(placeFromGeocodeResult({ geometry: {} }), null); +}); diff --git a/src/tools/catalog.js b/src/tools/catalog.js new file mode 100644 index 000000000..a6e3db530 --- /dev/null +++ b/src/tools/catalog.js @@ -0,0 +1,143 @@ +/** + * Tool definitions and their composition into a callable catalog. + * + * A tool declares what it does, its arguments, and which services it reads. + * It knows nothing about the surface that exposes it (MCP, voice) or the + * transport its services use. Applications compose the tools they want with + * the services they supply, plus optional interceptors that wrap every call. + */ + +import { assertSupportedSchema, validateValue } from './schema.js'; + +const TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/; +const KINDS = new Set(['query', 'action']); + +/** Error codes a tool may report. Each is safe to show to a model or person. */ +export const TOOL_ERROR_CODES = Object.freeze([ + 'invalid_arguments', + 'unavailable', + 'unsupported', + 'malformed', + 'retry_later', +]); + +/** An expected failure with a stable code and a message written for the caller. */ +export class ToolError extends Error { + constructor(code, message, { retryAfterSeconds = null } = {}) { + if (!TOOL_ERROR_CODES.includes(code)) + throw new TypeError(`Unknown tool error code: ${code}`); + super(message); + this.name = 'ToolError'; + this.code = code; + this.retryAfterSeconds = retryAfterSeconds; + } +} + +/** + * Validate and freeze a tool definition. + * + * `run(args, { services, signal })` resolves to `{ summary, data }`: a short + * sentence for people and a structured object for programs. + */ +export function defineTool({ + name, + kind = 'query', + title, + description, + inputSchema, + annotations = {}, + requires = [], + run, +}) { + if (!TOOL_NAME.test(String(name))) + throw new TypeError(`Invalid tool name: ${name}`); + if (!KINDS.has(kind)) throw new TypeError(`Invalid tool kind: ${kind}`); + if (typeof title !== 'string' || !title) + throw new TypeError(`${name} needs a title`); + if (typeof description !== 'string' || !description) + throw new TypeError(`${name} needs a description`); + assertSupportedSchema(inputSchema, `${name}.inputSchema`); + if (inputSchema.type !== 'object') + throw new TypeError(`${name}.inputSchema must describe an object`); + if ( + !Array.isArray(requires) || + requires.some((key) => typeof key !== 'string') + ) + throw new TypeError(`${name}.requires must list service names`); + if (typeof run !== 'function') throw new TypeError(`${name} needs run()`); + return Object.freeze({ + name, + kind, + title, + description, + inputSchema: structuredClone(inputSchema), + annotations: Object.freeze({ + readOnlyHint: kind === 'query', + ...annotations, + }), + requires: Object.freeze([...requires]), + run, + }); +} + +/** + * Compose tools with the services an application supplies. + * + * - Duplicate names fail unless the later tool is listed in `replace`. + * - Tools whose required services are missing are left out. + * - Interceptors wrap every call in order, outermost first, as + * `(call, next) => next(call)` where `call` is `{ tool, args, signal }`. + */ +export function composeCatalog({ + tools, + replace = [], + services = {}, + interceptors = [], +}) { + const byName = new Map(); + const replaceable = new Set(replace); + for (const tool of tools) { + if (byName.has(tool.name) && !replaceable.has(tool.name)) + throw new TypeError(`Duplicate tool name: ${tool.name}`); + byName.set(tool.name, tool); + } + for (const name of replaceable) { + if (!byName.has(name)) + throw new TypeError(`Cannot replace unknown tool: ${name}`); + } + const available = [...byName.values()].filter((tool) => + tool.requires.every((key) => services[key] != null), + ); + const index = new Map(available.map((tool) => [tool.name, tool])); + + const execute = async ({ tool, args, signal }) => { + const problems = validateValue(tool.inputSchema, args); + if (problems.length) + throw new ToolError('invalid_arguments', problems.join('; ')); + const result = await tool.run(args, { services, signal }); + if (!result || typeof result.summary !== 'string' || !result.data) + throw new TypeError(`${tool.name} returned no summary or data`); + return result; + }; + const chain = interceptors.reduceRight( + (next, interceptor) => (call) => interceptor(call, next), + execute, + ); + + return Object.freeze({ + /** Available tool definitions, in composition order. */ + list: () => [...available], + /** The available tool with this name, or undefined. */ + get: (name) => index.get(name), + /** Validate arguments and run a tool through the interceptors. */ + async call(name, args = {}, { signal } = {}) { + const tool = index.get(name); + if (!tool) + throw new ToolError( + 'unsupported', + `No tool named ${name} is available`, + ); + return chain({ tool, args, signal }); + }, + }); +} diff --git a/src/tools/catalog.test.mjs b/src/tools/catalog.test.mjs new file mode 100644 index 000000000..997bf002f --- /dev/null +++ b/src/tools/catalog.test.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, defineTool, ToolError } from './catalog.js'; +import { validateValue } from './schema.js'; + +const echo = (name, extra = {}) => + defineTool({ + name, + title: `Echo ${name}`, + description: 'Returns its arguments.', + inputSchema: { + type: 'object', + properties: { word: { type: 'string', maxLength: 5 } }, + additionalProperties: false, + }, + run: async (args) => ({ summary: name, data: { args } }), + ...extra, + }); + +test('definitions are validated, frozen and read-only by default', () => { + const tool = echo('echo'); + assert.equal(Object.isFrozen(tool), true); + assert.equal(tool.kind, 'query'); + assert.deepEqual(tool.annotations, { readOnlyHint: true }); + assert.equal(echo('act', { kind: 'action' }).annotations.readOnlyHint, false); + assert.throws(() => echo('Bad-Name'), /Invalid tool name/); + assert.throws(() => echo('x', { kind: 'other' }), /Invalid tool kind/); + assert.throws(() => echo('x', { title: '' }), /needs a title/); + assert.throws( + () => + echo('x', { + inputSchema: { + type: 'object', + properties: { a: { type: 'string', pattern: '.' } }, + }, + }), + /unsupported keyword pattern/, + ); + assert.throws( + () => echo('x', { inputSchema: { type: 'string' } }), + /must describe an object/, + ); +}); + +test('composition rejects duplicates unless replaced, and hides tools without services', () => { + const first = echo('echo'); + const second = echo('echo', { description: 'Replacement.' }); + assert.throws( + () => composeCatalog({ tools: [first, second] }), + /Duplicate tool name: echo/, + ); + assert.throws( + () => composeCatalog({ tools: [first], replace: ['other'] }), + /Cannot replace unknown tool/, + ); + const replaced = composeCatalog({ + tools: [first, second], + replace: ['echo'], + }); + assert.equal(replaced.get('echo').description, 'Replacement.'); + + const needsFeed = echo('feed', { requires: ['feed'] }); + assert.deepEqual( + composeCatalog({ tools: [first, needsFeed] }) + .list() + .map((tool) => tool.name), + ['echo'], + ); + assert.deepEqual( + composeCatalog({ tools: [first, needsFeed], services: { feed: {} } }) + .list() + .map((tool) => tool.name), + ['echo', 'feed'], + ); +}); + +test('calls validate arguments and pass through interceptors outermost first', async () => { + const order = []; + const catalog = composeCatalog({ + tools: [echo('echo')], + interceptors: [ + async (call, next) => { + order.push(`outer:${call.tool.name}`); + const result = await next(call); + order.push('outer:done'); + return result; + }, + (call, next) => { + order.push('inner'); + return next({ ...call, args: { word: 'hi' } }); + }, + ], + }); + const result = await catalog.call('echo', { word: 'ignored' }); + assert.deepEqual(result, { summary: 'echo', data: { args: { word: 'hi' } } }); + assert.deepEqual(order, ['outer:echo', 'inner', 'outer:done']); + + await assert.rejects( + composeCatalog({ tools: [echo('echo')] }).call('echo', { + word: 'toolong', + extra: 1, + }), + (error) => + error instanceof ToolError && + error.code === 'invalid_arguments' && + /arguments\.word must be at most 5 characters/.test(error.message) && + /unknown property extra/.test(error.message), + ); + await assert.rejects( + composeCatalog({ tools: [] }).call('missing'), + (error) => error.code === 'unsupported', + ); +}); + +test('a tool that returns no summary is a programming error, not a tool error', async () => { + const broken = echo('broken', { run: async () => ({ data: {} }) }); + await assert.rejects( + composeCatalog({ tools: [broken] }).call('broken'), + (error) => error instanceof TypeError && !(error instanceof ToolError), + ); +}); + +test('tool errors only use known codes', () => { + assert.throws(() => new ToolError('nope', 'x'), /Unknown tool error code/); + assert.equal( + new ToolError('retry_later', 'x', { retryAfterSeconds: 5 }) + .retryAfterSeconds, + 5, + ); +}); + +test('the validator checks every supported keyword', () => { + const schema = { + type: 'object', + required: ['n'], + properties: { + n: { type: 'integer', minimum: 1, maximum: 3 }, + list: { + type: 'array', + minItems: 1, + maxItems: 2, + items: { type: 'number' }, + }, + mode: { type: 'string', enum: ['a', 'b'] }, + flag: { type: 'boolean' }, + }, + }; + assert.deepEqual( + validateValue(schema, { n: 2, list: [1], mode: 'a', flag: true }), + [], + ); + assert.deepEqual( + validateValue(schema, { n: 1.5, list: [], mode: 'c', flag: 'yes' }), + [ + 'arguments.n must be an integer', + 'arguments.list must have at least 1 items', + 'arguments.mode must be one of "a", "b"', + 'arguments.flag must be true or false', + ], + ); + assert.deepEqual(validateValue(schema, {}), ['arguments is missing n']); + assert.deepEqual(validateValue(schema, []), ['arguments must be an object']); + assert.deepEqual(validateValue(schema, { n: Number.NaN }), [ + 'arguments.n must be a number', + ]); +}); diff --git a/src/tools/index.js b/src/tools/index.js new file mode 100644 index 000000000..c8502261b --- /dev/null +++ b/src/tools/index.js @@ -0,0 +1,24 @@ +/** + * Tools that answer questions from God's Eye View data, independent of the + * surface that exposes them. See docs/TOOLS.md. + */ + +import { getActiveFires, getEarthquakes } from './queries/hazards.js'; +import { getRecentLaunches } from './queries/space.js'; + +export { + defineTool, + composeCatalog, + ToolError, + TOOL_ERROR_CODES, +} from './catalog.js'; +export { AREA_SCHEMA, resolveArea, areaContains, distanceKm } from './area.js'; +export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js'; +export { createGeocodePlaceService, placeFromGeocodeResult } from './places.js'; + +/** Every query Core defines, in a stable order. */ +export const coreTools = Object.freeze([ + getEarthquakes, + getActiveFires, + getRecentLaunches, +]); diff --git a/src/tools/localServer.test.mjs b/src/tools/localServer.test.mjs new file mode 100644 index 000000000..7b193aac4 --- /dev/null +++ b/src/tools/localServer.test.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PassThrough } from 'node:stream'; +import { createLocalMcpServer } from '../../server/mcp/server.js'; +import { createApiFetch } from '../../server/mcp/services.js'; +import { parseArgs, serveStdio } from '../../server/mcp/stdio.js'; + +const usgs = { + type: 'FeatureCollection', + features: [ + { + id: 'us1', + geometry: { type: 'Point', coordinates: [121.5, 24, 10] }, + properties: { mag: 5.1, place: 'near Hualien', time: 1767229200000 }, + }, + ], +}; + +test('relative API requests resolve against the configured base', async () => { + const seen = []; + const apiFetch = createApiFetch({ + apiBase: 'http://127.0.0.1:5000', + fetchImpl: async (url) => seen.push(String(url)), + }); + await apiFetch('/api/launches'); + await apiFetch('https://earthquake.usgs.gov/feed.geojson'); + assert.deepEqual(seen, [ + 'http://127.0.0.1:5000/api/launches', + 'https://earthquake.usgs.gov/feed.geojson', + ]); + assert.throws( + () => createApiFetch({ apiBase: 'file:///etc' }), + /http\(s\) URL/, + ); +}); + +test('the stdio server answers newline-delimited requests using only its data sources', async () => { + const contacted = []; + const server = createLocalMcpServer({ + apiBase: 'http://127.0.0.1:5000', + fetchImpl: async (url) => { + contacted.push(new URL(url).origin); + return Response.json(usgs); + }, + }); + const input = new PassThrough(); + const output = new PassThrough(); + let written = ''; + output.on('data', (chunk) => (written += chunk)); + const served = serveStdio(server, { input, output }); + input.write( + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}\n', + ); + input.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n\n'); + input.write('not json\n'); + input.write('{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n'); + input.end( + '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_earthquakes","arguments":{}}}\n', + ); + await served; + const responses = written + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + const byId = new Map(responses.map((response) => [response.id, response])); + assert.equal(responses.length, 4); + assert.equal(byId.get(1).result.serverInfo.name, 'gods-eye-view'); + assert.equal(byId.get(null).error.code, -32700); + assert.deepEqual( + byId.get(2).result.tools.map((tool) => tool.name), + ['get_earthquakes', 'get_active_fires', 'get_recent_launches'], + ); + assert.equal( + byId.get(3).result.content[0].text, + '1 earthquake of M2.5+ in the last 24 hours worldwide; strongest M5.1 near Hualien.', + ); + // The earthquake feed is fetched directly; nothing else was contacted. + assert.deepEqual(contacted, ['https://earthquake.usgs.gov']); +}); + +test('command-line arguments are strict', () => { + assert.deepEqual(parseArgs([]), {}); + assert.deepEqual(parseArgs(['--api-base', 'http://localhost:5173']), { + apiBase: 'http://localhost:5173', + }); + assert.throws(() => parseArgs(['--port', '1']), /Unknown argument/); +}); diff --git a/src/tools/mcp/http.js b/src/tools/mcp/http.js new file mode 100644 index 000000000..fc8549e19 --- /dev/null +++ b/src/tools/mcp/http.js @@ -0,0 +1,50 @@ +/** + * Stateless Streamable HTTP transport for an MCP server: one JSON-RPC message + * per POST, answered with a JSON body. Uses standard Request and Response so + * any web-standard runtime can mount it. The host owns routing and any access + * control in front of it. + */ + +import { MCP_PROTOCOL_VERSIONS, parseErrorResponse } from './protocol.js'; + +const MAX_BODY_BYTES = 1024 * 1024; + +/** Return `(request) => Promise` for an MCP server. */ +export function createMcpHttpHandler(server) { + return async function handleMcpRequest(request) { + if (request.method !== 'POST') + return new Response(null, { status: 405, headers: { Allow: 'POST' } }); + const version = request.headers.get('mcp-protocol-version'); + if (version && !MCP_PROTOCOL_VERSIONS.includes(version)) + return json(400, { + error: `Unsupported MCP protocol version: ${version}`, + }); + const type = request.headers.get('content-type') || ''; + if (!/^application\/json\b/i.test(type)) + return json(415, { error: 'Content-Type must be application/json' }); + const declared = Number(request.headers.get('content-length')); + if (declared > MAX_BODY_BYTES) + return json(413, { error: 'Request too large' }); + const text = await request.text(); + if (new TextEncoder().encode(text).length > MAX_BODY_BYTES) + return json(413, { error: 'Request too large' }); + let message; + try { + message = JSON.parse(text); + } catch { + return json(400, parseErrorResponse()); + } + const response = await server.handle(message, { signal: request.signal }); + return response ? json(200, response) : new Response(null, { status: 202 }); + }; +} + +function json(status, body) { + return new Response(JSON.stringify(body), { + status, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/src/tools/mcp/index.js b/src/tools/mcp/index.js new file mode 100644 index 000000000..5c1e985bb --- /dev/null +++ b/src/tools/mcp/index.js @@ -0,0 +1,9 @@ +/** Model Context Protocol adapter for a tool catalog. See docs/TOOLS.md. */ + +export { + createMcpServer, + toMcpTools, + parseErrorResponse, + MCP_PROTOCOL_VERSIONS, +} from './protocol.js'; +export { createMcpHttpHandler } from './http.js'; diff --git a/src/tools/mcp/mcp.test.mjs b/src/tools/mcp/mcp.test.mjs new file mode 100644 index 000000000..61a2ff63f --- /dev/null +++ b/src/tools/mcp/mcp.test.mjs @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, defineTool, ToolError } from '../catalog.js'; +import { + createMcpHttpHandler, + createMcpServer, + MCP_PROTOCOL_VERSIONS, + toMcpTools, +} from './index.js'; + +const tool = (name, run) => + defineTool({ + name, + title: 'Count', + description: 'Counts things.', + inputSchema: { + type: 'object', + properties: { n: { type: 'integer' } }, + additionalProperties: false, + }, + run, + }); +const catalog = composeCatalog({ + tools: [ + tool('count', async ({ n = 1 }) => ({ + summary: `${n} things.`, + data: { n }, + })), + tool('busy', async () => { + throw new ToolError('retry_later', 'Upstream is busy', { + retryAfterSeconds: 10, + }); + }), + tool('crash', async () => { + throw new Error('database password is hunter2'); + }), + ], +}); +const server = createMcpServer({ + catalog, + name: 'test', + version: '1.0.0', + instructions: 'Be brief.', +}); +const request = (method, params, id = 1) => + server.handle({ jsonrpc: '2.0', id, method, params }); + +test('initialize negotiates a supported protocol version', async () => { + const known = await request('initialize', { protocolVersion: '2025-06-18' }); + assert.deepEqual(known.result, { + protocolVersion: '2025-06-18', + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: 'test', version: '1.0.0' }, + instructions: 'Be brief.', + }); + const unknown = await request('initialize', { + protocolVersion: '1999-01-01', + }); + assert.equal(unknown.result.protocolVersion, MCP_PROTOCOL_VERSIONS[0]); + assert.deepEqual((await request('ping')).result, {}); +}); + +test('notifications and client responses get no reply', async () => { + assert.equal( + await server.handle({ + jsonrpc: '2.0', + method: 'notifications/initialized', + }), + null, + ); + assert.equal( + await server.handle({ jsonrpc: '2.0', id: 9, result: {} }), + null, + ); +}); + +test('invalid messages and unknown methods are JSON-RPC errors', async () => { + assert.equal( + (await server.handle({ id: 1, method: 'ping' })).error.code, + -32600, + ); + assert.equal( + (await server.handle([{ jsonrpc: '2.0', id: 1, method: 'ping' }])).error + .code, + -32600, + ); + assert.equal( + (await server.handle({ jsonrpc: '2.0', id: 1 })).error.code, + -32600, + ); + assert.equal((await request('tools/remove')).error.code, -32601); + assert.equal((await request('toString')).error.code, -32601); + assert.equal((await request('tools/list', [])).error.code, -32602); + assert.equal( + (await request('tools/call', { name: 'missing' })).error.code, + -32602, + ); +}); + +test('tools are listed with titles, schemas and annotations, with surface overrides', async () => { + const { tools } = (await request('tools/list')).result; + assert.deepEqual(tools[0], { + name: 'count', + title: 'Count', + description: 'Counts things.', + inputSchema: { + type: 'object', + properties: { n: { type: 'integer' } }, + additionalProperties: false, + }, + annotations: { title: 'Count', readOnlyHint: true }, + }); + const [custom] = toMcpTools(catalog, { + descriptions: { count: { description: 'Counts for this surface.' } }, + decorate: (definition, source) => ({ + _meta: { kind: source.kind, seen: definition.name }, + }), + }); + assert.equal(custom.description, 'Counts for this surface.'); + assert.deepEqual(custom._meta, { kind: 'query', seen: 'count' }); +}); + +test('tool results carry text and structured content; failures stay generic', async () => { + assert.deepEqual( + (await request('tools/call', { name: 'count', arguments: { n: 3 } })) + .result, + { + content: [{ type: 'text', text: '3 things.' }], + structuredContent: { n: 3 }, + isError: false, + }, + ); + assert.deepEqual((await request('tools/call', { name: 'busy' })).result, { + content: [{ type: 'text', text: 'Upstream is busy' }], + structuredContent: { error: 'retry_later', retry_after_seconds: 10 }, + isError: true, + }); + const invalid = ( + await request('tools/call', { name: 'count', arguments: { n: 'x' } }) + ).result; + assert.equal(invalid.isError, true); + assert.equal(invalid.structuredContent.error, 'invalid_arguments'); + const crash = (await request('tools/call', { name: 'crash' })).result; + assert.equal(crash.isError, true); + assert.equal(JSON.stringify(crash).includes('hunter2'), false); +}); + +test('the HTTP transport accepts one JSON message per POST', async () => { + const handle = createMcpHttpHandler(server); + const post = (body, headers = {}) => + handle( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }), + ); + const ok = await post({ jsonrpc: '2.0', id: 1, method: 'ping' }); + assert.equal(ok.status, 200); + assert.equal(ok.headers.get('content-type'), 'application/json'); + assert.deepEqual(await ok.json(), { jsonrpc: '2.0', id: 1, result: {} }); + assert.equal( + (await post({ jsonrpc: '2.0', method: 'notifications/initialized' })) + .status, + 202, + ); + const parse = await post('{'); + assert.equal(parse.status, 400); + assert.equal((await parse.json()).error.code, -32700); + assert.equal( + (await post({}, { 'MCP-Protocol-Version': '1999-01-01' })).status, + 400, + ); + assert.equal((await post({}, { 'Content-Type': 'text/plain' })).status, 415); + assert.equal((await post('x'.repeat(1024 * 1024 + 1))).status, 413); + const get = await handle(new Request('http://localhost/mcp')); + assert.equal(get.status, 405); + assert.equal(get.headers.get('allow'), 'POST'); +}); diff --git a/src/tools/mcp/protocol.js b/src/tools/mcp/protocol.js new file mode 100644 index 000000000..0450b66ea --- /dev/null +++ b/src/tools/mcp/protocol.js @@ -0,0 +1,167 @@ +/** + * 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 + * parsed messages in and send the returned responses out. + */ + +import { ToolError } from '../catalog.js'; + +/** Protocol revisions this server implements, newest first. */ +export const MCP_PROTOCOL_VERSIONS = Object.freeze([ + '2025-11-25', + '2025-06-18', + '2025-03-26', +]); + +const PARSE_ERROR = -32700; +const INVALID_REQUEST = -32600; +const METHOD_NOT_FOUND = -32601; +const INVALID_PARAMS = -32602; +const INTERNAL_ERROR = -32603; + +/** + * Describe catalog tools as MCP tool definitions. + * + * `descriptions` may override a tool's `title` and `description` by name. + * `decorate(definition, tool)` may return extra fields to merge into each + * definition, such as application metadata. + */ +export function toMcpTools(catalog, { descriptions = {}, decorate } = {}) { + return catalog.list().map((tool) => { + const override = descriptions[tool.name] || {}; + const title = override.title ?? tool.title; + const definition = { + name: tool.name, + title, + description: override.description ?? tool.description, + inputSchema: tool.inputSchema, + annotations: { title, ...tool.annotations }, + }; + return decorate + ? { ...definition, ...decorate(definition, tool) } + : definition; + }); +} + +/** + * Create a protocol handler. `handle(message, { signal })` resolves to the + * JSON-RPC response, or null for notifications. + */ +export function createMcpServer({ + catalog, + name, + version, + instructions, + descriptions, + decorate, +}) { + const tools = () => toMcpTools(catalog, { descriptions, decorate }); + const methods = { + initialize(params) { + const requested = params?.protocolVersion; + return { + protocolVersion: MCP_PROTOCOL_VERSIONS.includes(requested) + ? requested + : MCP_PROTOCOL_VERSIONS[0], + capabilities: { tools: { listChanged: false } }, + serverInfo: { name, version }, + ...(instructions ? { instructions } : {}), + }; + }, + ping: () => ({}), + 'tools/list': () => ({ tools: tools() }), + async 'tools/call'(params, { signal }) { + if (typeof params?.name !== 'string') + throw rpcError(INVALID_PARAMS, 'tools/call needs a tool name'); + if (!catalog.get(params.name)) + throw rpcError(INVALID_PARAMS, `Unknown tool: ${params.name}`); + try { + const result = await catalog.call(params.name, params.arguments ?? {}, { + signal, + }); + return { + content: [{ type: 'text', text: result.summary }], + structuredContent: result.data, + isError: false, + }; + } catch (error) { + if (signal?.aborted) throw error; + // Expected failures are reported to the model so it can adjust; other + // errors are not described, so internal details never leave the server. + const known = error instanceof ToolError; + return { + content: [ + { + type: 'text', + text: known ? error.message : 'The tool failed; try again later', + }, + ], + structuredContent: { + error: known ? error.code : 'unavailable', + ...(known && error.retryAfterSeconds != null + ? { retry_after_seconds: error.retryAfterSeconds } + : {}), + }, + isError: true, + }; + } + }, + }; + + return Object.freeze({ + async handle(message, { signal } = {}) { + if (!isObject(message) || message.jsonrpc !== '2.0') + return failure( + message?.id ?? null, + INVALID_REQUEST, + 'Invalid JSON-RPC message', + ); + const isRequest = message.id !== undefined; + if (typeof message.method !== 'string') + // Responses from the client are accepted and ignored: this server + // sends no requests of its own. + return isRequest && + message.result === undefined && + message.error === undefined + ? failure(message.id, INVALID_REQUEST, 'Missing method') + : null; + if (!isRequest) return null; + const method = Object.hasOwn(methods, message.method) + ? methods[message.method] + : null; + if (!method) + return failure( + message.id, + METHOD_NOT_FOUND, + `Unknown method: ${message.method}`, + ); + if (message.params !== undefined && !isObject(message.params)) + return failure(message.id, INVALID_PARAMS, 'params must be an object'); + try { + const result = await method(message.params, { signal }); + return { jsonrpc: '2.0', id: message.id, result }; + } catch (error) { + if (error?.rpcCode) + return failure(message.id, error.rpcCode, error.message); + return failure(message.id, INTERNAL_ERROR, 'Internal error'); + } + }, + }); +} + +/** A JSON-RPC parse-error response, for transports that cannot parse input. */ +export function parseErrorResponse() { + return failure(null, PARSE_ERROR, 'Parse error'); +} + +function failure(id, code, message) { + return { jsonrpc: '2.0', id, error: { code, message } }; +} + +function rpcError(code, message) { + return Object.assign(new Error(message), { rpcCode: code }); +} + +function isObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/tools/places.js b/src/tools/places.js new file mode 100644 index 000000000..7e5005ce3 --- /dev/null +++ b/src/tools/places.js @@ -0,0 +1,51 @@ +/** + * A `places` service backed by the app's `/api/geocode` route. Applications + * with a different place search can supply any object with the same + * `resolve(name, { signal })` contract. + */ + +const POINT_RADIUS_DEGREES = 0.25; + +/** Resolve place names to `{ name, bounds: { west, south, east, north } }` or null. */ +export function createGeocodePlaceService({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async resolve(name, { signal } = {}) { + const query = new URLSearchParams({ q: name }); + const response = await fetchImpl(`/api/geocode?${query}`, { signal }); + if (!response.ok) throw new Error(`Geocode HTTP ${response.status}`); + const payload = await response.json(); + return placeFromGeocodeResult(payload?.results?.[0]); + }, + }; +} + +/** Convert one geocode result to a place with bounds, or null. */ +export function placeFromGeocodeResult(result) { + const location = result?.geometry?.location; + if (!Number.isFinite(location?.lat) || !Number.isFinite(location?.lng)) + return null; + const viewport = result.geometry.viewport; + const bounds = + viewport && + [ + viewport.southwest?.lat, + viewport.southwest?.lng, + viewport.northeast?.lat, + viewport.northeast?.lng, + ].every(Number.isFinite) + ? { + west: viewport.southwest.lng, + south: viewport.southwest.lat, + east: viewport.northeast.lng, + north: viewport.northeast.lat, + } + : { + west: Math.max(-180, location.lng - POINT_RADIUS_DEGREES), + south: Math.max(-90, location.lat - POINT_RADIUS_DEGREES), + east: Math.min(180, location.lng + POINT_RADIUS_DEGREES), + north: Math.min(90, location.lat + POINT_RADIUS_DEGREES), + }; + return { name: result.formatted_address || null, bounds }; +} diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js new file mode 100644 index 000000000..15e80507a --- /dev/null +++ b/src/tools/queries/hazards.js @@ -0,0 +1,98 @@ +/** Natural hazard queries: earthquakes and active fires. */ + +import { defineTool, ToolError } from '../catalog.js'; +import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; +import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; + +export const getEarthquakes = defineTool({ + name: 'get_earthquakes', + title: 'Recent earthquakes', + description: + 'Earthquakes of magnitude 2.5 or more from the USGS feed for the last 24 ' + + 'hours, strongest first. Optionally limited to an area and a minimum magnitude.', + inputSchema: { + type: 'object', + properties: { + area: AREA_SCHEMA, + min_magnitude: { type: 'number', minimum: 2.5, maximum: 10 }, + limit: LIMIT_SCHEMA, + }, + additionalProperties: false, + }, + requires: ['earthquakes'], + async run(args, { services, signal }) { + const area = args.area + ? await resolveArea(args.area, { services, signal }) + : null; + const minimum = args.min_magnitude ?? 2.5; + const events = (await services.earthquakes.getSnapshot({ signal })) + .filter( + (event) => event.mag >= minimum && (!area || areaContains(area, event)), + ) + .sort((a, b) => b.mag - a.mag || (b.time ?? 0) - (a.time ?? 0)); + const result = capRows(events, args.limit); + const where = area ? ` in ${area.label}` : ' worldwide'; + const strongest = events[0] + ? `; strongest M${events[0].mag.toFixed(1)}${events[0].place ? ` ${events[0].place}` : ''}` + : ''; + return { + summary: `${countNoun(events.length, 'earthquake')} of M${minimum}+ in the last 24 hours${where}${strongest}.`, + data: { + ...result, + rows: result.rows.map((event) => ({ + id: event.usgsId ?? event.stableId, + magnitude: event.mag, + place: event.place, + time: isoTime(event.time), + lat: event.lat, + lon: event.lon, + depth_km: event.depthKm, + })), + }, + }; + }, +}); + +export const getActiveFires = defineTool({ + name: 'get_active_fires', + title: 'Active fires', + description: + 'Satellite fire detections from NASA FIRMS in the last 24 hours within an ' + + 'area, most intense (fire radiative power) first.', + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['fires'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + const snapshot = await services.fires.getSnapshot({ signal }); + if (snapshot.keyRequired) + throw new ToolError( + 'unavailable', + 'Fire detections need a NASA FIRMS key configured for this server', + ); + const fires = snapshot.fires + .filter((fire) => areaContains(area, fire)) + .sort((a, b) => (b.frp ?? 0) - (a.frp ?? 0)); + const result = capRows(fires, args.limit); + return { + summary: `${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours.`, + data: { + ...result, + rows: result.rows.map((fire) => ({ + lat: fire.lat, + lon: fire.lon, + frp_mw: fire.frp ?? null, + confidence: fire.confidence ?? null, + acquired: + [fire.acqDate, fire.acqTime].filter(Boolean).join(' ') || null, + satellite: fire.satellite ?? null, + day_night: fire.daynight ?? null, + })), + }, + }; + }, +}); diff --git a/src/tools/queries/queries.test.mjs b/src/tools/queries/queries.test.mjs new file mode 100644 index 000000000..b64f5e009 --- /dev/null +++ b/src/tools/queries/queries.test.mjs @@ -0,0 +1,202 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; + +const earthquakes = { + getSnapshot: async () => [ + { + stableId: 'a', + usgsId: 'us1', + lon: 121.5, + lat: 24, + depthKm: 10, + mag: 5.1, + place: 'near Hualien', + time: Date.UTC(2026, 0, 1, 1), + }, + { + stableId: 'b', + usgsId: 'us2', + lon: -120, + lat: 36, + depthKm: 5, + mag: 3.2, + place: 'Central California', + time: Date.UTC(2026, 0, 1, 2), + }, + { + stableId: 'c', + usgsId: null, + lon: 122, + lat: 23.5, + depthKm: null, + mag: 2.6, + place: null, + time: null, + }, + ], +}; +const fires = (snapshot) => ({ getSnapshot: async () => snapshot }); +const launches = (payload) => ({ getLaunches: async () => payload }); +const taiwan = { + resolve: async () => ({ + name: 'Taiwan', + bounds: { west: 119, south: 21, east: 123, north: 26 }, + }), +}; + +test('earthquakes are filtered, ordered strongest first and capped', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { earthquakes, places: taiwan }, + }); + const all = await catalog.call('get_earthquakes', {}); + assert.equal( + all.summary, + '3 earthquakes of M2.5+ in the last 24 hours worldwide; strongest M5.1 near Hualien.', + ); + assert.deepEqual( + all.data.rows.map((row) => row.id), + ['us1', 'us2', 'c'], + ); + assert.deepEqual(all.data.rows[0], { + id: 'us1', + magnitude: 5.1, + place: 'near Hualien', + time: '2026-01-01T01:00:00.000Z', + lat: 24, + lon: 121.5, + depth_km: 10, + }); + + const local = await catalog.call('get_earthquakes', { + area: { place: 'Taiwan' }, + min_magnitude: 3, + limit: 1, + }); + assert.equal( + local.summary, + '1 earthquake of M3+ in the last 24 hours in Taiwan; strongest M5.1 near Hualien.', + ); + assert.deepEqual( + { + total: local.data.total, + returned: local.data.returned, + truncated: local.data.truncated, + }, + { total: 1, returned: 1, truncated: false }, + ); + + const capped = await catalog.call('get_earthquakes', { limit: 2 }); + assert.equal(capped.data.truncated, true); + assert.equal(capped.data.total, 3); +}); + +test('fires need an area and report a missing key as unavailable', async () => { + const snapshot = { + fires: [ + { + lat: 24, + lon: 121, + frp: 12, + confidence: 'h', + acqDate: '2026-01-01', + acqTime: '0130', + satellite: 'N', + daynight: 'N', + }, + { lat: 25, lon: 122, frp: 40 }, + { lat: -33, lon: 151, frp: 99 }, + ], + }; + const catalog = composeCatalog({ + tools: coreTools, + services: { fires: fires(snapshot), places: taiwan }, + }); + await assert.rejects(catalog.call('get_active_fires', {}), /missing area/); + const result = await catalog.call('get_active_fires', { + area: { place: 'Taiwan' }, + }); + assert.equal( + result.summary, + '2 fire detections in Taiwan in the last 24 hours.', + ); + assert.deepEqual( + result.data.rows.map((row) => row.frp_mw), + [40, 12], + ); + assert.equal(result.data.rows[1].acquired, '2026-01-01 0130'); + + const keyless = composeCatalog({ + tools: coreTools, + services: { fires: fires({ keyRequired: true }) }, + }); + await assert.rejects( + keyless.call('get_active_fires', { area: { bbox: [0, 0, 1, 1] } }), + (error) => + error.code === 'unavailable' && /NASA FIRMS key/.test(error.message), + ); +}); + +test('recent launches are newest first with missing fields as null', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + launches: launches({ + results: [ + { + id: 'old', + name: 'Falcon 9 | Starlink', + net: '2026-01-01T00:00:00Z', + status: { name: 'Launch Successful' }, + }, + { + id: 'new', + name: 'Electron | Mission', + net: '2026-01-05T00:00:00Z', + launch_service_provider: { name: 'Rocket Lab' }, + rocket: { configuration: { full_name: 'Electron' } }, + pad: { name: 'LC-1A', location: { name: 'Mahia' } }, + mission: { name: 'Mission', orbit: { name: 'Low Earth Orbit' } }, + }, + { id: 'unnamed' }, + ], + }), + }, + }); + const result = await catalog.call('get_recent_launches', {}); + assert.equal( + result.summary, + '2 launches in the last 30 days; latest Electron | Mission.', + ); + assert.deepEqual(result.data.rows[0], { + id: 'new', + name: 'Electron | Mission', + time: '2026-01-05T00:00:00Z', + status: null, + provider: 'Rocket Lab', + rocket: 'Electron', + pad: 'LC-1A', + location: 'Mahia', + mission: 'Mission', + orbit: 'Low Earth Orbit', + }); + const broken = composeCatalog({ + tools: coreTools, + services: { launches: launches({}) }, + }); + await assert.rejects( + broken.call('get_recent_launches', {}), + (error) => error.code === 'malformed', + ); +}); + +test('every core tool is a read-only query with a unique name', () => { + const names = coreTools.map((tool) => tool.name); + assert.equal(new Set(names).size, names.length); + for (const tool of coreTools) { + assert.equal(tool.kind, 'query', tool.name); + assert.equal(tool.annotations.readOnlyHint, true, tool.name); + assert.ok(tool.requires.length > 0, tool.name); + } +}); diff --git a/src/tools/queries/space.js b/src/tools/queries/space.js new file mode 100644 index 000000000..371d8ecbd --- /dev/null +++ b/src/tools/queries/space.js @@ -0,0 +1,49 @@ +/** Space queries: launches. */ + +import { defineTool, ToolError } from '../catalog.js'; +import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js'; + +const text = (value) => (typeof value === 'string' && value ? value : null); + +export const getRecentLaunches = defineTool({ + name: 'get_recent_launches', + title: 'Recent launches', + description: + 'Orbital launches from Launch Library 2 over the last 30 days, newest ' + + 'first, with provider, rocket, pad, mission and outcome.', + inputSchema: { + type: 'object', + properties: { limit: LIMIT_SCHEMA }, + additionalProperties: false, + }, + requires: ['launches'], + async run(args, { services, signal }) { + const payload = await services.launches.getLaunches({ signal }); + const launches = Array.isArray(payload) ? payload : payload.results; + if (!Array.isArray(launches)) + throw new ToolError('malformed', 'The launch feed returned no launches'); + const rows = launches + .map((launch) => ({ + id: text(launch?.id), + name: text(launch?.name), + time: text(launch?.net), + status: text(launch?.status?.name), + provider: text(launch?.launch_service_provider?.name), + rocket: text( + launch?.rocket?.configuration?.full_name ?? + launch?.rocket?.configuration?.name, + ), + pad: text(launch?.pad?.name), + location: text(launch?.pad?.location?.name), + mission: text(launch?.mission?.name), + orbit: text(launch?.mission?.orbit?.name), + })) + .filter((launch) => launch.name) + .sort((a, b) => String(b.time).localeCompare(String(a.time))); + const result = capRows(rows, args.limit); + return { + summary: `${countNoun(rows.length, 'launch', 'launches')} in the last 30 days${rows[0] ? `; latest ${rows[0].name}` : ''}.`, + data: result, + }; + }, +}); diff --git a/src/tools/results.js b/src/tools/results.js new file mode 100644 index 000000000..331e56941 --- /dev/null +++ b/src/tools/results.js @@ -0,0 +1,33 @@ +/** Result sizing shared by tools that return lists. */ + +export const DEFAULT_LIMIT = 25; +export const MAX_LIMIT = 200; + +/** JSON Schema for the `limit` argument. */ +export const LIMIT_SCHEMA = Object.freeze({ + type: 'integer', + minimum: 1, + maximum: MAX_LIMIT, + description: `Most rows to return (default ${DEFAULT_LIMIT}, at most ${MAX_LIMIT}).`, +}); + +/** Keep the first `limit` rows and report how many matched in total. */ +export function capRows(rows, limit = DEFAULT_LIMIT) { + const kept = rows.slice(0, Math.min(limit, MAX_LIMIT)); + return { + total: rows.length, + returned: kept.length, + truncated: kept.length < rows.length, + rows: kept, + }; +} + +/** Format an epoch millisecond time as ISO 8601, or null. */ +export function isoTime(ms) { + return Number.isFinite(ms) ? new Date(ms).toISOString() : null; +} + +/** "1 earthquake", "3 earthquakes". */ +export function countNoun(count, singular, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; +} diff --git a/src/tools/schema.js b/src/tools/schema.js new file mode 100644 index 000000000..02fc44396 --- /dev/null +++ b/src/tools/schema.js @@ -0,0 +1,125 @@ +/** + * Validates tool arguments against the JSON Schema subset tool definitions use: + * object, array, string, number, integer and boolean types; required, + * properties, additionalProperties, items, enum, minimum, maximum, minLength, + * maxLength, minItems and maxItems. Unsupported keywords are rejected when a + * tool is defined, so a schema can never promise checks that are not made. + */ + +const KEYWORDS = new Set([ + 'type', + 'description', + 'title', + 'properties', + 'required', + 'additionalProperties', + 'items', + 'enum', + 'minimum', + 'maximum', + 'minLength', + 'maxLength', + 'minItems', + 'maxItems', + 'default', +]); +const TYPES = new Set([ + 'object', + 'array', + 'string', + 'number', + 'integer', + 'boolean', +]); + +/** Throw when a schema uses a keyword or type this validator does not check. */ +export function assertSupportedSchema(schema, path = 'schema') { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) + throw new TypeError(`${path} must be an object`); + for (const key of Object.keys(schema)) { + if (!KEYWORDS.has(key)) + throw new TypeError(`${path} uses unsupported keyword ${key}`); + } + if (!TYPES.has(schema.type)) + throw new TypeError(`${path} has unsupported type ${schema.type}`); + for (const [name, child] of Object.entries(schema.properties || {})) + assertSupportedSchema(child, `${path}.${name}`); + if (schema.items) assertSupportedSchema(schema.items, `${path}[]`); +} + +/** + * Return a list of problems with a value, empty when it conforms. Messages + * name the argument path so a model can correct its call. + */ +export function validateValue(schema, value, path = 'arguments') { + const problems = []; + const fail = (message) => problems.push(`${path} ${message}`); + switch (schema.type) { + case 'object': { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail('must be an object'); + break; + } + for (const name of schema.required || []) { + if (value[name] === undefined) fail(`is missing ${name}`); + } + const properties = schema.properties || {}; + for (const [name, child] of Object.entries(value)) { + if (properties[name]) + problems.push( + ...validateValue(properties[name], child, `${path}.${name}`), + ); + else if (schema.additionalProperties === false) + fail(`has unknown property ${name}`); + } + break; + } + case 'array': + if (!Array.isArray(value)) { + fail('must be an array'); + break; + } + if (schema.minItems != null && value.length < schema.minItems) + fail(`must have at least ${schema.minItems} items`); + if (schema.maxItems != null && value.length > schema.maxItems) + fail(`must have at most ${schema.maxItems} items`); + if (schema.items) + value.forEach((item, index) => + problems.push( + ...validateValue(schema.items, item, `${path}[${index}]`), + ), + ); + break; + case 'string': + if (typeof value !== 'string') { + fail('must be a string'); + break; + } + if (schema.minLength != null && value.length < schema.minLength) + fail(`must be at least ${schema.minLength} characters`); + if (schema.maxLength != null && value.length > schema.maxLength) + fail(`must be at most ${schema.maxLength} characters`); + break; + case 'number': + case 'integer': + if (typeof value !== 'number' || !Number.isFinite(value)) { + fail('must be a number'); + break; + } + if (schema.type === 'integer' && !Number.isInteger(value)) + fail('must be an integer'); + if (schema.minimum != null && value < schema.minimum) + fail(`must be at least ${schema.minimum}`); + if (schema.maximum != null && value > schema.maximum) + fail(`must be at most ${schema.maximum}`); + break; + case 'boolean': + if (typeof value !== 'boolean') fail('must be true or false'); + break; + } + if (schema.enum && !schema.enum.includes(value)) + fail( + `must be one of ${schema.enum.map((item) => JSON.stringify(item)).join(', ')}`, + ); + return problems; +} From 989594263323da1210f7a0d12721cfc492eb68da Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 12:05:41 -0700 Subject: [PATCH 002/111] refactor: move TLE text parsing into a portable source module The satellites layer parsed catalog text inside its Cesium-backed orbit owner. Move the parser unchanged to src/sources/tle.js, add a catalog number helper, and have the layer delegate to it so code outside the layer can read TLE text without loading rendering. --- scripts/package-boundaries.json | 3 +++ src/layers/satellites/orbits.js | 20 ++------------------ src/sources/tle.js | 27 +++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 18 deletions(-) create mode 100644 src/sources/tle.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 636004286..2bb2e1121 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -922,6 +922,7 @@ "src/data/issPass.js", "src/data/satelliteClass.js", "src/data/satellitePass.js", + "src/sources/tle.js", "src/layers/satellites/catalog.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", @@ -1659,6 +1660,7 @@ "src/layers/satellites/labels.js", "src/layers/satellites/lifecycle.js", "src/layers/satellites/orbits.js", + "src/sources/tle.js", "src/layers/satellites/records.js", "src/layers/satellites/policy.js", "src/layers/satellites/rendering.js", @@ -2323,6 +2325,7 @@ "src/layers/satellites/labels.js", "src/layers/satellites/lifecycle.js", "src/layers/satellites/orbits.js", + "src/sources/tle.js", "src/layers/satellites/records.js", "src/layers/satellites/policy.js", "src/layers/satellites/rendering.js", diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js index ed26a8883..32bb57cf9 100644 --- a/src/layers/satellites/orbits.js +++ b/src/layers/satellites/orbits.js @@ -8,6 +8,7 @@ import { twoline2satrec, } from 'satellite.js'; import { findNextSatellitePass } from '../../data/satellitePass.js'; +import { parseTleText } from '../../sources/tle.js'; import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js'; export function createOrbits({ state: layerState, services, parts, source }) { @@ -40,24 +41,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { /** * Parse TLE text into array of { name, line1, line2 } objects. */ - - function parseTLE(text) { - const lines = text - .trim() - .split('\n') - .map((l) => l.trim()) - .filter((l) => l.length > 0); - const result = []; - for (let i = 0; i < lines.length - 2; i += 3) { - const name = lines[i]; - const line1 = lines[i + 1]; - const line2 = lines[i + 2]; - if (line1.startsWith('1 ') && line2.startsWith('2 ')) { - result.push({ name, line1, line2 }); - } - } - return result; - } + const parseTLE = parseTleText; /** * Propagate satellite position at a given JS Date. diff --git a/src/sources/tle.js b/src/sources/tle.js new file mode 100644 index 000000000..423651e6b --- /dev/null +++ b/src/sources/tle.js @@ -0,0 +1,27 @@ +/** + * Parse three-line TLE catalog text into `{ name, line1, line2 }` entries. + * Blocks whose second and third lines are not TLE lines 1 and 2 are skipped. + */ +export function parseTleText(text) { + const lines = String(text) + .trim() + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const result = []; + for (let i = 0; i < lines.length - 2; i += 3) { + const name = lines[i]; + const line1 = lines[i + 1]; + const line2 = lines[i + 2]; + if (line1.startsWith('1 ') && line2.startsWith('2 ')) { + result.push({ name, line1, line2 }); + } + } + return result; +} + +/** The NORAD catalog number from TLE line 1, or null. */ +export function tleCatalogNumber(line1) { + const number = Number.parseInt(String(line1).slice(2, 7), 10); + return Number.isInteger(number) ? number : null; +} From 22c3fe40b0dd5d9488fe566fb62b418a6c41bacc Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 12:05:50 -0700 Subject: [PATCH 003/111] feat: add aviation and satellite tools Add aircraft_in_area, find_aircraft, get_aircraft_track and get_aircraft_info over the live aircraft sources, with an optional military feed, and next_satellite_pass and satellites_overhead over the satellite catalog and the existing pass predictor. Source failures from the live contract now become tool errors with the matching code, so a rate limit reaches clients as retry_later with a wait time. Tool schemas may use pattern. --- CHANGELOG.md | 4 +- docs/CURRENT-STATE.md | 5 +- docs/TOOLS.md | 6 + scripts/format-scope.json | 4 +- scripts/package-boundaries.json | 7 +- server/mcp/services.js | 8 + src/tools/area.js | 14 ++ src/tools/catalog.js | 31 ++- src/tools/catalog.test.mjs | 43 +++- src/tools/index.js | 26 ++- src/tools/localServer.test.mjs | 12 +- src/tools/queries/aviation.js | 306 ++++++++++++++++++++++++++++ src/tools/queries/aviation.test.mjs | 276 +++++++++++++++++++++++++ src/tools/queries/space.js | 222 +++++++++++++++++++- src/tools/queries/space.test.mjs | 154 ++++++++++++++ src/tools/schema.js | 9 +- 16 files changed, 1111 insertions(+), 16 deletions(-) create mode 100644 src/tools/queries/aviation.js create mode 100644 src/tools/queries/aviation.test.mjs create mode 100644 src/tools/queries/space.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index e8915b417..590d19872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ # Changelog - Add tools for language-model clients and a local MCP server. `npm run mcp` - serves earthquake, active-fire and recent-launch queries over stdio to - clients such as Claude Code, reading from a running app. Tools are defined + serves earthquake, active-fire, launch, aircraft and satellite queries over + stdio to clients such as Claude Code, reading from a running app. Tools are defined once in `gods-eye-view/tools`, reuse the layers' source factories, and are exposed through the protocol adapter in `gods-eye-view/tools/mcp`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bd68e4879..706298590 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6,8 +6,9 @@ and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client, reading from a running app's `/api` routes (default `http://localhost:4173`). -The first queries are `get_earthquakes`, `get_active_fires` and -`get_recent_launches`. Tools reuse the layers' portable source factories, take a +Queries cover earthquakes, active fires, recent launches, aircraft (in an +area, by identifier, tracks, and type and route lookups) and satellites (next +pass over a point, and those overhead now). Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. The app does not import the tools, so its runtime behavior is unchanged. See [tools and the MCP server](TOOLS.md). diff --git a/docs/TOOLS.md b/docs/TOOLS.md index f955f32ba..747d19d2e 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -87,3 +87,9 @@ public feeds the sources already use. | `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first | | `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first | | `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first | +| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed | +| `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration | +| `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points | +| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb | +| `next_satellite_pass` | `satellites` | Next pass over a point (default the ISS), with naked-eye visibility | +| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a point now, highest first | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b2358d5ca..9d9e71c17 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -869,5 +869,7 @@ "src/tools/area.test.mjs", "src/tools/queries/queries.test.mjs", "src/tools/mcp/mcp.test.mjs", - "src/tools/localServer.test.mjs" + "src/tools/localServer.test.mjs", + "src/tools/queries/aviation.test.mjs", + "src/tools/queries/space.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 2bb2e1121..503fb0255 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3086,9 +3086,12 @@ "src/tools/results.js", "src/tools/places.js", "src/tools/queries/hazards.js", - "src/tools/queries/space.js" + "src/tools/queries/space.js", + "src/tools/queries/aviation.js", + "src/sources/tle.js", + "src/data/satellitePass.js" ], - "external": [] + "external": ["satellite.js"] }, "tools-mcp": { "exports": ["./tools/mcp"], diff --git a/server/mcp/services.js b/server/mcp/services.js index 07a492692..b828ef1ef 100644 --- a/server/mcp/services.js +++ b/server/mcp/services.js @@ -6,6 +6,11 @@ import { createUsgsEarthquakeSource } from '../../src/layers/earthquakes/source.js'; import { createFirmsSource } from '../../src/layers/firms/source.js'; import { createLaunchSource } from '../../src/layers/launches/source.js'; +import { createSatelliteSource } from '../../src/layers/satellites/source.js'; +import { + createAdsbLolSource, + createOpenSkySource, +} from '../../src/sources/live/standalone.js'; import { createGeocodePlaceService } from '../../src/tools/places.js'; export const DEFAULT_API_BASE = 'http://localhost:4173'; @@ -34,6 +39,9 @@ export function createLocalToolServices(options = {}) { earthquakes: createUsgsEarthquakeSource({ fetchImpl }), fires: createFirmsSource({ fetchImpl }), launches: createLaunchSource({ fetchImpl }), + aircraft: createOpenSkySource({ fetchImpl }), + military: createAdsbLolSource({ fetchImpl }), + satellites: createSatelliteSource({ fetchImpl }), places: createGeocodePlaceService({ fetchImpl }), }; } diff --git a/src/tools/area.js b/src/tools/area.js index 35b27315f..4f0f5c55a 100644 --- a/src/tools/area.js +++ b/src/tools/area.js @@ -104,6 +104,20 @@ export function distanceKm(a, b) { return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h))); } +/** The center of a resolved area, handling boxes across the antimeridian. */ +export function areaCenter(area) { + if (area.center) return { lat: area.center.lat, lon: area.center.lon }; + const width = + area.west <= area.east + ? area.east - area.west + : area.east + 360 - area.west; + const lon = area.west + width / 2; + return { + lat: (area.south + area.north) / 2, + lon: lon > 180 ? lon - 360 : lon, + }; +} + /** Whether a point lies inside a resolved area, including radius areas. */ export function areaContains(area, point) { if (!Number.isFinite(point?.lat) || !Number.isFinite(point?.lon)) diff --git a/src/tools/catalog.js b/src/tools/catalog.js index a6e3db530..e289665b6 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -33,6 +33,30 @@ export class ToolError extends Error { } } +// Live sources report failures with these codes (src/sources/live/contract.js). +const SOURCE_ERROR_CODES = { + limited: 'retry_later', + denied: 'unavailable', + unavailable: 'unavailable', + malformed: 'malformed', + unsupported: 'unsupported', +}; + +/** Translate a source failure into a tool error; other errors pass through. */ +export function fromSourceError(error) { + const code = + error?.name === 'LiveSourceError' && + Object.hasOwn(SOURCE_ERROR_CODES, error.code) + ? SOURCE_ERROR_CODES[error.code] + : null; + if (!code) return error; + const retryAfterSeconds = + code === 'retry_later' && Number.isFinite(error.retryAfterMs) + ? Math.ceil(error.retryAfterMs / 1000) + : null; + return new ToolError(code, error.message, { retryAfterSeconds }); +} + /** * Validate and freeze a tool definition. * @@ -114,7 +138,12 @@ export function composeCatalog({ const problems = validateValue(tool.inputSchema, args); if (problems.length) throw new ToolError('invalid_arguments', problems.join('; ')); - const result = await tool.run(args, { services, signal }); + let result; + try { + result = await tool.run(args, { services, signal }); + } catch (error) { + throw fromSourceError(error); + } if (!result || typeof result.summary !== 'string' || !result.data) throw new TypeError(`${tool.name} returned no summary or data`); return result; diff --git a/src/tools/catalog.test.mjs b/src/tools/catalog.test.mjs index 997bf002f..4e2a5d105 100644 --- a/src/tools/catalog.test.mjs +++ b/src/tools/catalog.test.mjs @@ -31,10 +31,10 @@ test('definitions are validated, frozen and read-only by default', () => { echo('x', { inputSchema: { type: 'object', - properties: { a: { type: 'string', pattern: '.' } }, + properties: { a: { type: 'string', format: 'email' } }, }, }), - /unsupported keyword pattern/, + /unsupported keyword format/, ); assert.throws( () => echo('x', { inputSchema: { type: 'string' } }), @@ -129,6 +129,45 @@ test('tool errors only use known codes', () => { ); }); +test('only live source errors are translated', async () => { + const { fromSourceError } = await import('./catalog.js'); + const plain = new Error('x'); + assert.equal(fromSourceError(plain), plain); + const denied = Object.assign(new Error('refused'), { + name: 'LiveSourceError', + code: 'denied', + }); + const translated = fromSourceError(denied); + assert.equal(translated.code, 'unavailable'); + assert.equal(translated.retryAfterSeconds, null); + const odd = Object.assign(new Error('?'), { + name: 'LiveSourceError', + code: 'toString', + }); + assert.equal(fromSourceError(odd), odd); +}); + +test('patterns are validated and must compile', () => { + const schema = { + type: 'object', + properties: { id: { type: 'string', pattern: '^[a-f]{2}$' } }, + }; + assert.deepEqual(validateValue(schema, { id: 'ab' }), []); + assert.deepEqual(validateValue(schema, { id: 'xy' }), [ + 'arguments.id must match ^[a-f]{2}$', + ]); + assert.throws( + () => + echo('x', { + inputSchema: { + type: 'object', + properties: { id: { type: 'string', pattern: '(' } }, + }, + }), + SyntaxError, + ); +}); + test('the validator checks every supported keyword', () => { const schema = { type: 'object', diff --git a/src/tools/index.js b/src/tools/index.js index c8502261b..84d2aaa1e 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -3,8 +3,18 @@ * surface that exposes them. See docs/TOOLS.md. */ +import { + aircraftInArea, + findAircraft, + getAircraftInfo, + getAircraftTrack, +} from './queries/aviation.js'; import { getActiveFires, getEarthquakes } from './queries/hazards.js'; -import { getRecentLaunches } from './queries/space.js'; +import { + getRecentLaunches, + nextSatellitePass, + satellitesOverhead, +} from './queries/space.js'; export { defineTool, @@ -12,7 +22,13 @@ export { ToolError, TOOL_ERROR_CODES, } from './catalog.js'; -export { AREA_SCHEMA, resolveArea, areaContains, distanceKm } from './area.js'; +export { + AREA_SCHEMA, + resolveArea, + areaCenter, + areaContains, + distanceKm, +} from './area.js'; export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js'; export { createGeocodePlaceService, placeFromGeocodeResult } from './places.js'; @@ -21,4 +37,10 @@ export const coreTools = Object.freeze([ getEarthquakes, getActiveFires, getRecentLaunches, + aircraftInArea, + findAircraft, + getAircraftTrack, + getAircraftInfo, + nextSatellitePass, + satellitesOverhead, ]); diff --git a/src/tools/localServer.test.mjs b/src/tools/localServer.test.mjs index 7b193aac4..42425ea39 100644 --- a/src/tools/localServer.test.mjs +++ b/src/tools/localServer.test.mjs @@ -68,7 +68,17 @@ test('the stdio server answers newline-delimited requests using only its data so assert.equal(byId.get(null).error.code, -32700); assert.deepEqual( byId.get(2).result.tools.map((tool) => tool.name), - ['get_earthquakes', 'get_active_fires', 'get_recent_launches'], + [ + 'get_earthquakes', + 'get_active_fires', + 'get_recent_launches', + 'aircraft_in_area', + 'find_aircraft', + 'get_aircraft_track', + 'get_aircraft_info', + 'next_satellite_pass', + 'satellites_overhead', + ], ); assert.equal( byId.get(3).result.content[0].text, diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js new file mode 100644 index 000000000..34f3d26de --- /dev/null +++ b/src/tools/queries/aviation.js @@ -0,0 +1,306 @@ +/** Aviation queries over the live aircraft sources (src/sources/live). */ + +import { defineTool, ToolError } from '../catalog.js'; +import { + AREA_SCHEMA, + areaCenter, + areaContains, + distanceKm, + resolveArea, +} from '../area.js'; +import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; + +const ICAO24_SCHEMA = Object.freeze({ + type: 'string', + pattern: '^[0-9a-fA-F]{6}$', + description: 'ICAO 24-bit address as six hex characters, such as "a1b2c3".', +}); +const CALLSIGN_SCHEMA = Object.freeze({ + type: 'string', + pattern: '^[A-Za-z0-9]{2,8}$', + description: 'Flight callsign, such as "UAL123".', +}); +const MILITARY_SCHEMA = Object.freeze({ + type: 'boolean', + description: 'Use the military aircraft feed instead of the general feed.', +}); +const MAX_TRACK_POINTS = 200; + +const round = (value, digits) => + Number.isFinite(value) ? Number(value.toFixed(digits)) : null; + +/** The common row for an aircraft record from any live aircraft source. */ +function aircraftRow(record, from) { + const point = { lat: record.latitude, lon: record.longitude }; + return { + id: record.id, + callsign: record.callsign || null, + lat: round(record.latitude, 5), + lon: round(record.longitude, 5), + altitude_m: round(record.baroAltitudeM ?? record.ellipsoidAltitudeM, 0), + on_ground: record.onGround === true, + speed_mps: round(record.speedMps, 1), + course_deg: round(record.courseDeg, 0), + vertical_rate_mps: round(record.verticalRateMps, 1), + type_code: record.typeCode || null, + registration: record.registration || null, + operator: record.operator || null, + origin_country: record.originCountry || null, + last_contact: isoTime(record.contactTimeMs ?? record.positionTimeMs), + ...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}), + }; +} + +function snapshotInfo(snapshot) { + return { + source: snapshot.source ?? null, + coverage: snapshot.coverage ?? null, + observed_at: isoTime(snapshot.observedAtMs), + freshness: snapshot.freshness ?? 'unknown', + }; +} + +function feedFor(services, military) { + if (!military) return services.aircraft; + if (!services.military) + throw new ToolError( + 'unsupported', + 'The military aircraft feed is not available here', + ); + return services.military; +} + +export const aircraftInArea = defineTool({ + name: 'aircraft_in_area', + title: 'Aircraft in an area', + description: + 'Aircraft currently reported inside an area, nearest the center first. ' + + 'Optionally military aircraft only, airborne only, or within an altitude band.', + inputSchema: { + type: 'object', + properties: { + area: AREA_SCHEMA, + military: MILITARY_SCHEMA, + airborne_only: { type: 'boolean' }, + min_altitude_m: { type: 'number', minimum: -500, maximum: 30000 }, + max_altitude_m: { type: 'number', minimum: -500, maximum: 30000 }, + limit: LIMIT_SCHEMA, + }, + required: ['area'], + additionalProperties: false, + }, + requires: ['aircraft'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + const center = areaCenter(area); + const snapshot = await feedFor(services, args.military).getSnapshot( + { latitude: center.lat, longitude: center.lon }, + { signal }, + ); + const altitude = (record) => + record.baroAltitudeM ?? record.ellipsoidAltitudeM; + const rows = snapshot.records + .filter( + (record) => + areaContains(area, { lat: record.latitude, lon: record.longitude }) && + !(args.airborne_only && record.onGround) && + (args.min_altitude_m == null || + altitude(record) >= args.min_altitude_m) && + (args.max_altitude_m == null || + altitude(record) <= args.max_altitude_m), + ) + .map((record) => aircraftRow(record, center)) + .sort((a, b) => a.distance_km - b.distance_km); + const kind = args.military ? 'military aircraft' : 'aircraft'; + return { + summary: + `${rows.length} ${kind} in ${area.label}` + + (snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'), + data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) }, + }; + }, +}); + +export const findAircraft = defineTool({ + name: 'find_aircraft', + title: 'Find an aircraft', + description: + 'Find aircraft currently reported anywhere by callsign, ICAO 24-bit ' + + 'address or registration. Registrations are known only for aircraft in ' + + 'the military feed.', + inputSchema: { + type: 'object', + properties: { + callsign: CALLSIGN_SCHEMA, + icao24: ICAO24_SCHEMA, + registration: { type: 'string', minLength: 2, maxLength: 12 }, + limit: LIMIT_SCHEMA, + }, + additionalProperties: false, + }, + requires: ['aircraft'], + async run(args, { services, signal }) { + const given = ['callsign', 'icao24', 'registration'].filter( + (key) => args[key] != null, + ); + if (given.length !== 1) + throw new ToolError( + 'invalid_arguments', + 'Give exactly one of callsign, icao24 or registration', + ); + const [key] = given; + const wanted = args[key].trim().toUpperCase(); + const field = { + callsign: 'callsign', + icao24: 'id', + registration: 'registration', + }[key]; + const feeds = [services.aircraft, services.military].filter(Boolean); + const snapshots = await Promise.all( + feeds.map((feed) => feed.getSnapshot({}, { signal })), + ); + const seen = new Set(); + const rows = snapshots + .flatMap((snapshot) => snapshot.records) + .filter( + (record) => + String(record[field] || '') + .trim() + .toUpperCase() === wanted, + ) + .filter((record) => !seen.has(record.id) && seen.add(record.id)) + .map((record) => aircraftRow(record)); + return { + summary: rows.length + ? `Found ${countNoun(rows.length, 'aircraft', 'aircraft')} with ${key} ${wanted}.` + : `No aircraft with ${key} ${wanted} is currently reported.`, + data: capRows(rows, args.limit), + }; + }, +}); + +export const getAircraftTrack = defineTool({ + name: 'get_aircraft_track', + title: 'Aircraft track', + description: + 'Recent positions of one aircraft by ICAO 24-bit address, oldest first. ' + + `Long tracks are evenly thinned to at most ${MAX_TRACK_POINTS} points.`, + inputSchema: { + type: 'object', + properties: { icao24: ICAO24_SCHEMA, military: MILITARY_SCHEMA }, + required: ['icao24'], + additionalProperties: false, + }, + requires: ['aircraft'], + async run(args, { services, signal }) { + const icao24 = args.icao24.toLowerCase(); + const track = await feedFor(services, args.military).getTrack(icao24, { + signal, + }); + const points = [...track.records].sort( + (a, b) => a.observedAtMs - b.observedAtMs, + ); + const kept = thin(points, MAX_TRACK_POINTS); + const first = points[0]; + const last = points.at(-1); + return { + summary: points.length + ? `${countNoun(points.length, 'position')} for ${icao24} from ${isoTime(first.observedAtMs)} to ${isoTime(last.observedAtMs)}.` + : `No recent track is available for ${icao24}.`, + data: { + icao24, + total: points.length, + returned: kept.length, + thinned: kept.length < points.length, + points: kept.map((point) => ({ + time: isoTime(point.observedAtMs), + lat: round(point.latitude, 5), + lon: round(point.longitude, 5), + altitude_m: round(point.baroAltitudeM, 0), + on_ground: point.onGround === true, + })), + }, + }; + }, +}); + +/** Keep the first and last items and evenly spaced items between them. */ +function thin(items, max) { + if (items.length <= max) return items; + const step = (items.length - 1) / (max - 1); + return Array.from( + { length: max }, + (_, index) => items[Math.round(index * step)], + ); +} + +export const getAircraftInfo = defineTool({ + name: 'get_aircraft_info', + title: 'Aircraft type and route', + description: + 'Look up an aircraft type and registration by ICAO 24-bit address, and ' + + 'a flight route by callsign, from adsbdb. Give either or both.', + inputSchema: { + type: 'object', + properties: { icao24: ICAO24_SCHEMA, callsign: CALLSIGN_SCHEMA }, + additionalProperties: false, + }, + requires: ['aircraft'], + async run(args, { services, signal }) { + if (args.icao24 == null && args.callsign == null) + throw new ToolError('invalid_arguments', 'Give icao24, callsign or both'); + const lookup = (kind, id) => + id == null + ? null + : services.aircraft.getEnrichment({ kind, id }, { signal }); + const icao24 = args.icao24?.toLowerCase(); + const callsign = args.callsign?.toUpperCase(); + const [type, route] = await Promise.all([ + lookup('type', icao24), + lookup('route', callsign), + ]); + const aircraft = type?.found + ? { + icao24, + type_code: type.typeCode ?? null, + type_name: type.typeName ?? null, + registration: type.registration ?? null, + } + : null; + const airport = (value) => + value + ? { + code: value.code || null, + name: value.name || null, + lat: value.lat ?? null, + lon: value.lon ?? null, + } + : null; + const flight = route?.found + ? { + callsign, + airline: route.airline ?? null, + origin: airport(route.origin), + destination: airport(route.destination), + } + : null; + const parts = []; + if (icao24) + parts.push( + aircraft + ? `${icao24} is ${aircraft.type_name || aircraft.type_code || 'an aircraft of unknown type'}${aircraft.registration ? ` (${aircraft.registration})` : ''}` + : `no type is known for ${icao24}`, + ); + if (callsign) + parts.push( + flight + ? `${callsign} flies ${flight.origin?.code || flight.origin?.name} to ${flight.destination?.code || flight.destination?.name}${flight.airline ? ` (${flight.airline})` : ''}` + : `no route is known for ${callsign}`, + ); + const summary = parts.join('; '); + return { + summary: `${summary.charAt(0).toUpperCase()}${summary.slice(1)}.`, + data: { aircraft, route: flight }, + }; + }, +}); diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs new file mode 100644 index 000000000..671226e0d --- /dev/null +++ b/src/tools/queries/aviation.test.mjs @@ -0,0 +1,276 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; +import { LiveSourceError } from '../../sources/live/contract.js'; + +const record = (id, latitude, longitude, extra = {}) => ({ + id, + reference: id, + latitude, + longitude, + callsign: null, + originCountry: null, + positionTimeMs: Date.UTC(2026, 0, 1), + contactTimeMs: Date.UTC(2026, 0, 1, 0, 0, 5), + baroAltitudeM: 10000, + ellipsoidAltitudeM: null, + onGround: false, + speedMps: 230.04, + courseDeg: 90.4, + verticalRateMps: 0, + category: null, + typeCode: null, + registration: null, + operator: null, + ...extra, +}); +const snapshot = (records, extra = {}) => ({ + records, + complete: true, + source: 'Test feed', + coverage: 'test coverage', + observedAtMs: Date.UTC(2026, 0, 1), + freshness: 'current', + ...extra, +}); +const feed = (records, { track = [], enrichment = {}, ...extra } = {}) => { + const calls = []; + return { + calls, + async getSnapshot(query) { + calls.push(query); + return snapshot(records, extra); + }, + async getTrack(reference) { + calls.push(reference); + return { records: track, complete: false }; + }, + async getEnrichment({ kind, id }) { + calls.push(`${kind}:${id}`); + return enrichment[`${kind}:${id}`] ?? { found: false }; + }, + }; +}; +const civil = [ + record('aaa111', 37.7, -122.4, { + callsign: 'UAL123', + originCountry: 'United States', + }), + record('bbb222', 37.58, -122.28, { onGround: true, baroAltitudeM: 0 }), + record('ccc333', 51.5, -0.1, { callsign: 'BAW1' }), +]; +const military = [ + record('ae0001', 37.6, -122.3, { + callsign: 'RCH123', + registration: '07-7175', + typeCode: 'C17', + }), +]; +const bay = { lat: 37.6, lon: -122.3, radius_km: 60 }; + +test('aircraft in an area are filtered, nearest first, with source details', async () => { + const aircraft = feed(civil); + const catalog = composeCatalog({ tools: coreTools, services: { aircraft } }); + const result = await catalog.call('aircraft_in_area', { area: bay }); + assert.equal(result.summary, '2 aircraft in 60 km around 37.600, -122.300.'); + assert.deepEqual( + result.data.rows.map((row) => row.id), + ['bbb222', 'aaa111'], + ); + assert.deepEqual(aircraft.calls[0], { latitude: 37.6, longitude: -122.3 }); + assert.deepEqual(result.data.rows[1], { + id: 'aaa111', + callsign: 'UAL123', + lat: 37.7, + lon: -122.4, + altitude_m: 10000, + on_ground: false, + speed_mps: 230, + course_deg: 90, + vertical_rate_mps: 0, + type_code: null, + registration: null, + operator: null, + origin_country: 'United States', + last_contact: '2026-01-01T00:00:05.000Z', + distance_km: 14.2, + }); + assert.equal(result.data.source, 'Test feed'); + assert.equal(result.data.observed_at, '2026-01-01T00:00:00.000Z'); + + const airborne = await catalog.call('aircraft_in_area', { + area: bay, + airborne_only: true, + }); + assert.deepEqual( + airborne.data.rows.map((row) => row.id), + ['aaa111'], + ); + const low = await catalog.call('aircraft_in_area', { + area: bay, + max_altitude_m: 100, + }); + assert.deepEqual( + low.data.rows.map((row) => row.id), + ['bbb222'], + ); +}); + +test('the military feed is used on request and reported when missing', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + aircraft: feed(civil), + military: feed(military, { freshness: 'stale' }), + }, + }); + const result = await catalog.call('aircraft_in_area', { + area: bay, + military: true, + }); + assert.equal( + result.summary, + '1 military aircraft in 60 km around 37.600, -122.300 (data may be stale).', + ); + assert.equal(result.data.rows[0].registration, '07-7175'); + const civilOnly = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(civil) }, + }); + await assert.rejects( + civilOnly.call('aircraft_in_area', { area: bay, military: true }), + (error) => error.code === 'unsupported', + ); +}); + +test('aircraft are found by exactly one identifier across feeds', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(civil), military: feed(military) }, + }); + const byCallsign = await catalog.call('find_aircraft', { + callsign: 'ual123', + }); + assert.equal(byCallsign.summary, 'Found 1 aircraft with callsign UAL123.'); + assert.deepEqual( + byCallsign.data.rows.map((row) => row.id), + ['aaa111'], + ); + assert.equal(byCallsign.data.rows[0].distance_km, undefined); + const byRegistration = await catalog.call('find_aircraft', { + registration: '07-7175', + }); + assert.deepEqual( + byRegistration.data.rows.map((row) => row.id), + ['ae0001'], + ); + const none = await catalog.call('find_aircraft', { icao24: 'ABCDEF' }); + assert.equal( + none.summary, + 'No aircraft with icao24 ABCDEF is currently reported.', + ); + await assert.rejects(catalog.call('find_aircraft', {}), /exactly one/); + await assert.rejects( + catalog.call('find_aircraft', { callsign: 'UAL123', icao24: 'aaa111' }), + /exactly one/, + ); + await assert.rejects( + catalog.call('find_aircraft', { icao24: 'xyz' }), + /must match/, + ); +}); + +test('tracks are ordered and thinned to 200 points', async () => { + const start = Date.UTC(2026, 0, 1); + const track = Array.from({ length: 450 }, (_, index) => ({ + latitude: 37 + index / 1000, + longitude: -122, + observedAtMs: start + (449 - index) * 1000, + baroAltitudeM: 1000, + onGround: false, + })); + const aircraft = feed(civil, { track }); + const catalog = composeCatalog({ tools: coreTools, services: { aircraft } }); + const result = await catalog.call('get_aircraft_track', { icao24: 'AAA111' }); + assert.equal(aircraft.calls[0], 'aaa111'); + assert.equal( + result.summary, + '450 positions for aaa111 from 2026-01-01T00:00:00.000Z to 2026-01-01T00:07:29.000Z.', + ); + assert.deepEqual( + { + total: result.data.total, + returned: result.data.returned, + thinned: result.data.thinned, + }, + { total: 450, returned: 200, thinned: true }, + ); + assert.equal(result.data.points[0].time, '2026-01-01T00:00:00.000Z'); + assert.equal(result.data.points.at(-1).time, '2026-01-01T00:07:29.000Z'); + const empty = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(civil) }, + }); + assert.equal( + (await empty.call('get_aircraft_track', { icao24: 'aaa111' })).summary, + 'No recent track is available for aaa111.', + ); +}); + +test('aircraft info combines type and route lookups', async () => { + const aircraft = feed(civil, { + enrichment: { + 'type:aaa111': { + found: true, + typeCode: 'B738', + typeName: 'Boeing 737-800', + registration: 'N12345', + }, + 'route:UAL123': { + found: true, + airline: 'United Airlines', + origin: { code: 'SFO', name: 'San Francisco', lat: 37.6, lon: -122.4 }, + destination: { code: 'EWR', name: 'Newark', lat: 40.7, lon: -74.2 }, + }, + }, + }); + const catalog = composeCatalog({ tools: coreTools, services: { aircraft } }); + const both = await catalog.call('get_aircraft_info', { + icao24: 'AAA111', + callsign: 'ual123', + }); + assert.equal( + both.summary, + 'Aaa111 is Boeing 737-800 (N12345); UAL123 flies SFO to EWR (United Airlines).', + ); + assert.equal(both.data.route.destination.name, 'Newark'); + const unknown = await catalog.call('get_aircraft_info', { callsign: 'XYZ9' }); + assert.equal(unknown.summary, 'No route is known for XYZ9.'); + assert.deepEqual(unknown.data, { aircraft: null, route: null }); + await assert.rejects( + catalog.call('get_aircraft_info', {}), + /icao24, callsign or both/, + ); +}); + +test('live source failures become tool errors with retry guidance', async () => { + const limited = { + getSnapshot: async () => { + throw new LiveSourceError('limited', 'OpenSky rate limited', { + retryAfterMs: 45000, + }); + }, + }; + const catalog = composeCatalog({ + tools: coreTools, + services: { aircraft: limited }, + }); + await assert.rejects( + catalog.call('aircraft_in_area', { area: bay }), + (error) => + error.name === 'ToolError' && + error.code === 'retry_later' && + error.retryAfterSeconds === 45 && + error.message === 'OpenSky rate limited', + ); +}); diff --git a/src/tools/queries/space.js b/src/tools/queries/space.js index 371d8ecbd..298ec104a 100644 --- a/src/tools/queries/space.js +++ b/src/tools/queries/space.js @@ -1,7 +1,13 @@ -/** Space queries: launches. */ +/** Space queries: launches, satellite passes and satellites overhead. */ +import { twoline2satrec } from 'satellite.js'; +import { + findNextSatellitePass, + lookAnglesAt, +} from '../../data/satellitePass.js'; +import { parseTleText, tleCatalogNumber } from '../../sources/tle.js'; import { defineTool, ToolError } from '../catalog.js'; -import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js'; +import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; const text = (value) => (typeof value === 'string' && value ? value : null); @@ -47,3 +53,215 @@ export const getRecentLaunches = defineTool({ }; }, }); + +// Groups served by the satellite catalog route; `stations` includes the ISS. +const GROUPS = [ + 'stations', + 'visual', + 'gps-ops', + 'glo-ops', + 'galileo', + 'geo', + 'starlink', +]; +const ISS_NORAD = 25544; +const COMPASS = [ + 'N', + 'NNE', + 'NE', + 'ENE', + 'E', + 'ESE', + 'SE', + 'SSE', + 'S', + 'SSW', + 'SW', + 'WSW', + 'W', + 'WNW', + 'NW', + 'NNW', +]; +const OBSERVER = { + lat: { + type: 'number', + minimum: -90, + maximum: 90, + description: 'Observer latitude.', + }, + lon: { + type: 'number', + minimum: -180, + maximum: 180, + description: 'Observer longitude.', + }, +}; +const GROUP_SCHEMA = { + type: 'string', + enum: GROUPS, + description: 'CelesTrak group to search (default "stations").', +}; + +const compass = (degrees) => + COMPASS[Math.round((((degrees % 360) + 360) % 360) / 22.5) % 16]; +const round = (value, digits) => + Number.isFinite(value) ? Number(value.toFixed(digits)) : null; +const nowMs = (services) => services.clock?.now() ?? Date.now(); + +async function readCatalog(services, group, signal) { + const result = await services.satellites.readGroup(group, { signal }); + if (!result.ok) + throw new ToolError( + 'unavailable', + `The ${group} satellite catalog is unavailable (HTTP ${result.status})`, + ); + return parseTleText(result.text).flatMap((entry) => { + try { + return [ + { + ...entry, + norad: tleCatalogNumber(entry.line1), + satrec: twoline2satrec(entry.line1, entry.line2), + }, + ]; + } catch { + return []; + } + }); +} + +function matchSatellite(entries, wanted) { + if (wanted == null) + return entries.find((entry) => entry.norad === ISS_NORAD) ?? null; + const text = String(wanted).trim().toUpperCase(); + const number = /^\d+$/.test(text) ? Number(text) : null; + return ( + entries.find((entry) => number != null && entry.norad === number) ?? + entries.find((entry) => entry.name.toUpperCase() === text) ?? + entries.find((entry) => entry.name.toUpperCase().includes(text)) ?? + null + ); +} + +export const nextSatellitePass = defineTool({ + name: 'next_satellite_pass', + title: 'Next satellite pass', + description: + 'When a satellite next rises over an observer, with rise, peak and set ' + + 'times, peak elevation, rise direction and naked-eye visibility. Defaults ' + + 'to the International Space Station.', + inputSchema: { + type: 'object', + properties: { + ...OBSERVER, + satellite: { + type: 'string', + minLength: 1, + maxLength: 80, + description: + 'Satellite name or NORAD catalog number (default the ISS).', + }, + group: GROUP_SCHEMA, + min_elevation_deg: { type: 'number', minimum: 0, maximum: 80 }, + visible_only: { + type: 'boolean', + description: 'Only passes visible to the naked eye.', + }, + hours: { type: 'number', minimum: 1, maximum: 72 }, + }, + required: ['lat', 'lon'], + additionalProperties: false, + }, + requires: ['satellites'], + async run(args, { services, signal }) { + const group = args.group ?? 'stations'; + const entries = await readCatalog(services, group, signal); + const satellite = matchSatellite(entries, args.satellite); + if (!satellite) + throw new ToolError( + 'invalid_arguments', + `No satellite matching "${args.satellite ?? 'ISS'}" is in the ${group} group`, + ); + const hours = args.hours ?? 24; + const pass = findNextSatellitePass({ + satrec: satellite.satrec, + latDeg: args.lat, + lonDeg: args.lon, + fromMs: nowMs(services), + minElevDeg: args.min_elevation_deg ?? 10, + horizonHours: hours, + requireVisible: args.visible_only === true, + }); + const name = satellite.name; + if (!pass) + return { + summary: `${name} has no ${args.visible_only ? 'visible ' : ''}pass over this location in the next ${hours} hours.`, + data: { satellite: name, norad: satellite.norad, pass: null }, + }; + return { + summary: + `${name} next rises ${isoTime(pass.riseMs)} in the ${compass(pass.riseAzDeg)}, ` + + `peaking at ${Math.round(pass.maxElevDeg)}°` + + `${pass.visible ? '; visible to the naked eye' : ''}.`, + data: { + satellite: name, + norad: satellite.norad, + pass: { + rise: isoTime(pass.riseMs), + peak: isoTime(pass.maxElevMs), + set: isoTime(pass.setMs), + max_elevation_deg: round(pass.maxElevDeg, 1), + rise_azimuth_deg: round(pass.riseAzDeg, 0), + rise_direction: compass(pass.riseAzDeg), + visible: pass.visible, + }, + }, + }; + }, +}); + +export const satellitesOverhead = defineTool({ + name: 'satellites_overhead', + title: 'Satellites overhead', + description: + 'Satellites in a group that are above an observer right now, highest first, ' + + 'with elevation and direction.', + inputSchema: { + type: 'object', + properties: { + ...OBSERVER, + group: GROUP_SCHEMA, + min_elevation_deg: { type: 'number', minimum: 0, maximum: 90 }, + limit: LIMIT_SCHEMA, + }, + required: ['lat', 'lon'], + additionalProperties: false, + }, + requires: ['satellites'], + async run(args, { services, signal }) { + const group = args.group ?? 'stations'; + const minimum = args.min_elevation_deg ?? 10; + const at = nowMs(services); + const rows = (await readCatalog(services, group, signal)) + .flatMap((entry) => { + const look = lookAnglesAt(entry.satrec, at, args.lat, args.lon); + return look && look.elevDeg >= minimum + ? [ + { + name: entry.name, + norad: entry.norad, + elevation_deg: round(look.elevDeg, 1), + azimuth_deg: round(look.azDeg, 0), + direction: compass(look.azDeg), + }, + ] + : []; + }) + .sort((a, b) => b.elevation_deg - a.elevation_deg); + return { + summary: `${countNoun(rows.length, 'satellite')} from the ${group} group ${rows.length === 1 ? 'is' : 'are'} at least ${minimum}° above this location at ${isoTime(at)}.`, + data: { ...capRows(rows, args.limit), group, at: isoTime(at) }, + }; + }, +}); diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs new file mode 100644 index 000000000..460da8319 --- /dev/null +++ b/src/tools/queries/space.test.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; +import { parseTleText, tleCatalogNumber } from '../../sources/tle.js'; + +// Element sets published for 1 January 2024. +const TLE = `ISS (ZARYA) +1 25544U 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994 +2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432414 +CSS (TIANHE) +1 48274U 21035A 24001.50000000 .00020137 00000-0 24481-3 0 9993 +2 48274 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000 +`; +const CLOCK = { now: () => Date.UTC(2024, 0, 1, 12) }; +const satellites = (text = TLE, ok = true) => ({ + calls: [], + async readGroup(group) { + this.calls.push(group); + return { ok, status: ok ? 200 : 503, text }; + }, +}); + +test('TLE text parses into named entries with catalog numbers', () => { + const entries = parseTleText(`${TLE}\nBROKEN\nnot a line\nnor this\n`); + assert.deepEqual( + entries.map((entry) => entry.name), + ['ISS (ZARYA)', 'CSS (TIANHE)'], + ); + assert.equal(tleCatalogNumber(entries[0].line1), 25544); + assert.equal(tleCatalogNumber('1 xx'), null); +}); + +test('the next pass defaults to the ISS and reports times, peak and direction', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { satellites: satellites(), clock: CLOCK }, + }); + const result = await catalog.call('next_satellite_pass', { + lat: 40.7, + lon: -74, + }); + const { pass } = result.data; + assert.equal(result.data.satellite, 'ISS (ZARYA)'); + assert.equal(result.data.norad, 25544); + assert.ok(Date.parse(pass.rise) >= CLOCK.now()); + assert.ok(Date.parse(pass.rise) < Date.parse(pass.peak)); + assert.ok(Date.parse(pass.peak) < Date.parse(pass.set)); + assert.ok(pass.max_elevation_deg >= 10 && pass.max_elevation_deg <= 90); + assert.match(pass.rise_direction, /^[NESW]{1,3}$/); + assert.match( + result.summary, + new RegExp( + `^ISS \\(ZARYA\\) next rises ${pass.rise} in the ${pass.rise_direction}, peaking at \\d+°`, + ), + ); +}); + +test('satellites are matched by name or catalog number within the group', async () => { + const source = satellites(); + const catalog = composeCatalog({ + tools: coreTools, + services: { satellites: source, clock: CLOCK }, + }); + const byName = await catalog.call('next_satellite_pass', { + lat: 0, + lon: 0, + satellite: 'tianhe', + }); + assert.equal(byName.data.norad, 48274); + const byNumber = await catalog.call('next_satellite_pass', { + lat: 0, + lon: 0, + satellite: '48274', + group: 'visual', + }); + assert.equal(byNumber.data.satellite, 'CSS (TIANHE)'); + assert.deepEqual(source.calls, ['stations', 'visual']); + await assert.rejects( + catalog.call('next_satellite_pass', { + lat: 0, + lon: 0, + satellite: 'Hubble', + }), + /No satellite matching "Hubble" is in the stations group/, + ); + await assert.rejects( + catalog.call('next_satellite_pass', { lat: 0, lon: 0, group: 'weather' }), + /must be one of/, + ); +}); + +test('a pass that never comes is a normal answer, and catalog failures are unavailable', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { satellites: satellites(), clock: CLOCK }, + }); + // The ISS orbit is inclined 51.6°, so it never rises 10° above the pole. + const none = await catalog.call('next_satellite_pass', { + lat: 89.9, + lon: 0, + hours: 6, + }); + assert.equal(none.data.pass, null); + assert.equal( + none.summary, + 'ISS (ZARYA) has no pass over this location in the next 6 hours.', + ); + const down = composeCatalog({ + tools: coreTools, + services: { satellites: satellites('', false), clock: CLOCK }, + }); + await assert.rejects( + down.call('next_satellite_pass', { lat: 0, lon: 0 }), + (error) => error.code === 'unavailable' && /HTTP 503/.test(error.message), + ); +}); + +test('satellites overhead are those above the elevation, highest first', async () => { + const source = satellites(); + const services = { satellites: source, clock: CLOCK }; + const catalog = composeCatalog({ tools: coreTools, services }); + const pass = ( + await catalog.call('next_satellite_pass', { lat: 40.7, lon: -74 }) + ).data.pass; + const atPeak = { + satellites: source, + clock: { now: () => Date.parse(pass.peak) }, + }; + const overhead = await composeCatalog({ + tools: coreTools, + services: atPeak, + }).call('satellites_overhead', { + lat: 40.7, + lon: -74, + }); + assert.equal(overhead.data.rows[0].name, 'ISS (ZARYA)'); + assert.ok( + Math.abs(overhead.data.rows[0].elevation_deg - pass.max_elevation_deg) < 1, + ); + assert.equal(overhead.data.at, pass.peak); + const strict = await composeCatalog({ + tools: coreTools, + services: atPeak, + }).call('satellites_overhead', { + lat: 40.7, + lon: -74, + min_elevation_deg: 90, + }); + assert.equal(strict.data.total, 0); + assert.match( + strict.summary, + /^0 satellites from the stations group are at least 90° above/, + ); +}); diff --git a/src/tools/schema.js b/src/tools/schema.js index 02fc44396..942d6ff1f 100644 --- a/src/tools/schema.js +++ b/src/tools/schema.js @@ -2,7 +2,7 @@ * Validates tool arguments against the JSON Schema subset tool definitions use: * object, array, string, number, integer and boolean types; required, * properties, additionalProperties, items, enum, minimum, maximum, minLength, - * maxLength, minItems and maxItems. Unsupported keywords are rejected when a + * maxLength, pattern, minItems and maxItems. Unsupported keywords are rejected when a * tool is defined, so a schema can never promise checks that are not made. */ @@ -19,6 +19,7 @@ const KEYWORDS = new Set([ 'maximum', 'minLength', 'maxLength', + 'pattern', 'minItems', 'maxItems', 'default', @@ -42,6 +43,7 @@ export function assertSupportedSchema(schema, path = 'schema') { } if (!TYPES.has(schema.type)) throw new TypeError(`${path} has unsupported type ${schema.type}`); + if (schema.pattern !== undefined) new RegExp(schema.pattern, 'u'); for (const [name, child] of Object.entries(schema.properties || {})) assertSupportedSchema(child, `${path}.${name}`); if (schema.items) assertSupportedSchema(schema.items, `${path}[]`); @@ -99,6 +101,11 @@ export function validateValue(schema, value, path = 'arguments') { fail(`must be at least ${schema.minLength} characters`); if (schema.maxLength != null && value.length > schema.maxLength) fail(`must be at most ${schema.maxLength} characters`); + if ( + schema.pattern !== undefined && + !new RegExp(schema.pattern, 'u').test(value) + ) + fail(`must match ${schema.pattern}`); break; case 'number': case 'integer': From d65402eae382cdec80349a3d6a9c5ba3eb39a5ca Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 12:11:00 -0700 Subject: [PATCH 004/111] feat: read current camera frames through the CCTV source Add getFrame(camera) to the CCTV source. It reads one frame through the registered frame endpoint the layer already uses and returns its content type and bytes, so code outside the layer can show a camera's view. --- src/layers/cctv/source.js | 13 +++++++++++++ src/layers/cctv/source.test.mjs | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/layers/cctv/source.js b/src/layers/cctv/source.js index bf4363128..0dddce1b4 100644 --- a/src/layers/cctv/source.js +++ b/src/layers/cctv/source.js @@ -51,5 +51,18 @@ export function createCctvSource({ }, getFrameUrl: frameUrlFor, getMediaUrl: mediaUrlFor, + /** Read one current frame through the registered frame endpoint. */ + async getFrame(camera, { signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl(frameUrlFor(camera), { + cache: 'no-store', + signal, + }); + if (!response.ok) throw new Error('Camera frame HTTP ' + response.status); + const bytes = new Uint8Array(await response.arrayBuffer()); + signal?.throwIfAborted(); + const type = response.headers.get('content-type') || ''; + return { contentType: type.split(';')[0].trim().toLowerCase(), bytes }; + }, }; } diff --git a/src/layers/cctv/source.test.mjs b/src/layers/cctv/source.test.mjs index f0e3b0cc3..0103c03de 100644 --- a/src/layers/cctv/source.test.mjs +++ b/src/layers/cctv/source.test.mjs @@ -148,3 +148,28 @@ test('camera construction is inert and destruction cancels a pending catalog and assert.equal(a.getStats().count, 0); assert.equal(b.getStats().count, 0); }); + +test('frames are read through the registered frame endpoint', async () => { + const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]); + const requested = []; + const source = createCctvSource({ + fetchImpl: async (url, init) => { + requested.push({ url, cache: init.cache }); + return new Response(png, { + headers: { 'Content-Type': 'Image/PNG; charset=binary' }, + }); + }, + }); + const frame = await source.getFrame(camera); + assert.equal(frame.contentType, 'image/png'); + assert.deepEqual([...frame.bytes], [...png]); + assert.equal( + requested[0].url.split('?')[0], + '/api/cctv/frame/pack%2Fcamera%20%3Fx', + ); + assert.equal(requested[0].cache, 'no-store'); + const failing = createCctvSource({ + fetchImpl: async () => new Response(null, { status: 502 }), + }); + await assert.rejects(failing.getFrame(camera), /Camera frame HTTP 502/); +}); From 85fee59da14e8f23331aebb74e7fabed88c92fdc Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 12:11:00 -0700 Subject: [PATCH 005/111] feat: add public camera and radio tools Add find_cctv_cameras, get_cctv_snapshot and find_radio_stations. A snapshot returns the camera's current frame as image content with its credit. Tool results may now carry base64 images, which the MCP adapter sends as image content. --- docs/TOOLS.md | 7 +- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 1 + server/mcp/services.js | 4 + src/tools/catalog.js | 13 +- src/tools/index.js | 8 ++ src/tools/localServer.test.mjs | 3 + src/tools/mcp/protocol.js | 9 +- src/tools/queries/media.js | 202 ++++++++++++++++++++++++++++ src/tools/queries/media.test.mjs | 222 +++++++++++++++++++++++++++++++ 10 files changed, 468 insertions(+), 4 deletions(-) create mode 100644 src/tools/queries/media.js create mode 100644 src/tools/queries/media.test.mjs diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 747d19d2e..a33bac852 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -25,7 +25,9 @@ validates and freezes a tool. `kind` is `query` (answers from data, read-only) or `action`. `inputSchema` uses a JSON Schema subset that `src/tools/schema.js` checks completely; unsupported keywords are rejected at definition time. `run(args, { services, signal })` resolves to `{ summary, data }`: one sentence -for people and a structured object for programs. +for people and a structured object for programs. A tool may also return `images`, +each `{ mimeType, data }` with base64 data; the MCP adapter sends them as image +content. `composeCatalog({ tools, services, replace, interceptors })` builds a catalog: @@ -93,3 +95,6 @@ public feeds the sources already use. | `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb | | `next_satellite_pass` | `satellites` | Next pass over a point (default the ISS), with naked-eye visibility | | `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a point now, highest first | +| `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first | +| `get_cctv_snapshot` | `cctv` | The current image from one camera, returned as image content | +| `find_radio_stations` | `radio` | Radio Browser stations by area and/or search terms, with stream URLs | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 9d9e71c17..674618cba 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -871,5 +871,6 @@ "src/tools/mcp/mcp.test.mjs", "src/tools/localServer.test.mjs", "src/tools/queries/aviation.test.mjs", - "src/tools/queries/space.test.mjs" + "src/tools/queries/space.test.mjs", + "src/tools/queries/media.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 503fb0255..033e9ce3e 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3088,6 +3088,7 @@ "src/tools/queries/hazards.js", "src/tools/queries/space.js", "src/tools/queries/aviation.js", + "src/tools/queries/media.js", "src/sources/tle.js", "src/data/satellitePass.js" ], diff --git a/server/mcp/services.js b/server/mcp/services.js index b828ef1ef..5e4ae774a 100644 --- a/server/mcp/services.js +++ b/server/mcp/services.js @@ -3,9 +3,11 @@ * at a running God's Eye View server's `/api` routes. */ +import { createCctvSource } from '../../src/layers/cctv/source.js'; import { createUsgsEarthquakeSource } from '../../src/layers/earthquakes/source.js'; import { createFirmsSource } from '../../src/layers/firms/source.js'; import { createLaunchSource } from '../../src/layers/launches/source.js'; +import { createRadioSource } from '../../src/layers/radio/source.js'; import { createSatelliteSource } from '../../src/layers/satellites/source.js'; import { createAdsbLolSource, @@ -42,6 +44,8 @@ export function createLocalToolServices(options = {}) { aircraft: createOpenSkySource({ fetchImpl }), military: createAdsbLolSource({ fetchImpl }), satellites: createSatelliteSource({ fetchImpl }), + cctv: createCctvSource({ fetchImpl }), + radio: createRadioSource({ fetchImpl }), places: createGeocodePlaceService({ fetchImpl }), }; } diff --git a/src/tools/catalog.js b/src/tools/catalog.js index e289665b6..aa993e765 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -61,7 +61,8 @@ export function fromSourceError(error) { * Validate and freeze a tool definition. * * `run(args, { services, signal })` resolves to `{ summary, data }`: a short - * sentence for people and a structured object for programs. + * sentence for people and a structured object for programs. It may add + * `images`, each `{ mimeType, data }` with base64 data. */ export function defineTool({ name, @@ -146,6 +147,16 @@ export function composeCatalog({ } if (!result || typeof result.summary !== 'string' || !result.data) throw new TypeError(`${tool.name} returned no summary or data`); + if ( + result.images !== undefined && + (!Array.isArray(result.images) || + !result.images.every( + (item) => + typeof item?.mimeType === 'string' && + typeof item?.data === 'string', + )) + ) + throw new TypeError(`${tool.name} returned malformed images`); return result; }; const chain = interceptors.reduceRight( diff --git a/src/tools/index.js b/src/tools/index.js index 84d2aaa1e..68f3b9158 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -10,6 +10,11 @@ import { getAircraftTrack, } from './queries/aviation.js'; import { getActiveFires, getEarthquakes } from './queries/hazards.js'; +import { + findCctvCameras, + findRadioStations, + getCctvSnapshot, +} from './queries/media.js'; import { getRecentLaunches, nextSatellitePass, @@ -43,4 +48,7 @@ export const coreTools = Object.freeze([ getAircraftInfo, nextSatellitePass, satellitesOverhead, + findCctvCameras, + getCctvSnapshot, + findRadioStations, ]); diff --git a/src/tools/localServer.test.mjs b/src/tools/localServer.test.mjs index 42425ea39..a8457fe25 100644 --- a/src/tools/localServer.test.mjs +++ b/src/tools/localServer.test.mjs @@ -78,6 +78,9 @@ test('the stdio server answers newline-delimited requests using only its data so 'get_aircraft_info', 'next_satellite_pass', 'satellites_overhead', + 'find_cctv_cameras', + 'get_cctv_snapshot', + 'find_radio_stations', ], ); assert.equal( diff --git a/src/tools/mcp/protocol.js b/src/tools/mcp/protocol.js index 0450b66ea..d86a0c6b8 100644 --- a/src/tools/mcp/protocol.js +++ b/src/tools/mcp/protocol.js @@ -80,7 +80,14 @@ export function createMcpServer({ signal, }); return { - content: [{ type: 'text', text: result.summary }], + content: [ + { type: 'text', text: result.summary }, + ...(result.images || []).map((item) => ({ + type: 'image', + data: item.data, + mimeType: item.mimeType, + })), + ], structuredContent: result.data, isError: false, }; diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js new file mode 100644 index 000000000..2ad0f78e3 --- /dev/null +++ b/src/tools/queries/media.js @@ -0,0 +1,202 @@ +/** Public camera and radio queries over the CCTV and radio sources. */ + +import { defineTool, ToolError } from '../catalog.js'; +import { + AREA_SCHEMA, + areaCenter, + areaContains, + distanceKm, + resolveArea, +} from '../area.js'; +import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js'; + +const FRAME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); +const MAX_FRAME_BYTES = 3 * 1024 * 1024; + +const round = (value, digits) => + Number.isFinite(value) ? Number(value.toFixed(digits)) : null; +const text = (value) => (typeof value === 'string' && value ? value : null); + +async function readCameras(services, signal) { + const payload = await services.cctv.getCatalog({ signal }); + return payload.sources.filter( + (camera) => + camera?.id && Number.isFinite(camera.lat) && Number.isFinite(camera.lon), + ); +} + +export const findCctvCameras = defineTool({ + name: 'find_cctv_cameras', + title: 'Public cameras in an area', + description: + 'Public traffic and city cameras in an area, nearest the center first. ' + + 'Use get_cctv_snapshot with a camera id to see its current view.', + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['cctv'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + const center = areaCenter(area); + const rows = (await readCameras(services, signal)) + .filter((camera) => areaContains(area, camera)) + .map((camera) => ({ + id: camera.id, + name: text(camera.name), + city: text(camera.city), + provider: text(camera.provider), + lat: round(camera.lat, 6), + lon: round(camera.lon, 6), + heading_deg: round(camera.headingDeg, 0), + feed_type: text(camera.feedType), + credit: text(camera.credit), + distance_km: round(distanceKm(center, camera), 2), + })) + .sort((a, b) => a.distance_km - b.distance_km); + return { + summary: `${countNoun(rows.length, 'public camera')} in ${area.label}.`, + data: capRows(rows, args.limit), + }; + }, +}); + +export const getCctvSnapshot = defineTool({ + name: 'get_cctv_snapshot', + title: 'Current camera view', + description: + 'The current image from one public camera, by the id from find_cctv_cameras.', + inputSchema: { + type: 'object', + properties: { camera_id: { type: 'string', minLength: 1, maxLength: 200 } }, + required: ['camera_id'], + additionalProperties: false, + }, + requires: ['cctv'], + async run(args, { services, signal }) { + const camera = (await readCameras(services, signal)).find( + (candidate) => candidate.id === args.camera_id, + ); + if (!camera) + throw new ToolError( + 'invalid_arguments', + `No camera has id ${args.camera_id}; use find_cctv_cameras first`, + ); + const frame = await services.cctv.getFrame(camera, { signal }); + if (!FRAME_TYPES.has(frame.contentType)) + throw new ToolError( + 'unavailable', + `Camera ${camera.name || camera.id} has no still image right now`, + ); + if (frame.bytes.byteLength > MAX_FRAME_BYTES) + throw new ToolError( + 'unavailable', + 'The camera image is too large to return', + ); + const name = text(camera.name) || camera.id; + return { + summary: `Current view from ${name}${camera.city ? ` in ${camera.city}` : ''}${camera.credit ? `, courtesy of ${camera.credit}` : ''}.`, + data: { + id: camera.id, + name, + city: text(camera.city), + provider: text(camera.provider), + credit: text(camera.credit), + mime_type: frame.contentType, + bytes: frame.bytes.byteLength, + }, + images: [{ mimeType: frame.contentType, data: base64(frame.bytes) }], + }; + }, +}); + +export const findRadioStations = defineTool({ + name: 'find_radio_stations', + title: 'Radio stations', + description: + 'Internet radio stations from the Radio Browser directory, by area and/or ' + + 'a search term matched against name, tags, language and country. Each ' + + "result includes the broadcaster's public stream URL.", + inputSchema: { + type: 'object', + properties: { + area: AREA_SCHEMA, + query: { + type: 'string', + minLength: 1, + maxLength: 80, + description: 'Words such as "jazz", "news" or "Tokyo".', + }, + limit: LIMIT_SCHEMA, + }, + additionalProperties: false, + }, + requires: ['radio'], + async run(args, { services, signal }) { + if (!args.area && !args.query) + throw new ToolError('invalid_arguments', 'Give an area, a query or both'); + const area = args.area + ? await resolveArea(args.area, { services, signal }) + : null; + const terms = String(args.query || '') + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + const directory = await services.radio.getDirectory({ signal }); + if (!Array.isArray(directory?.stations)) + throw new ToolError( + 'malformed', + 'The radio directory returned no stations', + ); + const center = area ? areaCenter(area) : null; + const rows = directory.stations + .filter((station) => !area || areaContains(area, station)) + .filter((station) => { + if (!terms.length) return true; + const haystack = [ + station.name, + ...(station.tags || []), + ...(station.languages || []), + station.country, + station.state, + ] + .join(' ') + .toLowerCase(); + return terms.every((term) => haystack.includes(term)); + }) + .map((station) => ({ + id: station.id, + name: text(station.name), + country: text(station.country), + state: text(station.state), + tags: station.tags || [], + languages: station.languages || [], + codec: text(station.codec), + bitrate_kbps: Number.isFinite(station.bitrate) ? station.bitrate : null, + stream_url: text(station.streamUrl), + homepage: text(station.homepage), + lat: round(station.lat, 4), + lon: round(station.lon, 4), + ...(center + ? { distance_km: round(distanceKm(center, station), 1) } + : {}), + })); + if (center) rows.sort((a, b) => a.distance_km - b.distance_km); + const where = area ? ` in ${area.label}` : ''; + const what = args.query ? ` matching "${args.query}"` : ''; + return { + summary: `${countNoun(rows.length, 'radio station')}${what}${where}.`, + data: capRows(rows, args.limit), + }; + }, +}); + +/** Base64-encode bytes with the platform's btoa, in chunks. */ +function base64(bytes) { + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += 0x8000) + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + return btoa(binary); +} diff --git a/src/tools/queries/media.test.mjs b/src/tools/queries/media.test.mjs new file mode 100644 index 000000000..b99e9e781 --- /dev/null +++ b/src/tools/queries/media.test.mjs @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; +import { createMcpServer } from '../mcp/index.js'; + +const cameras = [ + { + id: 'austin-1', + name: 'Congress at 6th', + city: 'Austin', + provider: 'City of Austin', + lat: 30.2685, + lon: -97.7425, + headingDeg: 180, + feedType: 'image', + credit: 'City of Austin', + }, + { + id: 'austin-2', + name: 'Lamar at 5th', + city: 'Austin', + provider: 'City of Austin', + lat: 30.2695, + lon: -97.7535, + headingDeg: 90, + feedType: 'image', + credit: '', + }, + { id: 'far', name: 'Elsewhere', city: 'Oslo', lat: 59.9, lon: 10.7 }, + { id: 'no-position', name: 'Unplaced' }, +]; +const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const cctv = (frame = { contentType: 'image/png', bytes: PNG }) => ({ + frames: [], + async getCatalog() { + return { sources: cameras }; + }, + async getFrame(camera) { + this.frames.push(camera.id); + return frame; + }, +}); +const downtown = { lat: 30.2672, lon: -97.7431, radius_km: 3 }; + +test('cameras in an area are listed nearest first', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { cctv: cctv() }, + }); + const result = await catalog.call('find_cctv_cameras', { area: downtown }); + assert.equal( + result.summary, + '2 public cameras in 3 km around 30.267, -97.743.', + ); + assert.deepEqual( + result.data.rows.map((row) => row.id), + ['austin-1', 'austin-2'], + ); + assert.deepEqual(result.data.rows[0], { + id: 'austin-1', + name: 'Congress at 6th', + city: 'Austin', + provider: 'City of Austin', + lat: 30.2685, + lon: -97.7425, + heading_deg: 180, + feed_type: 'image', + credit: 'City of Austin', + distance_km: 0.16, + }); + assert.equal(result.data.rows[1].credit, null); +}); + +test('a snapshot returns the frame as an image with its credit', async () => { + const source = cctv(); + const catalog = composeCatalog({ + tools: coreTools, + services: { cctv: source }, + }); + const result = await catalog.call('get_cctv_snapshot', { + camera_id: 'austin-1', + }); + assert.equal( + result.summary, + 'Current view from Congress at 6th in Austin, courtesy of City of Austin.', + ); + assert.deepEqual(result.images, [ + { mimeType: 'image/png', data: 'iVBORw0KGgo=' }, + ]); + assert.equal(result.data.bytes, 8); + assert.deepEqual(source.frames, ['austin-1']); + + const server = createMcpServer({ catalog, name: 'test', version: '1' }); + const response = await server.handle({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'get_cctv_snapshot', arguments: { camera_id: 'austin-1' } }, + }); + assert.deepEqual(response.result.content[1], { + type: 'image', + data: 'iVBORw0KGgo=', + mimeType: 'image/png', + }); +}); + +test('unknown cameras, video-only feeds and oversized frames are reported', async () => { + const catalog = (frame) => + composeCatalog({ tools: coreTools, services: { cctv: cctv(frame) } }); + await assert.rejects( + catalog().call('get_cctv_snapshot', { camera_id: 'nope' }), + /No camera has id nope; use find_cctv_cameras first/, + ); + await assert.rejects( + catalog({ contentType: 'video/mp2t', bytes: PNG }).call( + 'get_cctv_snapshot', + { camera_id: 'austin-2' }, + ), + (error) => + error.code === 'unavailable' && /no still image/.test(error.message), + ); + await assert.rejects( + catalog({ + contentType: 'image/jpeg', + bytes: new Uint8Array(3 * 1024 * 1024 + 1), + }).call('get_cctv_snapshot', { camera_id: 'austin-2' }), + /too large/, + ); +}); + +test('radio stations match area and every search term', async () => { + const radio = { + async getDirectory() { + return { + stations: [ + { + id: 'a', + name: 'KUTX', + lat: 30.28, + lon: -97.74, + country: 'United States', + state: 'Texas', + tags: ['music', 'indie'], + languages: ['english'], + codec: 'MP3', + bitrate: 128, + streamUrl: 'https://example.org/kutx.mp3', + homepage: 'https://kutx.org', + }, + { + id: 'b', + name: 'KMFA Classical', + lat: 30.3, + lon: -97.7, + country: 'United States', + state: 'Texas', + tags: ['classical'], + languages: ['english'], + codec: 'AAC', + bitrate: null, + streamUrl: 'https://example.org/kmfa.aac', + }, + { + id: 'c', + name: 'Jazz Oslo', + lat: 59.9, + lon: 10.7, + country: 'Norway', + tags: ['jazz'], + languages: ['norwegian'], + }, + ], + }; + }, + }; + const catalog = composeCatalog({ tools: coreTools, services: { radio } }); + const nearby = await catalog.call('find_radio_stations', { + area: { lat: 30.27, lon: -97.74, radius_km: 20 }, + }); + assert.deepEqual( + nearby.data.rows.map((row) => row.id), + ['a', 'b'], + ); + assert.equal(nearby.data.rows[0].stream_url, 'https://example.org/kutx.mp3'); + const jazz = await catalog.call('find_radio_stations', { + query: 'jazz norwegian', + }); + assert.equal(jazz.summary, '1 radio station matching "jazz norwegian".'); + assert.equal(jazz.data.rows[0].distance_km, undefined); + const classicalTexas = await catalog.call('find_radio_stations', { + query: 'classical texas', + area: { lat: 30.27, lon: -97.74, radius_km: 20 }, + }); + assert.deepEqual( + classicalTexas.data.rows.map((row) => row.id), + ['b'], + ); + assert.equal(classicalTexas.data.rows[0].bitrate_kbps, null); + await assert.rejects( + catalog.call('find_radio_stations', {}), + /area, a query or both/, + ); +}); + +test('malformed image results are a programming error', async () => { + const { defineTool } = await import('../catalog.js'); + const bad = defineTool({ + name: 'bad_image', + title: 'Bad', + description: 'Returns a malformed image.', + inputSchema: { type: 'object' }, + run: async () => ({ + summary: 'x', + data: {}, + images: [{ mimeType: 'image/png' }], + }), + }); + await assert.rejects( + composeCatalog({ tools: [bad] }).call('bad_image'), + /malformed images/, + ); +}); From e680a59bf5d4cbbe8d0822f7eccd7be25164f816 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 12:16:01 -0700 Subject: [PATCH 006/111] feat: add place search and routing tools Add search_places and places_nearby over the app's Google Places routes, and plan_route over its OpenStreetMap routing. The place search service reports when no search key is configured, so an unconfigured server is not mistaken for an empty result, and rate limits become retry_later. Route endpoints accept a place name or coordinates. --- CHANGELOG.md | 5 +- docs/CURRENT-STATE.md | 5 +- docs/TOOLS.md | 7 +- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 1 + server/mcp/services.js | 8 +- src/tools/area.js | 47 ++++++ src/tools/index.js | 11 +- src/tools/localServer.test.mjs | 3 + src/tools/places.js | 90 ++++++++++- src/tools/queries/aviation.js | 20 +-- src/tools/queries/places.js | 158 +++++++++++++++++++ src/tools/queries/places.test.mjs | 251 ++++++++++++++++++++++++++++++ src/tools/results.js | 10 ++ 14 files changed, 596 insertions(+), 23 deletions(-) create mode 100644 src/tools/queries/places.js create mode 100644 src/tools/queries/places.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 590d19872..704b1a957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,9 @@ # Changelog - Add tools for language-model clients and a local MCP server. `npm run mcp` - serves earthquake, active-fire, launch, aircraft and satellite queries over - stdio to clients such as Claude Code, reading from a running app. Tools are defined + serves earthquake, active-fire, launch, aircraft, satellite, camera, radio, + place and routing queries over stdio to clients such as Claude Code, reading + from a running app. Tools are defined once in `gods-eye-view/tools`, reuse the layers' source factories, and are exposed through the protocol adapter in `gods-eye-view/tools/mcp`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 706298590..b33f0c3c9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -7,8 +7,9 @@ and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client, reading from a running app's `/api` routes (default `http://localhost:4173`). Queries cover earthquakes, active fires, recent launches, aircraft (in an -area, by identifier, tracks, and type and route lookups) and satellites (next -pass over a point, and those overhead now). Tools reuse the layers' portable source factories, take a +area, by identifier, tracks, and type and route lookups), satellites (next +pass over a point, and those overhead now), public cameras (including a +camera's current image), radio stations, place search and routing. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. The app does not import the tools, so its runtime behavior is unchanged. See [tools and the MCP server](TOOLS.md). diff --git a/docs/TOOLS.md b/docs/TOOLS.md index a33bac852..4618af8ce 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -49,7 +49,9 @@ Services are the portable source factories the layers already use, such as plus a `places` service with `resolve(name, { signal })`. Sources request relative `/api/...` paths through an injected `fetchImpl`, so the same tool code runs wherever an application routes those paths. -`createGeocodePlaceService` resolves place names through `/api/geocode`. +`createGeocodePlaceService` resolves place names through `/api/geocode`; +`createPlaceSearchService` searches `/api/google/*` and reports when no search key +is configured; `createRouteService` plans routes through `/api/route`. ## The `area` argument @@ -98,3 +100,6 @@ public feeds the sources already use. | `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first | | `get_cctv_snapshot` | `cctv` | The current image from one camera, returned as image content | | `find_radio_stations` | `radio` | Radio Browser stations by area and/or search terms, with stream URLs | +| `search_places` | `placeSearch` | Points of interest matching a query within an area (Google Places) | +| `places_nearby` | `placeSearch` | Notable places around a point (Google Places) | +| `plan_route` | `routing` | Walking, driving or cycling route over OpenStreetMap, with a simplified path | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 674618cba..9c4436652 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -872,5 +872,6 @@ "src/tools/localServer.test.mjs", "src/tools/queries/aviation.test.mjs", "src/tools/queries/space.test.mjs", - "src/tools/queries/media.test.mjs" + "src/tools/queries/media.test.mjs", + "src/tools/queries/places.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 033e9ce3e..3ccd6bda3 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3089,6 +3089,7 @@ "src/tools/queries/space.js", "src/tools/queries/aviation.js", "src/tools/queries/media.js", + "src/tools/queries/places.js", "src/sources/tle.js", "src/data/satellitePass.js" ], diff --git a/server/mcp/services.js b/server/mcp/services.js index 5e4ae774a..7bfd04a37 100644 --- a/server/mcp/services.js +++ b/server/mcp/services.js @@ -13,7 +13,11 @@ import { createAdsbLolSource, createOpenSkySource, } from '../../src/sources/live/standalone.js'; -import { createGeocodePlaceService } from '../../src/tools/places.js'; +import { + createGeocodePlaceService, + createPlaceSearchService, + createRouteService, +} from '../../src/tools/places.js'; export const DEFAULT_API_BASE = 'http://localhost:4173'; @@ -46,6 +50,8 @@ export function createLocalToolServices(options = {}) { satellites: createSatelliteSource({ fetchImpl }), cctv: createCctvSource({ fetchImpl }), radio: createRadioSource({ fetchImpl }), + placeSearch: createPlaceSearchService({ fetchImpl }), + routing: createRouteService({ fetchImpl }), places: createGeocodePlaceService({ fetchImpl }), }; } diff --git a/src/tools/area.js b/src/tools/area.js index 4f0f5c55a..ff61077bc 100644 --- a/src/tools/area.js +++ b/src/tools/area.js @@ -93,6 +93,53 @@ function circleArea(lat, lon, radiusKm) { }; } +/** JSON Schema for a single location: a place name, or lat and lon. */ +export const POINT_SCHEMA = Object.freeze({ + type: 'object', + description: 'A place name, or lat and lon.', + properties: { + place: { type: 'string', minLength: 1, maxLength: 200 }, + lat: { type: 'number', minimum: -90, maximum: 90 }, + lon: { type: 'number', minimum: -180, maximum: 180 }, + }, + additionalProperties: false, +}); + +/** Resolve a point argument to `{ label, lat, lon }`. */ +export async function resolvePoint(point, { services, signal } = {}) { + if (point?.place != null && point.lat == null && point.lon == null) { + const area = await resolveArea( + { place: point.place }, + { services, signal }, + ); + return { label: area.label, ...areaCenter(area) }; + } + if (point?.place == null && point?.lat != null && point?.lon != null) + return { + label: `${point.lat.toFixed(4)}, ${point.lon.toFixed(4)}`, + lat: point.lat, + lon: point.lon, + }; + throw new ToolError( + 'invalid_arguments', + 'A location needs either place, or lat and lon', + ); +} + +/** Distance in kilometers from an area's center to its farthest edge point. */ +export function areaRadiusKm(area) { + if (area.center) return area.center.radiusKm; + const center = areaCenter(area); + return Math.max( + ...[ + { lat: area.north, lon: area.west }, + { lat: area.north, lon: area.east }, + { lat: area.south, lon: area.west }, + { lat: area.south, lon: area.east }, + ].map((corner) => distanceKm(center, corner)), + ); +} + /** Great-circle distance in kilometers. */ export function distanceKm(a, b) { const toRad = Math.PI / 180; diff --git a/src/tools/index.js b/src/tools/index.js index 68f3b9158..a6f54b4d1 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -10,6 +10,7 @@ import { getAircraftTrack, } from './queries/aviation.js'; import { getActiveFires, getEarthquakes } from './queries/hazards.js'; +import { placesNearby, planRoute, searchPlaces } from './queries/places.js'; import { findCctvCameras, findRadioStations, @@ -35,7 +36,12 @@ export { distanceKm, } from './area.js'; export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js'; -export { createGeocodePlaceService, placeFromGeocodeResult } from './places.js'; +export { + createGeocodePlaceService, + createPlaceSearchService, + createRouteService, + placeFromGeocodeResult, +} from './places.js'; /** Every query Core defines, in a stable order. */ export const coreTools = Object.freeze([ @@ -51,4 +57,7 @@ export const coreTools = Object.freeze([ findCctvCameras, getCctvSnapshot, findRadioStations, + searchPlaces, + placesNearby, + planRoute, ]); diff --git a/src/tools/localServer.test.mjs b/src/tools/localServer.test.mjs index a8457fe25..02f705043 100644 --- a/src/tools/localServer.test.mjs +++ b/src/tools/localServer.test.mjs @@ -81,6 +81,9 @@ test('the stdio server answers newline-delimited requests using only its data so 'find_cctv_cameras', 'get_cctv_snapshot', 'find_radio_stations', + 'search_places', + 'places_nearby', + 'plan_route', ], ); assert.equal( diff --git a/src/tools/places.js b/src/tools/places.js index 7e5005ce3..64d9da201 100644 --- a/src/tools/places.js +++ b/src/tools/places.js @@ -1,9 +1,16 @@ /** - * A `places` service backed by the app's `/api/geocode` route. Applications - * with a different place search can supply any object with the same - * `resolve(name, { signal })` contract. + * Place services backed by the app's own routes. Applications with different + * providers can supply any objects with the same contracts: + * + * - `places.resolve(name, { signal })` turns a name into bounds (`/api/geocode`). + * - `placeSearch.search(query, near, { signal })` and + * `placeSearch.nearby(near, { signal })` find points of interest + * (`/api/google/*`), reporting whether search is configured. + * - `routing.route(points, profile, { signal })` plans a route (`/api/route`). */ +import { ToolError } from './catalog.js'; + const POINT_RADIUS_DEGREES = 0.25; /** Resolve place names to `{ name, bounds: { west, south, east, north } }` or null. */ @@ -49,3 +56,80 @@ export function placeFromGeocodeResult(result) { }; return { name: result.formatted_address || null, bounds }; } + +/** Search points of interest; `configured` is false when the server has no search key. */ +export function createPlaceSearchService({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + async function read(path, params, signal) { + const response = await fetchImpl(`${path}?${new URLSearchParams(params)}`, { + signal, + }); + let payload = null; + try { + payload = await response.json(); + } catch { + /* the status below is authoritative */ + } + if (!response.ok) throw httpFailure(response, 'Place search'); + return { + configured: payload?.configured !== false, + places: Array.isArray(payload?.places) ? payload.places : [], + }; + } + return { + search(query, { latitude, longitude, radiusM }, { signal } = {}) { + return read( + '/api/google/text-search', + { q: query, lat: latitude, lon: longitude, radiusM }, + signal, + ); + }, + nearby({ latitude, longitude, radiusM }, { signal } = {}) { + return read( + '/api/google/nearby-places', + { lat: latitude, lon: longitude, radiusM }, + signal, + ); + }, + }; +} + +/** Plan routes between `[{ lat, lon }]` points; resolves `{ ok, ... }`. */ +export function createRouteService({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async route(points, profile, { signal } = {}) { + const coords = points + .map((point) => `${point.lon.toFixed(6)},${point.lat.toFixed(6)}`) + .join(';'); + const response = await fetchImpl( + `/api/route?${new URLSearchParams({ profile, coords })}`, + { signal }, + ); + let payload = null; + try { + payload = await response.json(); + } catch { + /* the status below is authoritative */ + } + if (response.status === 429) throw httpFailure(response, 'Routing'); + if (payload?.ok === false || response.status === 400) + return { ok: false, error: payload?.error || 'no route found' }; + if (!response.ok) throw httpFailure(response, 'Routing'); + return payload; + }, + }; +} + +function httpFailure(response, label) { + if (response.status === 429) { + const seconds = Number(response.headers.get('retry-after')); + return new ToolError('retry_later', `${label} is rate limited`, { + retryAfterSeconds: + Number.isFinite(seconds) && seconds > 0 ? seconds : null, + }); + } + return new Error(`${label} HTTP ${response.status}`); +} diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 34f3d26de..110024841 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -8,7 +8,13 @@ import { distanceKm, resolveArea, } from '../area.js'; -import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; +import { + LIMIT_SCHEMA, + capRows, + countNoun, + isoTime, + thinEvenly, +} from '../results.js'; const ICAO24_SCHEMA = Object.freeze({ type: 'string', @@ -200,7 +206,7 @@ export const getAircraftTrack = defineTool({ const points = [...track.records].sort( (a, b) => a.observedAtMs - b.observedAtMs, ); - const kept = thin(points, MAX_TRACK_POINTS); + const kept = thinEvenly(points, MAX_TRACK_POINTS); const first = points[0]; const last = points.at(-1); return { @@ -224,16 +230,6 @@ export const getAircraftTrack = defineTool({ }, }); -/** Keep the first and last items and evenly spaced items between them. */ -function thin(items, max) { - if (items.length <= max) return items; - const step = (items.length - 1) / (max - 1); - return Array.from( - { length: max }, - (_, index) => items[Math.round(index * step)], - ); -} - export const getAircraftInfo = defineTool({ name: 'get_aircraft_info', title: 'Aircraft type and route', diff --git a/src/tools/queries/places.js b/src/tools/queries/places.js new file mode 100644 index 000000000..1e82237e1 --- /dev/null +++ b/src/tools/queries/places.js @@ -0,0 +1,158 @@ +/** Place search and routing queries. */ + +import { defineTool, ToolError } from '../catalog.js'; +import { + AREA_SCHEMA, + POINT_SCHEMA, + areaCenter, + areaContains, + areaRadiusKm, + resolveArea, + resolvePoint, +} from '../area.js'; +import { LIMIT_SCHEMA, capRows, countNoun, thinEvenly } from '../results.js'; + +const MODES = { walk: 'foot', drive: 'car', bike: 'bike' }; +const MAX_ROUTE_POINTS = 100; +const NOT_CONFIGURED = + 'Place search needs a Google Places key configured for this server'; + +const round = (value, digits) => + Number.isFinite(value) ? Number(value.toFixed(digits)) : null; + +function placeRow(place) { + return { + id: place.id ?? null, + name: place.name ?? null, + address: place.address ?? null, + type: place.primaryType ?? null, + lat: round(place.latitude, 6), + lon: round(place.longitude, 6), + distance_m: Number.isFinite(place.distanceM) ? place.distanceM : null, + }; +} + +export const searchPlaces = defineTool({ + name: 'search_places', + title: 'Search places', + description: + 'Find businesses, landmarks and other points of interest matching a ' + + 'query within an area, using Google Places.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', minLength: 1, maxLength: 200 }, + area: AREA_SCHEMA, + limit: LIMIT_SCHEMA, + }, + required: ['query', 'area'], + additionalProperties: false, + }, + requires: ['placeSearch'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + const center = areaCenter(area); + const radiusM = Math.round( + Math.min(50000, Math.max(500, areaRadiusKm(area) * 1000)), + ); + const result = await services.placeSearch.search( + args.query, + { latitude: center.lat, longitude: center.lon, radiusM }, + { signal }, + ); + if (!result.configured) throw new ToolError('unavailable', NOT_CONFIGURED); + const rows = result.places + .filter((place) => + areaContains(area, { lat: place.latitude, lon: place.longitude }), + ) + .map(placeRow); + return { + summary: `${countNoun(rows.length, 'place')} matching "${args.query}" in ${area.label}.`, + data: capRows(rows, args.limit), + }; + }, +}); + +export const placesNearby = defineTool({ + name: 'places_nearby', + title: 'Places nearby', + description: + 'Notable places around a point within a radius, most notable first, ' + + 'using Google Places.', + inputSchema: { + type: 'object', + properties: { + lat: { type: 'number', minimum: -90, maximum: 90 }, + lon: { type: 'number', minimum: -180, maximum: 180 }, + radius_m: { type: 'number', minimum: 10, maximum: 5000 }, + limit: LIMIT_SCHEMA, + }, + required: ['lat', 'lon'], + additionalProperties: false, + }, + requires: ['placeSearch'], + async run(args, { services, signal }) { + const radiusM = Math.round(args.radius_m ?? 250); + const result = await services.placeSearch.nearby( + { latitude: args.lat, longitude: args.lon, radiusM }, + { signal }, + ); + if (!result.configured) throw new ToolError('unavailable', NOT_CONFIGURED); + const rows = result.places.map(placeRow); + return { + summary: `${countNoun(rows.length, 'place')} within ${radiusM} m.`, + data: capRows(rows, args.limit), + }; + }, +}); + +export const planRoute = defineTool({ + name: 'plan_route', + title: 'Plan a route', + description: + 'Walking, driving or cycling route between two locations over ' + + 'OpenStreetMap, with distance, duration and a simplified path.', + inputSchema: { + type: 'object', + properties: { + from: POINT_SCHEMA, + to: POINT_SCHEMA, + mode: { type: 'string', enum: Object.keys(MODES) }, + }, + required: ['from', 'to'], + additionalProperties: false, + }, + requires: ['routing'], + async run(args, { services, signal }) { + const [from, to] = await Promise.all([ + resolvePoint(args.from, { services, signal }), + resolvePoint(args.to, { services, signal }), + ]); + const mode = args.mode ?? 'walk'; + const route = await services.routing.route([from, to], MODES[mode], { + signal, + }); + if (!route?.ok) + throw new ToolError( + 'invalid_arguments', + `No ${mode} route from ${from.label} to ${to.label}: ${route?.error || 'no route found'}`, + ); + const path = thinEvenly(route.geometry, MAX_ROUTE_POINTS).map( + ([lon, lat]) => [round(lon, 5), round(lat, 5)], + ); + const km = route.distanceM / 1000; + const minutes = Math.round(route.durationS / 60); + return { + summary: `${mode[0].toUpperCase()}${mode.slice(1)} from ${from.label} to ${to.label}: ${km.toFixed(1)} km, about ${minutes} minutes.`, + data: { + mode, + from, + to, + distance_m: route.distanceM, + duration_s: route.durationS, + path_lon_lat: path, + path_points: route.geometry.length, + }, + }; + }, +}); diff --git a/src/tools/queries/places.test.mjs b/src/tools/queries/places.test.mjs new file mode 100644 index 000000000..29755d517 --- /dev/null +++ b/src/tools/queries/places.test.mjs @@ -0,0 +1,251 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; +import { createPlaceSearchService, createRouteService } from '../places.js'; + +const place = (id, latitude, longitude, extra = {}) => ({ + id, + name: `Place ${id}`, + address: `${id} Main St`, + latitude, + longitude, + distanceM: 120, + primaryType: 'cafe', + types: ['cafe'], + ...extra, +}); +const placeSearch = (result) => ({ + calls: [], + async search(query, near) { + this.calls.push({ query, ...near }); + return result; + }, + async nearby(near) { + this.calls.push(near); + return result; + }, +}); +const places = { + resolve: async (name) => + ({ + Austin: { + name: 'Austin, Texas', + bounds: { west: -97.94, south: 30.1, east: -97.56, north: 30.52 }, + }, + Dallas: { + name: 'Dallas, Texas', + bounds: { west: -96.99, south: 32.62, east: -96.46, north: 33.02 }, + }, + })[name] ?? null, +}; + +test('place search is centered on the area, kept inside it, and reports a missing key', async () => { + const search = placeSearch({ + configured: true, + places: [place('a', 30.27, -97.74), place('far', 40.7, -74)], + }); + const catalog = composeCatalog({ + tools: coreTools, + services: { placeSearch: search, places }, + }); + const result = await catalog.call('search_places', { + query: 'coffee', + area: { place: 'Austin' }, + }); + assert.equal(result.summary, '1 place matching "coffee" in Austin, Texas.'); + assert.deepEqual(result.data.rows[0], { + id: 'a', + name: 'Place a', + address: 'a Main St', + type: 'cafe', + lat: 30.27, + lon: -97.74, + distance_m: 120, + }); + assert.equal(search.calls[0].query, 'coffee'); + assert.ok(Math.abs(search.calls[0].latitude - 30.31) < 1e-9); + assert.equal(search.calls[0].radiusM, 29642); + + const unconfigured = composeCatalog({ + tools: coreTools, + services: { + placeSearch: placeSearch({ configured: false, places: [] }), + places, + }, + }); + await assert.rejects( + unconfigured.call('search_places', { + query: 'coffee', + area: { place: 'Austin' }, + }), + (error) => + error.code === 'unavailable' && /Google Places key/.test(error.message), + ); +}); + +test('nearby places use the given radius', async () => { + const search = placeSearch({ + configured: true, + places: [place('a', 30.27, -97.74)], + }); + const catalog = composeCatalog({ + tools: coreTools, + services: { placeSearch: search }, + }); + const result = await catalog.call('places_nearby', { + lat: 30.27, + lon: -97.74, + radius_m: 400, + }); + assert.equal(result.summary, '1 place within 400 m.'); + assert.deepEqual(search.calls[0], { + latitude: 30.27, + longitude: -97.74, + radiusM: 400, + }); +}); + +test('routes resolve endpoints, map modes and thin long paths', async () => { + const requests = []; + const geometry = Array.from({ length: 250 }, (_, index) => [ + -97.74 + index / 1000, + 30.27, + ]); + const routing = { + async route(points, profile) { + requests.push({ points, profile }); + return { + ok: true, + profile, + distanceM: 312400, + durationS: 11220, + geometry, + }; + }, + }; + const catalog = composeCatalog({ + tools: coreTools, + services: { routing, places }, + }); + const result = await catalog.call('plan_route', { + from: { place: 'Austin' }, + to: { lat: 32.78, lon: -96.8 }, + mode: 'drive', + }); + assert.equal( + result.summary, + 'Drive from Austin, Texas to 32.7800, -96.8000: 312.4 km, about 187 minutes.', + ); + assert.equal(requests[0].profile, 'car'); + assert.deepEqual(requests[0].points[1], { + label: '32.7800, -96.8000', + lat: 32.78, + lon: -96.8, + }); + assert.equal(result.data.path_points, 250); + assert.equal(result.data.path_lon_lat.length, 100); + assert.deepEqual(result.data.path_lon_lat.at(-1), [-97.491, 30.27]); + + const failing = composeCatalog({ + tools: coreTools, + services: { + routing: { + route: async () => ({ ok: false, error: 'route leg too long' }), + }, + places, + }, + }); + await assert.rejects( + failing.call('plan_route', { + from: { place: 'Austin' }, + to: { place: 'Dallas' }, + }), + /No walk route from Austin, Texas to Dallas, Texas: route leg too long/, + ); + await assert.rejects( + catalog.call('plan_route', { + from: { place: 'Austin', lat: 1 }, + to: { lat: 1, lon: 2 }, + }), + /either place, or lat and lon/, + ); +}); + +test('the place and route services read the app routes and translate failures', async () => { + const requested = []; + const respond = (body, status = 200, headers = {}) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }); + const search = createPlaceSearchService({ + fetchImpl: async (url) => { + requested.push(url); + return url.includes('nearby') + ? respond({ configured: false, error: null, places: [] }) + : respond({ places: [place('a', 1, 2)] }); + }, + }); + assert.deepEqual( + await search.search('tea', { latitude: 1, longitude: 2, radiusM: 300 }), + { + configured: true, + places: [place('a', 1, 2)], + }, + ); + assert.deepEqual( + await search.nearby({ latitude: 1, longitude: 2, radiusM: 50 }), + { configured: false, places: [] }, + ); + assert.deepEqual(requested, [ + '/api/google/text-search?q=tea&lat=1&lon=2&radiusM=300', + '/api/google/nearby-places?lat=1&lon=2&radiusM=50', + ]); + + const limited = createPlaceSearchService({ + fetchImpl: async () => + respond({ error: 'Rate limit exceeded', places: [] }, 429, { + 'Retry-After': '30', + }), + }); + await assert.rejects( + limited.nearby({ latitude: 1, longitude: 2, radiusM: 50 }), + (error) => error.code === 'retry_later' && error.retryAfterSeconds === 30, + ); + + const routes = []; + const routing = createRouteService({ + fetchImpl: async (url) => { + routes.push(url); + if (routes.length === 1) + return respond({ + ok: true, + distanceM: 10, + durationS: 5, + geometry: [[2, 1]], + }); + if (routes.length === 2) + return respond({ ok: false, error: 'need 2-12 coordinates' }, 400); + return respond({ ok: false, error: 'rate limited' }, 429, { + 'Retry-After': '5', + }); + }, + }); + const points = [ + { lat: 1, lon: 2 }, + { lat: 1.5, lon: 2.5 }, + ]; + assert.equal((await routing.route(points, 'foot')).distanceM, 10); + assert.equal( + routes[0], + '/api/route?profile=foot&coords=2.000000%2C1.000000%3B2.500000%2C1.500000', + ); + assert.deepEqual(await routing.route(points, 'foot'), { + ok: false, + error: 'need 2-12 coordinates', + }); + await assert.rejects( + routing.route(points, 'foot'), + (error) => error.code === 'retry_later' && error.retryAfterSeconds === 5, + ); +}); diff --git a/src/tools/results.js b/src/tools/results.js index 331e56941..33213da0b 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -31,3 +31,13 @@ export function isoTime(ms) { export function countNoun(count, singular, plural = `${singular}s`) { return `${count} ${count === 1 ? singular : plural}`; } + +/** Keep the first and last items and evenly spaced items between them. */ +export function thinEvenly(items, max) { + if (items.length <= max) return items; + const step = (items.length - 1) / (max - 1); + return Array.from( + { length: max }, + (_, index) => items[Math.round(index * step)], + ); +} From eb12303f46700bcfb67c9b93b0730a004f176ee8 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 12:18:48 -0700 Subject: [PATCH 007/111] refactor: move GBFS station parsing into a portable source module The bike-share layer parsed station documents inside its model factory. Move the parsers unchanged to src/sources/gbfsStations.js and have the model import them, so code outside the layer can read GBFS stations without constructing the layer. --- scripts/package-boundaries.json | 3 + src/layers/bikeshare/model.js | 127 ++------------------------------ src/sources/gbfsStations.js | 115 +++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 119 deletions(-) create mode 100644 src/sources/gbfsStations.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3ccd6bda3..14ce87c0c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1080,6 +1080,7 @@ "src/layers/bikeshare/ingestion.js", "src/layers/bikeshare/lifecycle.js", "src/layers/bikeshare/model.js", + "src/sources/gbfsStations.js", "src/layers/bikeshare/policy.js", "src/layers/bikeshare/queries.js", "src/layers/bikeshare/registry.js", @@ -1526,6 +1527,7 @@ "src/layers/bikeshare/ingestion.js", "src/layers/bikeshare/lifecycle.js", "src/layers/bikeshare/model.js", + "src/sources/gbfsStations.js", "src/layers/bikeshare/policy.js", "src/layers/bikeshare/queries.js", "src/layers/bikeshare/registry.js", @@ -2191,6 +2193,7 @@ "src/layers/bikeshare/ingestion.js", "src/layers/bikeshare/lifecycle.js", "src/layers/bikeshare/model.js", + "src/sources/gbfsStations.js", "src/layers/bikeshare/policy.js", "src/layers/bikeshare/queries.js", "src/layers/bikeshare/registry.js", diff --git a/src/layers/bikeshare/model.js b/src/layers/bikeshare/model.js index 89628f613..f12148dba 100644 --- a/src/layers/bikeshare/model.js +++ b/src/layers/bikeshare/model.js @@ -1,36 +1,12 @@ +import { + extractStationsArray, + normalizeGbfsBool, + parseStationInformation, + parseStationStatus, + toNonNegativeInteger, +} from '../../sources/gbfsStations.js'; + export function createModel({ state: layerState, services, parts, source }) { - /** - * Coerce a GBFS boolean field to a native boolean. - * GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings. - * @param {*} value - Raw field value from GBFS JSON. - * @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized. - * @returns {boolean} - */ - - function normalizeGbfsBool(value, fallback = true) { - if (value == null) return fallback; - if (typeof value === 'boolean') return value; - const num = Number(value); - if (Number.isFinite(num)) return num !== 0; - const text = String(value).trim().toLowerCase(); - if (text === 'true' || text === 'yes') return true; - if (text === 'false' || text === 'no') return false; - return fallback; - } - - /** - * Parse a value as a non-negative integer, returning fallback on failure. - * @param {*} value - Raw numeric value. - * @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number. - * @returns {number|null} - */ - - function toNonNegativeInteger(value, fallback = null) { - const n = Number(value); - if (!Number.isFinite(n) || n < 0) return fallback; - return Math.round(n); - } - /** * Build a globally unique render key for a station. * @param {string} cityId - City identifier. @@ -63,93 +39,6 @@ export function createModel({ state: layerState, services, parts, source }) { return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } - /** - * Extract the stations array from a GBFS JSON payload. - * Handles multiple response shapes: { data: { stations: [...] } }, - * { data: [...] }, and nested objects with a stations sub-key. - * @param {Object} payload - Parsed GBFS JSON response. - * @returns {Object[]} Array of raw station objects (may be empty). - */ - - function extractStationsArray(payload) { - const data = payload?.data; - if (Array.isArray(data?.stations)) return data.stations; - if (Array.isArray(data)) return data; - // Some feeds nest stations under an additional key (e.g. locale wrappers) - if (data && typeof data === 'object') { - for (const value of Object.values(data)) { - if (Array.isArray(value?.stations)) return value.stations; - } - } - return []; - } - - /** - * Parse a GBFS station_information payload into a Map of station metadata. - * Handles field-name variations across different GBFS providers - * (station_id vs id, lat vs latitude, etc.). Stations with missing or - * invalid coordinates are silently skipped. - * @param {Object} payload - Parsed station_information.json response. - * @returns {Map} Map of stationId to station info objects. - */ - - function parseStationInformation(payload) { - const stations = extractStationsArray(payload); - const stationMap = new Map(); - - for (const raw of stations) { - // Accept both GBFS 2.x (station_id) and alternate (id) field names - const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); - const lat = Number(raw?.lat ?? raw?.latitude); - const lon = Number(raw?.lon ?? raw?.longitude); - if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) - continue; - - stationMap.set(stationId, { - stationId, - name: String(raw?.name || raw?.short_name || '').trim(), - lat, - lon, - capacity: toNonNegativeInteger(raw?.capacity), - isInstalled: normalizeGbfsBool(raw?.is_installed, true), - isRenting: normalizeGbfsBool(raw?.is_renting, true), - isReturning: normalizeGbfsBool(raw?.is_returning, true), - }); - } - - return stationMap; - } - - /** - * Parse a GBFS station_status payload into a Map of real-time availability. - * @param {Object} payload - Parsed station_status.json response. - * @returns {Map} Map of stationId to status objects containing - * bikesAvailable, docksAvailable, and operational flags. - */ - - function parseStationStatus(payload) { - const stations = extractStationsArray(payload); - const statusMap = new Map(); - - for (const raw of stations) { - const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); - if (!stationId) continue; - - const bikes = toNonNegativeInteger(raw?.num_bikes_available); - const docks = toNonNegativeInteger(raw?.num_docks_available); - statusMap.set(stationId, { - stationId, - bikesAvailable: bikes, - docksAvailable: docks, - isInstalled: normalizeGbfsBool(raw?.is_installed, true), - isRenting: normalizeGbfsBool(raw?.is_renting, true), - isReturning: normalizeGbfsBool(raw?.is_returning, true), - lastReported: toNonNegativeInteger(raw?.last_reported), - }); - } - - return statusMap; - } return { normalizeGbfsBool, toNonNegativeInteger, diff --git a/src/sources/gbfsStations.js b/src/sources/gbfsStations.js new file mode 100644 index 000000000..9c5b93528 --- /dev/null +++ b/src/sources/gbfsStations.js @@ -0,0 +1,115 @@ +/** Parse GBFS station documents, tolerating field-name variations across providers. */ + +/** + * Coerce a GBFS boolean field to a native boolean. + * GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings. + * @param {*} value - Raw field value from GBFS JSON. + * @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized. + * @returns {boolean} + */ +export function normalizeGbfsBool(value, fallback = true) { + if (value == null) return fallback; + if (typeof value === 'boolean') return value; + const num = Number(value); + if (Number.isFinite(num)) return num !== 0; + const text = String(value).trim().toLowerCase(); + if (text === 'true' || text === 'yes') return true; + if (text === 'false' || text === 'no') return false; + return fallback; +} + +/** + * Parse a value as a non-negative integer, returning fallback on failure. + * @param {*} value - Raw numeric value. + * @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number. + * @returns {number|null} + */ +export function toNonNegativeInteger(value, fallback = null) { + const n = Number(value); + if (!Number.isFinite(n) || n < 0) return fallback; + return Math.round(n); +} + +/** + * Extract the stations array from a GBFS JSON payload. + * Handles multiple response shapes: { data: { stations: [...] } }, + * { data: [...] }, and nested objects with a stations sub-key. + * @param {Object} payload - Parsed GBFS JSON response. + * @returns {Object[]} Array of raw station objects (may be empty). + */ +export function extractStationsArray(payload) { + const data = payload?.data; + if (Array.isArray(data?.stations)) return data.stations; + if (Array.isArray(data)) return data; + // Some feeds nest stations under an additional key (e.g. locale wrappers) + if (data && typeof data === 'object') { + for (const value of Object.values(data)) { + if (Array.isArray(value?.stations)) return value.stations; + } + } + return []; +} + +/** + * Parse a GBFS station_information payload into a Map of station metadata. + * Handles field-name variations across different GBFS providers + * (station_id vs id, lat vs latitude, etc.). Stations with missing or + * invalid coordinates are silently skipped. + * @param {Object} payload - Parsed station_information.json response. + * @returns {Map} Map of stationId to station info objects. + */ +export function parseStationInformation(payload) { + const stations = extractStationsArray(payload); + const stationMap = new Map(); + + for (const raw of stations) { + // Accept both GBFS 2.x (station_id) and alternate (id) field names + const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); + const lat = Number(raw?.lat ?? raw?.latitude); + const lon = Number(raw?.lon ?? raw?.longitude); + if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; + + stationMap.set(stationId, { + stationId, + name: String(raw?.name || raw?.short_name || '').trim(), + lat, + lon, + capacity: toNonNegativeInteger(raw?.capacity), + isInstalled: normalizeGbfsBool(raw?.is_installed, true), + isRenting: normalizeGbfsBool(raw?.is_renting, true), + isReturning: normalizeGbfsBool(raw?.is_returning, true), + }); + } + + return stationMap; +} + +/** + * Parse a GBFS station_status payload into a Map of real-time availability. + * @param {Object} payload - Parsed station_status.json response. + * @returns {Map} Map of stationId to status objects containing + * bikesAvailable, docksAvailable, and operational flags. + */ +export function parseStationStatus(payload) { + const stations = extractStationsArray(payload); + const statusMap = new Map(); + + for (const raw of stations) { + const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); + if (!stationId) continue; + + const bikes = toNonNegativeInteger(raw?.num_bikes_available); + const docks = toNonNegativeInteger(raw?.num_docks_available); + statusMap.set(stationId, { + stationId, + bikesAvailable: bikes, + docksAvailable: docks, + isInstalled: normalizeGbfsBool(raw?.is_installed, true), + isRenting: normalizeGbfsBool(raw?.is_renting, true), + isReturning: normalizeGbfsBool(raw?.is_returning, true), + lastReported: toNonNegativeInteger(raw?.last_reported), + }); + } + + return statusMap; +} From 2ba751ace840143b01d7bf6477810a1e14d22309 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 12:19:19 -0700 Subject: [PATCH 008/111] feat: read the transit feed catalog through the transit source Add getFeeds() to the transit source. It reads the public catalog route the server already serves, so code outside the layer can find feeds near a location without loading the layer. --- scripts/format-scope.json | 3 +- src/layers/transit/source.js | 15 ++++++++++ src/layers/transit/source.test.mjs | 46 ++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/layers/transit/source.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 9c4436652..6cfe9fe4e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -873,5 +873,6 @@ "src/tools/queries/aviation.test.mjs", "src/tools/queries/space.test.mjs", "src/tools/queries/media.test.mjs", - "src/tools/queries/places.test.mjs" + "src/tools/queries/places.test.mjs", + "src/layers/transit/source.test.mjs" ] diff --git a/src/layers/transit/source.js b/src/layers/transit/source.js index 6ccdb9e55..702b5ed00 100644 --- a/src/layers/transit/source.js +++ b/src/layers/transit/source.js @@ -5,6 +5,21 @@ export function createTransitSource({ fetchImpl = (...args) => fetch(...args), } = {}) { return { + /** Read the public catalog of enabled feeds. */ + async getFeeds({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/transit/feeds', { + signal, + headers: { Accept: 'application/json' }, + }); + if (!response.ok) + throw new Error('Transit feeds HTTP ' + response.status); + const body = await response.json(); + signal?.throwIfAborted(); + if (!Array.isArray(body?.feeds)) + throw new Error('Malformed transit feed catalog'); + return body.feeds; + }, getHistory(feedId, vehicleId, { signal } = {}) { signal?.throwIfAborted(); return fetchTransitHistory(feedId, vehicleId, signal, fetchImpl); diff --git a/src/layers/transit/source.test.mjs b/src/layers/transit/source.test.mjs new file mode 100644 index 000000000..88d66664a --- /dev/null +++ b/src/layers/transit/source.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createTransitSource } from './source.js'; + +const json = (body, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +test('the feed catalog is read from the public catalog route', async () => { + const requested = []; + const feeds = [ + { + id: 'mbta', + name: 'MBTA', + center: { lat: 42.36, lon: -71.06 }, + loadRadiusKm: 60, + }, + ]; + const source = createTransitSource({ + fetchImpl: async (url) => { + requested.push(url); + return json({ feeds }); + }, + }); + assert.deepEqual(await source.getFeeds(), feeds); + assert.deepEqual(requested, ['/api/transit/feeds']); +}); + +test('feed catalog failures are reported', async () => { + const failing = createTransitSource({ fetchImpl: async () => json({}, 503) }); + await assert.rejects(failing.getFeeds(), /Transit feeds HTTP 503/); + const malformed = createTransitSource({ + fetchImpl: async () => json({ feeds: {} }), + }); + await assert.rejects(malformed.getFeeds(), /Malformed transit feed catalog/); + const aborted = new AbortController(); + aborted.abort(); + await assert.rejects( + createTransitSource({ + fetchImpl: async () => json({ feeds: [] }), + }).getFeeds({ signal: aborted.signal }), + (error) => error.name === 'AbortError', + ); +}); From 8c97fed92d55881e950403c0339a57f1105a5819 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 12:22:13 -0700 Subject: [PATCH 009/111] feat: add bike-share and transit tools Add get_bike_share over the public GBFS systems and get_transit_vehicles over the GTFS-Realtime feeds. Each finds the systems or feeds whose coverage reaches the area, reads up to three, and reports any that were unavailable instead of failing the whole answer. Transit results carry each feed's attribution and license. --- CHANGELOG.md | 2 +- docs/CURRENT-STATE.md | 3 +- docs/TOOLS.md | 5 +- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 2 + server/mcp/services.js | 8 + src/tools/index.js | 3 + src/tools/localServer.test.mjs | 2 + src/tools/queries/mobility.js | 224 ++++++++++++++++++++++++ src/tools/queries/mobility.test.mjs | 262 ++++++++++++++++++++++++++++ 10 files changed, 510 insertions(+), 4 deletions(-) create mode 100644 src/tools/queries/mobility.js create mode 100644 src/tools/queries/mobility.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 704b1a957..fffa762de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ - Add tools for language-model clients and a local MCP server. `npm run mcp` serves earthquake, active-fire, launch, aircraft, satellite, camera, radio, - place and routing queries over stdio to clients such as Claude Code, reading + place, routing, bike-share and transit queries over stdio to clients such as Claude Code, reading from a running app. Tools are defined once in `gods-eye-view/tools`, reuse the layers' source factories, and are exposed through the protocol adapter in `gods-eye-view/tools/mcp`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b33f0c3c9..fdd1f9260 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,7 +9,8 @@ reading from a running app's `/api` routes (default `http://localhost:4173`). Queries cover earthquakes, active fires, recent launches, aircraft (in an area, by identifier, tracks, and type and route lookups), satellites (next pass over a point, and those overhead now), public cameras (including a -camera's current image), radio stations, place search and routing. Tools reuse the layers' portable source factories, take a +camera's current image), radio stations, place search, routing, bike-share +stations and transit vehicles. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. The app does not import the tools, so its runtime behavior is unchanged. See [tools and the MCP server](TOOLS.md). diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 4618af8ce..cf373d003 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -49,7 +49,8 @@ Services are the portable source factories the layers already use, such as plus a `places` service with `resolve(name, { signal })`. Sources request relative `/api/...` paths through an injected `fetchImpl`, so the same tool code runs wherever an application routes those paths. -`createGeocodePlaceService` resolves place names through `/api/geocode`; +The `bikeshare` service is `{ systems, getStations }`: the system registry and +the GBFS source. `createGeocodePlaceService` resolves place names through `/api/geocode`; `createPlaceSearchService` searches `/api/google/*` and reports when no search key is configured; `createRouteService` plans routes through `/api/route`. @@ -103,3 +104,5 @@ public feeds the sources already use. | `search_places` | `placeSearch` | Points of interest matching a query within an area (Google Places) | | `places_nearby` | `placeSearch` | Notable places around a point (Google Places) | | `plan_route` | `routing` | Walking, driving or cycling route over OpenStreetMap, with a simplified path | +| `get_bike_share` | `bikeshare` | Live GBFS stations in an area, with bikes and docks available | +| `get_transit_vehicles` | `transit` | Live GTFS-Realtime vehicle positions in an area, optionally one route | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6cfe9fe4e..685971b2a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -874,5 +874,6 @@ "src/tools/queries/space.test.mjs", "src/tools/queries/media.test.mjs", "src/tools/queries/places.test.mjs", - "src/layers/transit/source.test.mjs" + "src/layers/transit/source.test.mjs", + "src/tools/queries/mobility.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 14ce87c0c..834b2f8d2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3093,6 +3093,8 @@ "src/tools/queries/aviation.js", "src/tools/queries/media.js", "src/tools/queries/places.js", + "src/tools/queries/mobility.js", + "src/sources/gbfsStations.js", "src/sources/tle.js", "src/data/satellitePass.js" ], diff --git a/server/mcp/services.js b/server/mcp/services.js index 7bfd04a37..401d7c1ea 100644 --- a/server/mcp/services.js +++ b/server/mcp/services.js @@ -3,12 +3,15 @@ * at a running God's Eye View server's `/api` routes. */ +import { GBFS_CITY_REGISTRY } from '../../src/layers/bikeshare/registry.js'; +import { createBikeshareSource } from '../../src/layers/bikeshare/source.js'; import { createCctvSource } from '../../src/layers/cctv/source.js'; import { createUsgsEarthquakeSource } from '../../src/layers/earthquakes/source.js'; import { createFirmsSource } from '../../src/layers/firms/source.js'; import { createLaunchSource } from '../../src/layers/launches/source.js'; import { createRadioSource } from '../../src/layers/radio/source.js'; import { createSatelliteSource } from '../../src/layers/satellites/source.js'; +import { createTransitSource } from '../../src/layers/transit/source.js'; import { createAdsbLolSource, createOpenSkySource, @@ -52,6 +55,11 @@ export function createLocalToolServices(options = {}) { radio: createRadioSource({ fetchImpl }), placeSearch: createPlaceSearchService({ fetchImpl }), routing: createRouteService({ fetchImpl }), + bikeshare: { + systems: GBFS_CITY_REGISTRY, + getStations: createBikeshareSource({ fetchImpl }).getStations, + }, + transit: createTransitSource({ fetchImpl }), places: createGeocodePlaceService({ fetchImpl }), }; } diff --git a/src/tools/index.js b/src/tools/index.js index a6f54b4d1..bcc0e1992 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -10,6 +10,7 @@ import { getAircraftTrack, } from './queries/aviation.js'; import { getActiveFires, getEarthquakes } from './queries/hazards.js'; +import { getBikeShare, getTransitVehicles } from './queries/mobility.js'; import { placesNearby, planRoute, searchPlaces } from './queries/places.js'; import { findCctvCameras, @@ -60,4 +61,6 @@ export const coreTools = Object.freeze([ searchPlaces, placesNearby, planRoute, + getBikeShare, + getTransitVehicles, ]); diff --git a/src/tools/localServer.test.mjs b/src/tools/localServer.test.mjs index 02f705043..c971823d1 100644 --- a/src/tools/localServer.test.mjs +++ b/src/tools/localServer.test.mjs @@ -84,6 +84,8 @@ test('the stdio server answers newline-delimited requests using only its data so 'search_places', 'places_nearby', 'plan_route', + 'get_bike_share', + 'get_transit_vehicles', ], ); assert.equal( diff --git a/src/tools/queries/mobility.js b/src/tools/queries/mobility.js new file mode 100644 index 000000000..8d975648c --- /dev/null +++ b/src/tools/queries/mobility.js @@ -0,0 +1,224 @@ +/** Bike-share and public transit queries. */ + +import { + parseStationInformation, + parseStationStatus, +} from '../../sources/gbfsStations.js'; +import { defineTool, ToolError } from '../catalog.js'; +import { + AREA_SCHEMA, + areaCenter, + areaContains, + areaRadiusKm, + distanceKm, + resolveArea, +} from '../area.js'; +import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; + +const MAX_SYSTEMS = 3; + +const round = (value, digits) => + Number.isFinite(value) ? Number(value.toFixed(digits)) : null; + +/** Systems or feeds whose coverage reaches the area, nearest first. */ +function covering(area, entries, centerOf, radiusOf) { + const center = areaCenter(area); + const reach = areaRadiusKm(area); + return entries + .map((entry) => ({ entry, distance: distanceKm(center, centerOf(entry)) })) + .filter(({ entry, distance }) => distance <= reach + radiusOf(entry)) + .sort((a, b) => a.distance - b.distance) + .slice(0, MAX_SYSTEMS) + .map(({ entry }) => entry); +} + +export const getBikeShare = defineTool({ + name: 'get_bike_share', + title: 'Bike-share stations', + description: + 'Live bike-share stations in an area from public GBFS feeds, nearest the ' + + 'center first, with bikes and docks available.', + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['bikeshare'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + const { bikeshare } = services; + const systems = covering( + area, + bikeshare.systems, + (system) => ({ lat: system.centerLat, lon: system.centerLon }), + (system) => system.loadRadiusKm, + ); + if (!systems.length) + return { + summary: `No supported bike-share system covers ${area.label}.`, + data: { ...capRows([], args.limit), systems: [] }, + }; + const center = areaCenter(area); + const results = await Promise.allSettled( + systems.map(async (system) => { + const [information, status] = await Promise.all([ + bikeshare.getStations(system.stationInformationUrl, { signal }), + bikeshare.getStations(system.stationStatusUrl, { signal }), + ]); + const live = parseStationStatus(status); + return [...parseStationInformation(information).values()].map( + (station) => ({ system, station, live: live.get(station.stationId) }), + ); + }), + ); + signal?.throwIfAborted(); + if (results.every((result) => result.status === 'rejected')) + throw new ToolError( + 'unavailable', + 'Bike-share feeds are unavailable right now', + ); + const rows = results + .flatMap((result) => (result.status === 'fulfilled' ? result.value : [])) + .filter(({ station }) => areaContains(area, station)) + .map(({ system, station, live }) => ({ + system: system.city, + provider: system.provider ?? null, + station_id: station.stationId, + name: station.name || null, + lat: round(station.lat, 6), + lon: round(station.lon, 6), + bikes_available: live?.bikesAvailable ?? null, + docks_available: live?.docksAvailable ?? null, + capacity: station.capacity, + renting: (live?.isRenting ?? station.isRenting) === true, + returning: (live?.isReturning ?? station.isReturning) === true, + last_reported: Number.isFinite(live?.lastReported) + ? isoTime(live.lastReported * 1000) + : null, + distance_km: round(distanceKm(center, station), 2), + })) + .sort((a, b) => a.distance_km - b.distance_km); + const bikes = rows.reduce( + (sum, row) => sum + (row.bikes_available ?? 0), + 0, + ); + const failed = results.filter( + (result) => result.status === 'rejected', + ).length; + return { + summary: + `${countNoun(rows.length, 'bike-share station')} in ${area.label} with ` + + `${countNoun(bikes, 'bike')} available` + + (failed ? ` (${countNoun(failed, 'system')} unavailable).` : '.'), + data: { + ...capRows(rows, args.limit), + systems: systems.map((system) => system.city), + bikes_available: bikes, + }, + }; + }, +}); + +export const getTransitVehicles = defineTool({ + name: 'get_transit_vehicles', + title: 'Transit vehicles', + description: + 'Live positions of buses, trains and other transit vehicles in an area ' + + 'from public GTFS-Realtime feeds, optionally for one route.', + inputSchema: { + type: 'object', + properties: { + area: AREA_SCHEMA, + route: { + type: 'string', + minLength: 1, + maxLength: 64, + description: + 'Route id or vehicle label to match, such as "1" or "Red".', + }, + limit: LIMIT_SCHEMA, + }, + required: ['area'], + additionalProperties: false, + }, + requires: ['transit'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + const feeds = covering( + area, + await services.transit.getFeeds({ signal }), + (feed) => feed.center, + (feed) => feed.loadRadiusKm ?? 0, + ); + if (!feeds.length) + return { + summary: `No supported transit feed covers ${area.label}.`, + data: { ...capRows([], args.limit), feeds: [] }, + }; + const center = areaCenter(area); + const results = await Promise.allSettled( + feeds.map(async (feed) => { + const response = await services.transit.requestSnapshot(feed.id, { + signal, + }); + if (!response.ok) throw new Error(`Transit HTTP ${response.status}`); + const snapshot = await response.json(); + return (snapshot.vehicles || []).map((vehicle) => ({ feed, vehicle })); + }), + ); + signal?.throwIfAborted(); + if (results.every((result) => result.status === 'rejected')) + throw new ToolError( + 'unavailable', + 'Transit feeds are unavailable right now', + ); + const wanted = args.route?.trim().toLowerCase(); + const rows = results + .flatMap((result) => (result.status === 'fulfilled' ? result.value : [])) + .filter(({ vehicle }) => areaContains(area, vehicle)) + .filter( + ({ vehicle }) => + !wanted || + [vehicle.routeId, vehicle.label].some( + (value) => String(value || '').toLowerCase() === wanted, + ), + ) + .map(({ feed, vehicle }) => ({ + feed: feed.name, + id: vehicle.id, + label: vehicle.label ?? null, + route_id: vehicle.routeId ?? null, + trip_id: vehicle.tripId ?? null, + lat: vehicle.lat, + lon: vehicle.lon, + bearing_deg: round(vehicle.bearing, 0), + speed_mps: round(vehicle.speedMps, 1), + status: vehicle.status ?? null, + occupancy: vehicle.occupancy ?? null, + updated: Number.isFinite(vehicle.timestamp) + ? isoTime(vehicle.timestamp * 1000) + : null, + distance_km: round(distanceKm(center, vehicle), 2), + })) + .sort((a, b) => a.distance_km - b.distance_km); + const failed = results.filter( + (result) => result.status === 'rejected', + ).length; + const what = args.route ? ` on route ${args.route}` : ''; + return { + summary: + `${countNoun(rows.length, 'transit vehicle')}${what} in ${area.label}` + + (failed ? ` (${countNoun(failed, 'feed')} unavailable).` : '.'), + data: { + ...capRows(rows, args.limit), + feeds: feeds.map((feed) => ({ + name: feed.name, + operator: feed.operator ?? null, + attribution: feed.attribution ?? null, + license: feed.license ?? null, + })), + }, + }; + }, +}); diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs new file mode 100644 index 000000000..72b7d4a42 --- /dev/null +++ b/src/tools/queries/mobility.test.mjs @@ -0,0 +1,262 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; + +const system = (id, city, centerLat, centerLon) => ({ + id, + city, + centerLat, + centerLon, + loadRadiusKm: 30, + provider: 'Test Bikes', + stationInformationUrl: `https://gbfs.example/${id}/station_information.json`, + stationStatusUrl: `https://gbfs.example/${id}/station_status.json`, +}); +const documents = { + 'https://gbfs.example/austin/station_information.json': { + data: { + stations: [ + { + station_id: 's1', + name: 'Congress & 2nd', + lat: 30.2645, + lon: -97.7445, + capacity: 12, + }, + { + station_id: 's2', + name: 'Far Station', + lat: 30.4, + lon: -97.9, + capacity: 8, + }, + { + station_id: 's3', + name: 'Lamar & 5th', + lat: 30.268, + lon: -97.753, + capacity: '10', + }, + ], + }, + }, + 'https://gbfs.example/austin/station_status.json': { + data: { + stations: [ + { + station_id: 's1', + num_bikes_available: 4, + num_docks_available: 8, + is_renting: 1, + last_reported: 1767225600, + }, + { + station_id: 's3', + num_bikes_available: 0, + num_docks_available: 10, + is_renting: 0, + }, + ], + }, + }, +}; +const bikeshare = (systems, failing = new Set()) => ({ + systems, + async getStations(url) { + if ([...failing].some((id) => url.includes(`/${id}/`))) + throw new Error('GBFS HTTP 502'); + return documents[url]; + }, +}); +const downtown = { lat: 30.2672, lon: -97.7431, radius_km: 2 }; + +test('bike share joins station information with live status inside the area', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + bikeshare: bikeshare([ + system('austin', 'Austin, TX', 30.27, -97.74), + system('oslo', 'Oslo', 59.9, 10.7), + ]), + }, + }); + const result = await catalog.call('get_bike_share', { area: downtown }); + assert.equal( + result.summary, + '2 bike-share stations in 2 km around 30.267, -97.743 with 4 bikes available.', + ); + assert.deepEqual(result.data.systems, ['Austin, TX']); + assert.deepEqual(result.data.rows[0], { + system: 'Austin, TX', + provider: 'Test Bikes', + station_id: 's1', + name: 'Congress & 2nd', + lat: 30.2645, + lon: -97.7445, + bikes_available: 4, + docks_available: 8, + capacity: 12, + renting: true, + returning: true, + last_reported: '2026-01-01T00:00:00.000Z', + distance_km: 0.33, + }); + assert.equal(result.data.rows[1].renting, false); + assert.equal(result.data.rows[1].capacity, 10); +}); + +test('bike share reports uncovered areas and failed systems honestly', async () => { + const none = composeCatalog({ + tools: coreTools, + services: { bikeshare: bikeshare([system('oslo', 'Oslo', 59.9, 10.7)]) }, + }); + const empty = await none.call('get_bike_share', { area: downtown }); + assert.equal( + empty.summary, + 'No supported bike-share system covers 2 km around 30.267, -97.743.', + ); + assert.equal(empty.data.total, 0); + const both = [ + system('austin', 'Austin, TX', 30.27, -97.74), + system('round-rock', 'Round Rock', 30.5, -97.68), + ]; + const partial = composeCatalog({ + tools: coreTools, + services: { bikeshare: bikeshare(both, new Set(['round-rock'])) }, + }); + assert.match( + ( + await partial.call('get_bike_share', { + area: { lat: 30.4, lon: -97.7, radius_km: 20 }, + }) + ).summary, + /\(1 system unavailable\)\.$/, + ); + const down = composeCatalog({ + tools: coreTools, + services: { bikeshare: bikeshare(both, new Set(['austin', 'round-rock'])) }, + }); + await assert.rejects( + down.call('get_bike_share', { area: downtown }), + (error) => error.code === 'unavailable', + ); +}); + +const feed = { + id: 'capmetro', + name: 'CapMetro', + operator: 'Capital Metro', + center: { lat: 30.27, lon: -97.74 }, + loadRadiusKm: 50, + attribution: 'Capital Metro', + license: 'Open data', +}; +const vehicles = [ + { + id: 'v1', + lat: 30.2675, + lon: -97.7425, + bearing: 91.6, + speedMps: 8.23, + timestamp: 1767225600, + routeId: '1', + tripId: 't1', + label: '1 North', + status: 'IN_TRANSIT_TO', + occupancy: 'FEW_SEATS_AVAILABLE', + }, + { + id: 'v2', + lat: 30.268, + lon: -97.745, + bearing: null, + speedMps: null, + timestamp: null, + routeId: '801', + label: 'Rapid', + }, + { id: 'v3', lat: 30.5, lon: -97.6, routeId: '1' }, +]; +const transit = ( + snapshot = { ok: true, status: 200, json: async () => ({ vehicles }) }, +) => ({ + requested: [], + async getFeeds() { + return [ + feed, + { ...feed, id: 'far', name: 'Far Away', center: { lat: 0, lon: 0 } }, + ]; + }, + async requestSnapshot(id) { + this.requested.push(id); + return snapshot; + }, +}); + +test('transit vehicles come from nearby feeds and can be limited to a route', async () => { + const source = transit(); + const catalog = composeCatalog({ + tools: coreTools, + services: { transit: source }, + }); + const all = await catalog.call('get_transit_vehicles', { area: downtown }); + assert.equal( + all.summary, + '2 transit vehicles in 2 km around 30.267, -97.743.', + ); + assert.deepEqual(source.requested, ['capmetro']); + assert.deepEqual(all.data.rows[0], { + feed: 'CapMetro', + id: 'v1', + label: '1 North', + route_id: '1', + trip_id: 't1', + lat: 30.2675, + lon: -97.7425, + bearing_deg: 92, + speed_mps: 8.2, + status: 'IN_TRANSIT_TO', + occupancy: 'FEW_SEATS_AVAILABLE', + updated: '2026-01-01T00:00:00.000Z', + distance_km: 0.07, + }); + assert.deepEqual(all.data.feeds, [ + { + name: 'CapMetro', + operator: 'Capital Metro', + attribution: 'Capital Metro', + license: 'Open data', + }, + ]); + const rapid = await catalog.call('get_transit_vehicles', { + area: downtown, + route: 'rapid', + }); + assert.equal( + rapid.summary, + '1 transit vehicle on route rapid in 2 km around 30.267, -97.743.', + ); + assert.equal(rapid.data.rows[0].id, 'v2'); +}); + +test('transit reports uncovered areas and unavailable feeds', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { transit: transit() }, + }); + const oslo = await catalog.call('get_transit_vehicles', { + area: { lat: 59.9, lon: 10.7, radius_km: 5 }, + }); + assert.equal( + oslo.summary, + 'No supported transit feed covers 5 km around 59.900, 10.700.', + ); + const down = composeCatalog({ + tools: coreTools, + services: { transit: transit({ ok: false, status: 503 }) }, + }); + await assert.rejects( + down.call('get_transit_vehicles', { area: downtown }), + (error) => error.code === 'unavailable', + ); +}); From 0a74e36ce8963ca89249f7d0a9ef98645d595d0c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 12:28:34 -0700 Subject: [PATCH 010/111] feat: add weather, hazard, terrain and situation tools Add get_weather, get_regional_brief, get_cyclones, get_fire_perimeters, get_terrain_height, find_military_installations and get_map_features over the application request services and the layers' sources. situation_brief combines weather, earthquakes, fires, aircraft and cyclones for an area by reusing those tools, marking sections that are unavailable instead of failing. get_hud_caption returns the app's heads-up display caption for the same overview. --- CHANGELOG.md | 4 +- docs/CURRENT-STATE.md | 4 +- docs/TOOLS.md | 14 +- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 2 + server/mcp/services.js | 16 + src/tools/index.js | 19 ++ src/tools/localServer.test.mjs | 22 +- src/tools/queries/brief.js | 139 ++++++++ src/tools/queries/environment.js | 438 ++++++++++++++++++++++++ src/tools/queries/environment.test.mjs | 455 +++++++++++++++++++++++++ 11 files changed, 1093 insertions(+), 23 deletions(-) create mode 100644 src/tools/queries/brief.js create mode 100644 src/tools/queries/environment.js create mode 100644 src/tools/queries/environment.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index fffa762de..10428d684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ - Add tools for language-model clients and a local MCP server. `npm run mcp` serves earthquake, active-fire, launch, aircraft, satellite, camera, radio, - place, routing, bike-share and transit queries over stdio to clients such as Claude Code, reading + place, routing, bike-share, transit, weather, cyclone, fire-perimeter, + terrain, installation and map-feature queries, plus a situation brief, over + stdio to clients such as Claude Code, reading from a running app. Tools are defined once in `gods-eye-view/tools`, reuse the layers' source factories, and are exposed through the protocol adapter in `gods-eye-view/tools/mcp`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdd1f9260..bc1a8c2e1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -10,7 +10,9 @@ Queries cover earthquakes, active fires, recent launches, aircraft (in an area, by identifier, tracks, and type and route lookups), satellites (next pass over a point, and those overhead now), public cameras (including a camera's current image), radio stations, place search, routing, bike-share -stations and transit vehicles. Tools reuse the layers' portable source factories, take a +stations, transit vehicles, weather, regional briefs, tropical cyclones, fire +perimeters, terrain height, military installations and map features, plus a +combined situation brief and the app's heads-up display caption. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. The app does not import the tools, so its runtime behavior is unchanged. See [tools and the MCP server](TOOLS.md). diff --git a/docs/TOOLS.md b/docs/TOOLS.md index cf373d003..699fbcd9d 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -49,7 +49,10 @@ Services are the portable source factories the layers already use, such as plus a `places` service with `resolve(name, { signal })`. Sources request relative `/api/...` paths through an injected `fetchImpl`, so the same tool code runs wherever an application routes those paths. -The `bikeshare` service is `{ systems, getStations }`: the system registry and +The `weather`, `regional`, `terrain`, `summary` and `features` services are the +application request services from `gods-eye-view/application/requests`. +`situation_brief` runs each section whose services are supplied and marks the +others unavailable. The `bikeshare` service is `{ systems, getStations }`: the system registry and the GBFS source. `createGeocodePlaceService` resolves place names through `/api/geocode`; `createPlaceSearchService` searches `/api/google/*` and reports when no search key is configured; `createRouteService` plans routes through `/api/route`. @@ -106,3 +109,12 @@ public feeds the sources already use. | `plan_route` | `routing` | Walking, driving or cycling route over OpenStreetMap, with a simplified path | | `get_bike_share` | `bikeshare` | Live GBFS stations in an area, with bikes and docks available | | `get_transit_vehicles` | `transit` | Live GTFS-Realtime vehicle positions in an area, optionally one route | +| `get_weather` | `weather` | Current conditions at a place or point | +| `get_regional_brief` | `regional` | What and where a location is, its weather and recent headlines | +| `get_cyclones` | `cyclones` | Active NHC/CPHC tropical cyclones, optionally in an area | +| `get_fire_perimeters` | `perimeters` | Mapped WFIGS wildfire perimeters in an area, largest first | +| `get_terrain_height` | `terrain` | Ground, geoid and ellipsoid heights at up to 20 points | +| `find_military_installations` | `installations` | OpenStreetMap military sites in an area of at most 10° per side | +| `get_map_features` | `features` | Administrative areas, named places or monuments at a location (needs Overpass) | +| `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft and cyclones for an area, by section | +| `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 685971b2a..484c19214 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -875,5 +875,6 @@ "src/tools/queries/media.test.mjs", "src/tools/queries/places.test.mjs", "src/layers/transit/source.test.mjs", - "src/tools/queries/mobility.test.mjs" + "src/tools/queries/mobility.test.mjs", + "src/tools/queries/environment.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 834b2f8d2..0b9e5025c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3094,6 +3094,8 @@ "src/tools/queries/media.js", "src/tools/queries/places.js", "src/tools/queries/mobility.js", + "src/tools/queries/environment.js", + "src/tools/queries/brief.js", "src/sources/gbfsStations.js", "src/sources/tle.js", "src/data/satellitePass.js" diff --git a/server/mcp/services.js b/server/mcp/services.js index 401d7c1ea..d65e9e177 100644 --- a/server/mcp/services.js +++ b/server/mcp/services.js @@ -6,12 +6,16 @@ import { GBFS_CITY_REGISTRY } from '../../src/layers/bikeshare/registry.js'; import { createBikeshareSource } from '../../src/layers/bikeshare/source.js'; import { createCctvSource } from '../../src/layers/cctv/source.js'; +import { createCycloneSource } from '../../src/layers/cyclones/source.js'; import { createUsgsEarthquakeSource } from '../../src/layers/earthquakes/source.js'; import { createFirmsSource } from '../../src/layers/firms/source.js'; +import { createInstallationSource } from '../../src/layers/installations/source.js'; import { createLaunchSource } from '../../src/layers/launches/source.js'; +import { createWfigsPerimeterSource } from '../../src/layers/perimeters/source.js'; import { createRadioSource } from '../../src/layers/radio/source.js'; import { createSatelliteSource } from '../../src/layers/satellites/source.js'; import { createTransitSource } from '../../src/layers/transit/source.js'; +import { createApplicationRequestServices } from '../../src/services/requests.js'; import { createAdsbLolSource, createOpenSkySource, @@ -44,6 +48,7 @@ export function createApiFetch({ /** Construct every service Core's tools read, backed by the local server. */ export function createLocalToolServices(options = {}) { const fetchImpl = createApiFetch(options); + const requests = createApplicationRequestServices({ fetchImpl }); return { earthquakes: createUsgsEarthquakeSource({ fetchImpl }), fires: createFirmsSource({ fetchImpl }), @@ -60,6 +65,17 @@ export function createLocalToolServices(options = {}) { getStations: createBikeshareSource({ fetchImpl }).getStations, }, transit: createTransitSource({ fetchImpl }), + weather: requests.weather, + regional: requests.regional, + terrain: requests.terrain, + summary: requests.summary, + features: requests.features, + cyclones: createCycloneSource({ fetchImpl }), + perimeters: createWfigsPerimeterSource({ fetchImpl }), + installations: createInstallationSource({ + fetchImpl, + tileFetchImpl: fetchImpl, + }), places: createGeocodePlaceService({ fetchImpl }), }; } diff --git a/src/tools/index.js b/src/tools/index.js index bcc0e1992..3a753f425 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -9,6 +9,16 @@ import { getAircraftInfo, getAircraftTrack, } from './queries/aviation.js'; +import { getHudCaption, situationBrief } from './queries/brief.js'; +import { + findMilitaryInstallations, + getCyclones, + getFirePerimeters, + getMapFeatures, + getRegionalBrief, + getTerrainHeight, + getWeather, +} from './queries/environment.js'; import { getActiveFires, getEarthquakes } from './queries/hazards.js'; import { getBikeShare, getTransitVehicles } from './queries/mobility.js'; import { placesNearby, planRoute, searchPlaces } from './queries/places.js'; @@ -63,4 +73,13 @@ export const coreTools = Object.freeze([ planRoute, getBikeShare, getTransitVehicles, + getWeather, + getRegionalBrief, + getCyclones, + getFirePerimeters, + getTerrainHeight, + findMilitaryInstallations, + getMapFeatures, + situationBrief, + getHudCaption, ]); diff --git a/src/tools/localServer.test.mjs b/src/tools/localServer.test.mjs index c971823d1..19e09516b 100644 --- a/src/tools/localServer.test.mjs +++ b/src/tools/localServer.test.mjs @@ -4,6 +4,7 @@ import { PassThrough } from 'node:stream'; import { createLocalMcpServer } from '../../server/mcp/server.js'; import { createApiFetch } from '../../server/mcp/services.js'; import { parseArgs, serveStdio } from '../../server/mcp/stdio.js'; +import { coreTools } from './index.js'; const usgs = { type: 'FeatureCollection', @@ -66,27 +67,10 @@ test('the stdio server answers newline-delimited requests using only its data so assert.equal(responses.length, 4); assert.equal(byId.get(1).result.serverInfo.name, 'gods-eye-view'); assert.equal(byId.get(null).error.code, -32700); + // Every Core tool's services are composed locally. assert.deepEqual( byId.get(2).result.tools.map((tool) => tool.name), - [ - 'get_earthquakes', - 'get_active_fires', - 'get_recent_launches', - 'aircraft_in_area', - 'find_aircraft', - 'get_aircraft_track', - 'get_aircraft_info', - 'next_satellite_pass', - 'satellites_overhead', - 'find_cctv_cameras', - 'get_cctv_snapshot', - 'find_radio_stations', - 'search_places', - 'places_nearby', - 'plan_route', - 'get_bike_share', - 'get_transit_vehicles', - ], + coreTools.map((tool) => tool.name), ); assert.equal( byId.get(3).result.content[0].text, diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js new file mode 100644 index 000000000..57d0d4cef --- /dev/null +++ b/src/tools/queries/brief.js @@ -0,0 +1,139 @@ +/** Composite queries that combine other tools' answers for one area. */ + +import { defineTool, ToolError } from '../catalog.js'; +import { AREA_SCHEMA, areaCenter, resolveArea } from '../area.js'; +import { aircraftInArea } from './aviation.js'; +import { getCyclones, getWeather } from './environment.js'; +import { getActiveFires, getEarthquakes } from './hazards.js'; + +const SECTION_LIMIT = 5; + +/** Brief sections: the tool each one reuses and the services it needs. */ +const SECTIONS = [ + { + key: 'weather', + tool: getWeather, + args: (area, center) => ({ + location: { lat: center.lat, lon: center.lon }, + }), + }, + { + key: 'earthquakes', + tool: getEarthquakes, + args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), + }, + { + key: 'fires', + tool: getActiveFires, + args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), + }, + { + key: 'aircraft', + tool: aircraftInArea, + args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), + }, + { + key: 'cyclones', + tool: getCyclones, + args: (area) => ({ area: area.argument }), + }, +]; + +/** + * Run every section whose services are supplied. A failing section is + * reported as unavailable instead of failing the brief. + */ +async function buildBrief(args, { services, signal }) { + const resolved = await resolveArea(args.area, { services, signal }); + // Sections receive the resolved box so a place name is looked up once. + const area = { + ...resolved, + argument: resolved.center + ? { + lat: resolved.center.lat, + lon: resolved.center.lon, + radius_km: resolved.center.radiusKm, + } + : { + bbox: [resolved.west, resolved.south, resolved.east, resolved.north], + }, + }; + const center = areaCenter(resolved); + const sections = SECTIONS.filter(({ tool }) => + tool.requires.every((key) => services[key] != null), + ); + const results = await Promise.allSettled( + sections.map(({ tool, args: build }) => + tool.run(build(area, center), { services, signal }), + ), + ); + signal?.throwIfAborted(); + const brief = {}; + const lines = []; + sections.forEach(({ key }, index) => { + const result = results[index]; + if (result.status === 'fulfilled') { + brief[key] = { summary: result.value.summary, data: result.value.data }; + lines.push(result.value.summary); + } else { + const known = result.reason instanceof ToolError; + brief[key] = { + unavailable: true, + reason: known ? result.reason.message : 'unavailable right now', + }; + } + }); + return { area: resolved, center, brief, lines }; +} + +export const situationBrief = defineTool({ + name: 'situation_brief', + title: 'Situation brief', + description: + 'One overview of an area: current weather, recent earthquakes, active ' + + 'fires, aircraft overhead and tropical cyclones, each summarized with ' + + 'its top items. Sections that are unavailable are marked as such.', + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['weather'], + async run(args, context) { + const { area, brief, lines } = await buildBrief(args, context); + return { + summary: `Situation in ${area.label}: ${lines.join(' ')}`, + data: { area: area.label, sections: brief }, + }; + }, +}); + +export const getHudCaption = defineTool({ + name: 'get_hud_caption', + title: 'Heads-up display caption', + description: + "The short heads-up display caption God's Eye View would show for an " + + 'area, written by the app from the same overview situation_brief gives.', + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['weather', 'summary'], + async run(args, context) { + const { area, center, lines } = await buildBrief(args, context); + const response = await context.services.summary.summarize( + { location: area.label, center, observations: lines }, + { signal: context.signal }, + ); + const caption = response?.data?.summary; + if (!response?.ok || typeof caption !== 'string' || !caption) + throw new ToolError('unavailable', 'The caption service did not answer'); + return { + summary: caption, + data: { area: area.label, caption }, + }; + }, +}); diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js new file mode 100644 index 000000000..8e053f2e4 --- /dev/null +++ b/src/tools/queries/environment.js @@ -0,0 +1,438 @@ +/** + * Environment queries: weather, regional briefs, cyclones, fire perimeters, + * terrain height, mapped military sites and map features. + */ + +import { defineTool, ToolError } from '../catalog.js'; +import { + AREA_SCHEMA, + POINT_SCHEMA, + areaCenter, + areaContains, + distanceKm, + resolveArea, + resolvePoint, +} from '../area.js'; +import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; + +const MAX_TERRAIN_POINTS = 20; +const MAX_SITE_BOX_DEGREES = 10; +const FEATURE_KINDS = { + administrative_areas: 'getAdministrativeAreas', + named_places: 'getEnclosingAreas', + monuments: 'getMonuments', +}; + +const round = (value, digits) => + Number.isFinite(value) ? Number(value.toFixed(digits)) : null; + +// WMO weather interpretation codes used by the weather provider. +const WEATHER_CODES = { + 0: 'clear sky', + 1: 'mainly clear', + 2: 'partly cloudy', + 3: 'overcast', + 45: 'fog', + 48: 'depositing rime fog', + 51: 'light drizzle', + 53: 'drizzle', + 55: 'dense drizzle', + 56: 'light freezing drizzle', + 57: 'freezing drizzle', + 61: 'light rain', + 63: 'rain', + 65: 'heavy rain', + 66: 'light freezing rain', + 67: 'freezing rain', + 71: 'light snow', + 73: 'snow', + 75: 'heavy snow', + 77: 'snow grains', + 80: 'light rain showers', + 81: 'rain showers', + 82: 'violent rain showers', + 85: 'light snow showers', + 86: 'snow showers', + 95: 'thunderstorm', + 96: 'thunderstorm with light hail', + 99: 'thunderstorm with heavy hail', +}; + +function weatherRow(weather) { + if (!weather) return null; + return { + observed_at: weather.observedAt ?? null, + conditions: WEATHER_CODES[weather.weatherCode] ?? null, + weather_code: weather.weatherCode ?? null, + temperature_c: weather.temperatureC ?? null, + feels_like_c: weather.apparentTemperatureC ?? null, + precipitation_mm: weather.precipitationMm ?? null, + cloud_cover_pct: weather.cloudCoverPct ?? null, + wind_kph: weather.windKph ?? null, + wind_direction_deg: weather.windDirectionDeg ?? null, + visibility_m: weather.visibilityM ?? null, + }; +} + +function describeWeather(row) { + if (!row) return 'no current weather'; + const parts = [ + row.conditions, + Number.isFinite(row.temperature_c) && `${Math.round(row.temperature_c)}°C`, + Number.isFinite(row.wind_kph) && `wind ${Math.round(row.wind_kph)} km/h`, + ].filter(Boolean); + return parts.join(', ') || 'conditions unknown'; +} + +export const getWeather = defineTool({ + name: 'get_weather', + title: 'Current weather', + description: + 'Current weather at a location: conditions, temperature, feels-like, ' + + 'precipitation, cloud cover, wind and visibility.', + inputSchema: { + type: 'object', + properties: { location: POINT_SCHEMA }, + required: ['location'], + additionalProperties: false, + }, + requires: ['weather'], + async run(args, { services, signal }) { + const point = await resolvePoint(args.location, { services, signal }); + const payload = await services.weather.getConditions(point.lat, point.lon, { + signal, + }); + const row = weatherRow(payload?.weather); + return { + summary: `Weather at ${point.label}: ${describeWeather(row)}.`, + data: { location: point, weather: row }, + }; + }, +}); + +export const getRegionalBrief = defineTool({ + name: 'get_regional_brief', + title: 'Regional brief', + description: + 'What and where a location is, its current weather, and recent news ' + + 'headlines for the region.', + inputSchema: { + type: 'object', + properties: { location: POINT_SCHEMA }, + required: ['location'], + additionalProperties: false, + }, + requires: ['regional'], + async run(args, { services, signal }) { + const point = await resolvePoint(args.location, { services, signal }); + const brief = await services.regional.getBrief(point.lat, point.lon, { + signal, + }); + const place = brief?.place ?? null; + const articles = (brief?.articles || []).slice(0, 10).map((article) => ({ + title: article.title ?? null, + url: article.url ?? null, + source: article.domain ?? null, + published_at: article.publishedAt ?? null, + })); + const weather = weatherRow(brief?.weather); + const label = place?.label || point.label; + return { + summary: `${label}: ${describeWeather(weather)}; ${countNoun(articles.length, 'recent headline')}.`, + data: { + location: point, + place: place && { + label: place.label ?? null, + locality: place.locality ?? null, + region: place.region ?? null, + country: place.country ?? null, + kind: place.kind ?? null, + }, + weather, + news_source: brief?.newsSource ?? null, + articles, + }, + }; + }, +}); + +export const getCyclones = defineTool({ + name: 'get_cyclones', + title: 'Tropical cyclones', + description: + 'Active tropical cyclones from the NOAA National Hurricane Center and ' + + 'Central Pacific Hurricane Center, optionally limited to an area.', + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA }, + additionalProperties: false, + }, + requires: ['cyclones'], + async run(args, { services, signal }) { + const area = args.area + ? await resolveArea(args.area, { services, signal }) + : null; + const snapshot = await services.cyclones.getSnapshot({ signal }); + if (snapshot.unavailable) + throw new ToolError( + 'unavailable', + 'Cyclone advisories are unavailable right now', + ); + const storms = snapshot.storms + .filter( + (storm) => + !area || + areaContains(area, { + lat: storm.position.latitude, + lon: storm.position.longitude, + }), + ) + .map((storm) => ({ + id: storm.id, + name: storm.name, + classification: storm.classification, + basin: storm.basin, + lat: storm.position.latitude, + lon: storm.position.longitude, + position_at: storm.positionAt, + wind_kt: storm.windKt, + pressure_hpa: storm.pressureHpa, + movement: storm.movement ?? null, + advisory: storm.advisoryNumber, + issued_at: storm.issuedAt, + advisory_url: storm.advisoryUrl, + })) + .sort((a, b) => (b.wind_kt ?? 0) - (a.wind_kt ?? 0)); + const where = area ? ` in ${area.label}` : ''; + return { + summary: + `${countNoun(storms.length, 'active tropical cyclone')}${where}` + + (storms[0] + ? `; strongest ${storms[0].name} at ${storms[0].wind_kt} kt.` + : '.'), + data: { + storms, + coverage: snapshot.coverage ?? null, + source: snapshot.source ?? null, + attribution: snapshot.attribution ?? null, + stale: snapshot.stale === true, + }, + }; + }, +}); + +export const getFirePerimeters = defineTool({ + name: 'get_fire_perimeters', + title: 'Wildfire perimeters', + description: + 'Mapped wildfire perimeters from the US WFIGS service in an area, ' + + 'largest first, with size, containment, cause and personnel.', + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['perimeters'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + const rows = (await services.perimeters.getSnapshot({ signal })) + .map((fire) => ({ fire, point: polygonCenter(fire.polygons) })) + .filter(({ point }) => point && areaContains(area, point)) + .map(({ fire, point }) => ({ + id: fire.stableId, + name: fire.name, + acres: round(fire.acres, 0), + contained_pct: fire.containedPct, + state: fire.state, + county: fire.county, + category: fire.category, + cause: fire.cause, + behavior: fire.behavior, + personnel: fire.personnel, + discovered: isoTime(fire.discoveredTime), + updated: isoTime(fire.updatedTime), + lat: round(point.lat, 5), + lon: round(point.lon, 5), + })) + .sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0)); + return { + summary: `${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}.`, + data: capRows(rows, args.limit), + }; + }, +}); + +/** The average vertex of a perimeter's outer rings, enough to place it in an area. */ +function polygonCenter(polygons) { + let lat = 0; + let lon = 0; + let count = 0; + for (const polygon of polygons || []) { + for (const [x, y] of polygon?.[0] || []) { + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + lon += x; + lat += y; + count += 1; + } + } + return count ? { lat: lat / count, lon: lon / count } : null; +} + +export const getTerrainHeight = defineTool({ + name: 'get_terrain_height', + title: 'Terrain height', + description: + 'Ground elevation above sea level at up to 20 points, with the geoid ' + + 'and ellipsoid heights.', + inputSchema: { + type: 'object', + properties: { + points: { + type: 'array', + minItems: 1, + maxItems: MAX_TERRAIN_POINTS, + items: { + type: 'object', + properties: { + lat: { type: 'number', minimum: -90, maximum: 90 }, + lon: { type: 'number', minimum: -180, maximum: 180 }, + }, + required: ['lat', 'lon'], + additionalProperties: false, + }, + }, + }, + required: ['points'], + additionalProperties: false, + }, + requires: ['terrain'], + async run(args, { services, signal }) { + const results = await services.terrain.getHeights(args.points, { signal }); + if (!Array.isArray(results) || results.length !== args.points.length) + throw new ToolError('malformed', 'Terrain heights were incomplete'); + const rows = args.points.map((point, index) => ({ + lat: point.lat, + lon: point.lon, + elevation_m: round(results[index]?.elevation, 1), + geoid_m: round(results[index]?.geoid, 1), + ellipsoid_m: round(results[index]?.ellipsoid, 1), + })); + const first = rows[0]; + return { + summary: + rows.length === 1 + ? `Ground elevation is ${first.elevation_m ?? 'unknown'} m at ${first.lat}, ${first.lon}.` + : `Ground elevation at ${countNoun(rows.length, 'point')}.`, + data: { points: rows }, + }; + }, +}); + +export const findMilitaryInstallations = defineTool({ + name: 'find_military_installations', + title: 'Military installations', + description: + 'Military installations mapped in OpenStreetMap within an area of at ' + + `most ${MAX_SITE_BOX_DEGREES}° on each side, nearest the center first.`, + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['installations'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + if ( + area.west > area.east || + area.north - area.south > MAX_SITE_BOX_DEGREES || + area.east - area.west > MAX_SITE_BOX_DEGREES + ) + throw new ToolError( + 'invalid_arguments', + `The area must be at most ${MAX_SITE_BOX_DEGREES}° on each side and not cross the antimeridian`, + ); + const center = areaCenter(area); + const result = await services.installations.getMappedSites(area, { + signal, + }); + const rows = (result.records || []) + .map((site) => ({ + site, + point: { lat: site.latitude, lon: site.longitude }, + })) + .filter(({ point }) => areaContains(area, point)) + .map(({ site, point }) => ({ + id: site.id, + name: site.name ?? null, + kind: site.kind ?? null, + class: site.class ?? null, + lat: round(point.lat, 5), + lon: round(point.lon, 5), + distance_km: round(distanceKm(center, point), 1), + })) + .sort((a, b) => a.distance_km - b.distance_km); + return { + summary: `${countNoun(rows.length, 'mapped military installation')} in ${area.label}.`, + data: { ...capRows(rows, args.limit), source: result.source ?? null }, + }; + }, +}); + +export const getMapFeatures = defineTool({ + name: 'get_map_features', + title: 'Map features', + description: + 'Named OpenStreetMap features at a location: the administrative areas ' + + 'containing it, named parks, land uses and water nearby, or monuments ' + + 'and memorials within 2.5 km.', + inputSchema: { + type: 'object', + properties: { + location: POINT_SCHEMA, + kind: { type: 'string', enum: Object.keys(FEATURE_KINDS) }, + limit: LIMIT_SCHEMA, + }, + required: ['location', 'kind'], + additionalProperties: false, + }, + requires: ['features'], + async run(args, { services, signal }) { + const point = await resolvePoint(args.location, { services, signal }); + const features = await services.features[FEATURE_KINDS[args.kind]]( + { lat: point.lat, lon: point.lon }, + { signal }, + ); + if (features?.unavailable) + throw new ToolError( + 'unavailable', + 'Map feature lookups need an Overpass server configured for this app', + ); + if (!Array.isArray(features)) + throw new ToolError( + 'retry_later', + 'The map feature service did not answer', + ); + const rows = features + .map((feature) => ({ + id: feature.id, + name: + feature.names?.english || + feature.names?.primary || + feature.names?.official || + null, + category: feature.category, + admin_level: feature.level === 99 ? null : feature.level, + lat: round(feature.point?.lat, 5), + lon: round(feature.point?.lon, 5), + })) + .filter((row) => row.name) + .sort((a, b) => (a.admin_level ?? 99) - (b.admin_level ?? 99)); + const kind = args.kind.replace('_', ' '); + return { + summary: `${countNoun(rows.length, 'named feature')} (${kind}) at ${point.label}.`, + data: { location: point, ...capRows(rows, args.limit) }, + }; + }, +}); diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs new file mode 100644 index 000000000..4c9337a96 --- /dev/null +++ b/src/tools/queries/environment.test.mjs @@ -0,0 +1,455 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools, ToolError } from '../index.js'; + +const conditions = { + observedAt: '2026-10-01T19:15:00.000Z', + temperatureC: 26.3, + apparentTemperatureC: 32, + precipitationMm: 0, + cloudCoverPct: 100, + windKph: 5.4, + windDirectionDeg: 318, + visibilityM: 15400, + weatherCode: 3, +}; +const weather = { + calls: [], + async getConditions(latitude, longitude) { + this.calls.push([latitude, longitude]); + return { status: 'ready', weather: conditions }; + }, +}; +const places = { + resolve: async (name) => + name === 'Austin' + ? { + name: 'Austin, Texas', + bounds: { west: -97.94, south: 30.1, east: -97.56, north: 30.52 }, + } + : null, +}; +const catalogWith = (services) => + composeCatalog({ tools: coreTools, services }); + +test('weather describes current conditions at a point or place', async () => { + const catalog = catalogWith({ weather, places }); + const result = await catalog.call('get_weather', { + location: { place: 'Austin' }, + }); + assert.equal( + result.summary, + 'Weather at Austin, Texas: overcast, 26°C, wind 5 km/h.', + ); + assert.deepEqual(result.data.weather, { + observed_at: '2026-10-01T19:15:00.000Z', + conditions: 'overcast', + weather_code: 3, + temperature_c: 26.3, + feels_like_c: 32, + precipitation_mm: 0, + cloud_cover_pct: 100, + wind_kph: 5.4, + wind_direction_deg: 318, + visibility_m: 15400, + }); + const [latitude, longitude] = weather.calls.at(-1); + assert.ok(Math.abs(latitude - 30.31) < 1e-9 && longitude === -97.75); +}); + +test('the regional brief returns place, weather and at most ten headlines', async () => { + const regional = { + async getBrief() { + return { + place: { + label: 'Edwards Plateau', + locality: null, + region: 'Edwards Plateau', + country: null, + kind: 'natural', + }, + weather: conditions, + newsSource: 'Google News RSS', + articles: Array.from({ length: 12 }, (_, index) => ({ + title: `Story ${index}`, + url: `https://news.example/${index}`, + domain: 'news.example', + publishedAt: '2026-10-01T00:00:00Z', + })), + }; + }, + }; + const result = await catalogWith({ regional }).call('get_regional_brief', { + location: { lat: 30.27, lon: -97.74 }, + }); + assert.equal( + result.summary, + 'Edwards Plateau: overcast, 26°C, wind 5 km/h; 10 recent headlines.', + ); + assert.equal(result.data.articles.length, 10); + assert.deepEqual(result.data.articles[0], { + title: 'Story 0', + url: 'https://news.example/0', + source: 'news.example', + published_at: '2026-10-01T00:00:00Z', + }); +}); + +const storm = (id, name, latitude, longitude, windKt) => ({ + id, + name, + classification: 'Hurricane', + basin: 'Atlantic', + position: { latitude, longitude }, + positionAt: '2026-10-01T18:00:00.000Z', + advisoryNumber: '12', + issuedAt: '2026-10-01T15:00:00.000Z', + windKt, + pressureHpa: 980, + movement: { towardDeg: 300, speedKt: 10 }, + advisoryUrl: 'https://www.nhc.noaa.gov/text/X.shtml', +}); + +test('cyclones are listed strongest first and can be limited to an area', async () => { + const cyclones = { + async getSnapshot() { + return { + source: 'NOAA NHC / CPHC', + coverage: 'Atlantic and eastern/central North Pacific', + stale: false, + unavailable: false, + storms: [ + storm('a', 'Alpha', 25, -70, 65), + storm('b', 'Beta', 15, -110, 120), + ], + }; + }, + }; + const all = await catalogWith({ cyclones }).call('get_cyclones', {}); + assert.equal( + all.summary, + '2 active tropical cyclones; strongest Beta at 120 kt.', + ); + assert.deepEqual( + all.data.storms.map((row) => row.id), + ['b', 'a'], + ); + const atlantic = await catalogWith({ cyclones }).call('get_cyclones', { + area: { bbox: [-100, 0, -10, 50] }, + }); + assert.equal( + atlantic.summary, + '1 active tropical cyclone in the requested box; strongest Alpha at 65 kt.', + ); + const down = { getSnapshot: async () => ({ unavailable: true, storms: [] }) }; + await assert.rejects( + catalogWith({ cyclones: down }).call('get_cyclones', {}), + (error) => error.code === 'unavailable', + ); +}); + +test('fire perimeters are placed by their outline and sorted by size', async () => { + const ring = (lon, lat) => [ + [ + [lon, lat], + [lon + 0.1, lat], + [lon + 0.1, lat + 0.1], + [lon, lat + 0.1], + ], + ]; + const perimeters = { + async getSnapshot() { + return [ + { + stableId: 'small', + name: 'Small Fire', + acres: 120.4, + containedPct: 80, + state: 'US-CA', + county: 'Kern', + category: 'WF', + cause: 'Human', + behavior: null, + personnel: 20, + discoveredTime: Date.UTC(2026, 8, 1), + updatedTime: null, + polygons: [ring(-118.5, 35.2)], + }, + { + stableId: 'big', + name: 'Big Fire', + acres: 5000, + containedPct: 10, + polygons: [ring(-118.8, 35.4)], + }, + { + stableId: 'far', + name: 'Far Fire', + acres: 9000, + polygons: [ring(-80, 30)], + }, + { stableId: 'empty', name: 'No Outline', acres: 1, polygons: [] }, + ]; + }, + }; + const result = await catalogWith({ perimeters }).call('get_fire_perimeters', { + area: { bbox: [-120, 34, -117, 36] }, + }); + assert.equal( + result.summary, + '2 mapped wildfire perimeters in the requested box.', + ); + assert.deepEqual( + result.data.rows.map((row) => row.id), + ['big', 'small'], + ); + assert.deepEqual(result.data.rows[1], { + id: 'small', + name: 'Small Fire', + acres: 120, + contained_pct: 80, + state: 'US-CA', + county: 'Kern', + category: 'WF', + cause: 'Human', + behavior: null, + personnel: 20, + discovered: '2026-09-01T00:00:00.000Z', + updated: null, + lat: 35.25, + lon: -118.45, + }); +}); + +test('terrain heights are returned in request order', async () => { + const terrain = { + async getHeights(points) { + return points.map((point) => ({ + ...point, + elevation: point.lat * 100, + geoid: -30.04, + ellipsoid: point.lat * 100 - 30.04, + })); + }, + }; + const one = await catalogWith({ terrain }).call('get_terrain_height', { + points: [{ lat: 27.9881, lon: 86.925 }], + }); + assert.equal(one.summary, 'Ground elevation is 2798.8 m at 27.9881, 86.925.'); + const many = await catalogWith({ terrain }).call('get_terrain_height', { + points: [ + { lat: 1, lon: 1 }, + { lat: 2, lon: 2 }, + ], + }); + assert.deepEqual( + many.data.points.map((point) => point.elevation_m), + [100, 200], + ); + const short = { getHeights: async () => [] }; + await assert.rejects( + catalogWith({ terrain: short }).call('get_terrain_height', { + points: [{ lat: 1, lon: 1 }], + }), + (error) => error.code === 'malformed', + ); + await assert.rejects( + catalogWith({ terrain }).call('get_terrain_height', { + points: Array(21).fill({ lat: 0, lon: 0 }), + }), + /at most 20 items/, + ); +}); + +test('military installations need a bounded area and are nearest first', async () => { + const requested = []; + const installations = { + async getMappedSites(box) { + requested.push(box); + return { + source: 'OpenStreetMap', + records: [ + { + id: 'osm:1', + kind: 'installation', + class: 'base', + name: 'Naval Base Point Loma', + latitude: 32.6941, + longitude: -117.2494, + }, + { + id: 'osm:2', + kind: 'installation', + class: 'airfield', + name: 'North Island', + latitude: 32.7, + longitude: -117.2, + }, + ], + }; + }, + }; + const catalog = catalogWith({ installations }); + const result = await catalog.call('find_military_installations', { + area: { lat: 32.7, lon: -117.2, radius_km: 20 }, + }); + assert.equal( + result.summary, + '2 mapped military installations in 20 km around 32.700, -117.200.', + ); + assert.deepEqual( + result.data.rows.map((row) => row.id), + ['osm:2', 'osm:1'], + ); + assert.equal(result.data.source, 'OpenStreetMap'); + assert.ok(requested[0].north - requested[0].south < 1); + await assert.rejects( + catalog.call('find_military_installations', { + area: { bbox: [-130, 20, -100, 50] }, + }), + /at most 10° on each side/, + ); +}); + +test('map features name what is at a location and report an unconfigured server', async () => { + const features = { + async getAdministrativeAreas() { + return [ + { + id: 1, + category: 'administrative', + names: { primary: 'Texas' }, + level: 4, + point: null, + }, + { + id: 2, + category: 'administrative', + names: { primary: 'Travis County', english: 'Travis County' }, + level: 6, + point: null, + }, + { id: 3, category: 'administrative', names: {}, level: 8, point: null }, + ]; + }, + getEnclosingAreas: async () => null, + getMonuments: async () => ({ + unavailable: true, + code: 'OVERPASS_NOT_CONFIGURED', + }), + }; + const catalog = catalogWith({ features }); + const result = await catalog.call('get_map_features', { + location: { lat: 30.27, lon: -97.74 }, + kind: 'administrative_areas', + }); + assert.equal( + result.summary, + '2 named features (administrative areas) at 30.2700, -97.7400.', + ); + assert.deepEqual( + result.data.rows.map((row) => [row.name, row.admin_level]), + [ + ['Texas', 4], + ['Travis County', 6], + ], + ); + await assert.rejects( + catalog.call('get_map_features', { + location: { lat: 30.27, lon: -97.74 }, + kind: 'monuments', + }), + (error) => error.code === 'unavailable' && /Overpass/.test(error.message), + ); + await assert.rejects( + catalog.call('get_map_features', { + location: { lat: 30.27, lon: -97.74 }, + kind: 'named_places', + }), + (error) => error.code === 'retry_later', + ); +}); + +test('the situation brief combines available sections and marks failures', async () => { + const services = { + weather, + earthquakes: { + getSnapshot: async () => [ + { + stableId: 'q', + usgsId: 'us1', + lat: 30.3, + lon: -97.7, + mag: 3.1, + place: 'near Austin', + time: 0, + depthKm: 5, + }, + ], + }, + fires: { getSnapshot: async () => ({ keyRequired: true }) }, + cyclones: { + getSnapshot: async () => { + throw new Error('upstream down'); + }, + }, + places, + }; + const result = await catalogWith(services).call('situation_brief', { + area: { place: 'Austin' }, + }); + assert.equal( + result.summary, + 'Situation in Austin, Texas: Weather at 30.3100, -97.7500: overcast, 26°C, wind 5 km/h. ' + + '1 earthquake of M2.5+ in the last 24 hours in the requested box; strongest M3.1 near Austin.', + ); + assert.deepEqual(Object.keys(result.data.sections), [ + 'weather', + 'earthquakes', + 'fires', + 'cyclones', + ]); + assert.deepEqual(result.data.sections.fires, { + unavailable: true, + reason: 'Fire detections need a NASA FIRMS key configured for this server', + }); + assert.deepEqual(result.data.sections.cyclones, { + unavailable: true, + reason: 'unavailable right now', + }); + assert.equal(result.data.sections.aircraft, undefined); +}); + +test('the HUD caption summarizes the brief through the summary service', async () => { + const sent = []; + const summary = { + async summarize(context) { + sent.push(context); + return { + ok: true, + status: 200, + data: { summary: 'Overcast skies over Austin', error: null }, + }; + }, + }; + const result = await catalogWith({ weather, summary, places }).call( + 'get_hud_caption', + { area: { place: 'Austin' } }, + ); + assert.equal(result.summary, 'Overcast skies over Austin'); + assert.equal(sent[0].location, 'Austin, Texas'); + assert.match(sent[0].observations[0], /^Weather at /); + const failing = { + summarize: async () => ({ + ok: false, + status: 502, + data: { summary: null }, + }), + }; + await assert.rejects( + catalogWith({ weather, summary: failing, places }).call('get_hud_caption', { + area: { place: 'Austin' }, + }), + (error) => error instanceof ToolError && error.code === 'unavailable', + ); +}); From e6cbcc31ad8b8eff9baa647f95c793db76ae47e2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 12:32:09 -0700 Subject: [PATCH 011/111] feat: add ship tools Add vessels_in_area, find_vessel and get_vessel_track over the live vessel source, and a ships section in situation_brief. Without an AIS key the source's own message reaches clients as an unavailable feed. --- CHANGELOG.md | 2 +- docs/CURRENT-STATE.md | 3 +- docs/TOOLS.md | 5 +- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 1 + server/mcp/services.js | 5 + src/tools/index.js | 8 ++ src/tools/queries/brief.js | 8 +- src/tools/queries/maritime.js | 182 ++++++++++++++++++++++++++++ src/tools/queries/maritime.test.mjs | 181 +++++++++++++++++++++++++++ 10 files changed, 393 insertions(+), 5 deletions(-) create mode 100644 src/tools/queries/maritime.js create mode 100644 src/tools/queries/maritime.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 10428d684..daf8505e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog - Add tools for language-model clients and a local MCP server. `npm run mcp` - serves earthquake, active-fire, launch, aircraft, satellite, camera, radio, + serves earthquake, active-fire, launch, aircraft, ship, satellite, camera, radio, place, routing, bike-share, transit, weather, cyclone, fire-perimeter, terrain, installation and map-feature queries, plus a situation brief, over stdio to clients such as Claude Code, reading diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bc1a8c2e1..afacd89dc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -7,7 +7,8 @@ and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client, reading from a running app's `/api` routes (default `http://localhost:4173`). Queries cover earthquakes, active fires, recent launches, aircraft (in an -area, by identifier, tracks, and type and route lookups), satellites (next +area, by identifier, tracks, and type and route lookups), ships (in an area, +by identifier, and tracks), satellites (next pass over a point, and those overhead now), public cameras (including a camera's current image), radio stations, place search, routing, bike-share stations, transit vehicles, weather, regional briefs, tropical cyclones, fire diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 699fbcd9d..66c836cd7 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -99,6 +99,9 @@ public feeds the sources already use. | `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration | | `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points | | `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb | +| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type | +| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name | +| `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points | | `next_satellite_pass` | `satellites` | Next pass over a point (default the ISS), with naked-eye visibility | | `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a point now, highest first | | `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first | @@ -116,5 +119,5 @@ public feeds the sources already use. | `get_terrain_height` | `terrain` | Ground, geoid and ellipsoid heights at up to 20 points | | `find_military_installations` | `installations` | OpenStreetMap military sites in an area of at most 10° per side | | `get_map_features` | `features` | Administrative areas, named places or monuments at a location (needs Overpass) | -| `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft and cyclones for an area, by section | +| `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, by section | | `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 484c19214..c868f363a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -876,5 +876,6 @@ "src/tools/queries/places.test.mjs", "src/layers/transit/source.test.mjs", "src/tools/queries/mobility.test.mjs", - "src/tools/queries/environment.test.mjs" + "src/tools/queries/environment.test.mjs", + "src/tools/queries/maritime.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 0b9e5025c..21a5a9266 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3096,6 +3096,7 @@ "src/tools/queries/mobility.js", "src/tools/queries/environment.js", "src/tools/queries/brief.js", + "src/tools/queries/maritime.js", "src/sources/gbfsStations.js", "src/sources/tle.js", "src/data/satellitePass.js" diff --git a/server/mcp/services.js b/server/mcp/services.js index d65e9e177..1aa20af4e 100644 --- a/server/mcp/services.js +++ b/server/mcp/services.js @@ -18,6 +18,7 @@ import { createTransitSource } from '../../src/layers/transit/source.js'; import { createApplicationRequestServices } from '../../src/services/requests.js'; import { createAdsbLolSource, + createAisStreamSource, createOpenSkySource, } from '../../src/sources/live/standalone.js'; import { @@ -55,6 +56,10 @@ export function createLocalToolServices(options = {}) { launches: createLaunchSource({ fetchImpl }), aircraft: createOpenSkySource({ fetchImpl }), military: createAdsbLolSource({ fetchImpl }), + vessels: createAisStreamSource({ + fetchImpl, + origin: () => new URL(options.apiBase ?? DEFAULT_API_BASE).origin, + }), satellites: createSatelliteSource({ fetchImpl }), cctv: createCctvSource({ fetchImpl }), radio: createRadioSource({ fetchImpl }), diff --git a/src/tools/index.js b/src/tools/index.js index 3a753f425..5a3af690d 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -20,6 +20,11 @@ import { getWeather, } from './queries/environment.js'; import { getActiveFires, getEarthquakes } from './queries/hazards.js'; +import { + findVessel, + getVesselTrack, + vesselsInArea, +} from './queries/maritime.js'; import { getBikeShare, getTransitVehicles } from './queries/mobility.js'; import { placesNearby, planRoute, searchPlaces } from './queries/places.js'; import { @@ -63,6 +68,9 @@ export const coreTools = Object.freeze([ findAircraft, getAircraftTrack, getAircraftInfo, + vesselsInArea, + findVessel, + getVesselTrack, nextSatellitePass, satellitesOverhead, findCctvCameras, diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index 57d0d4cef..9c51f36a3 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -5,6 +5,7 @@ import { AREA_SCHEMA, areaCenter, resolveArea } from '../area.js'; import { aircraftInArea } from './aviation.js'; import { getCyclones, getWeather } from './environment.js'; import { getActiveFires, getEarthquakes } from './hazards.js'; +import { vesselsInArea } from './maritime.js'; const SECTION_LIMIT = 5; @@ -32,6 +33,11 @@ const SECTIONS = [ tool: aircraftInArea, args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, + { + key: 'vessels', + tool: vesselsInArea, + args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), + }, { key: 'cyclones', tool: getCyclones, @@ -91,7 +97,7 @@ export const situationBrief = defineTool({ title: 'Situation brief', description: 'One overview of an area: current weather, recent earthquakes, active ' + - 'fires, aircraft overhead and tropical cyclones, each summarized with ' + + 'fires, aircraft overhead, ships and tropical cyclones, each summarized with ' + 'its top items. Sections that are unavailable are marked as such.', inputSchema: { type: 'object', diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js new file mode 100644 index 000000000..c86193ac0 --- /dev/null +++ b/src/tools/queries/maritime.js @@ -0,0 +1,182 @@ +/** Maritime queries over the live vessel source (src/sources/live). */ + +import { defineTool, ToolError } from '../catalog.js'; +import { + AREA_SCHEMA, + areaCenter, + areaContains, + distanceKm, + resolveArea, +} from '../area.js'; +import { + LIMIT_SCHEMA, + capRows, + countNoun, + isoTime, + thinEvenly, +} from '../results.js'; + +const MMSI_SCHEMA = Object.freeze({ + type: 'string', + pattern: '^\\d{5,10}$', + description: 'Maritime Mobile Service Identity, such as "366999712".', +}); +const MAX_TRACK_POINTS = 200; + +const round = (value, digits) => + Number.isFinite(value) ? Number(value.toFixed(digits)) : null; +const text = (value) => (typeof value === 'string' && value ? value : null); + +function vesselRow(record, from) { + const point = { lat: record.latitude, lon: record.longitude }; + return { + mmsi: record.id, + name: text(record.name), + imo: text(record.imo), + type: text(record.type), + destination: text(record.destination), + lat: round(record.latitude, 5), + lon: round(record.longitude, 5), + speed_mps: round(record.speedMps, 1), + course_deg: round(record.courseDeg, 0), + heading_deg: round(record.headingDeg, 0), + observed_at: isoTime(record.observedAtMs), + ...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}), + }; +} + +function snapshotInfo(snapshot) { + return { + source: snapshot.source ?? null, + coverage: snapshot.coverage ?? null, + observed_at: isoTime(snapshot.observedAtMs), + freshness: snapshot.freshness ?? 'unknown', + }; +} + +export const vesselsInArea = defineTool({ + name: 'vessels_in_area', + title: 'Vessels in an area', + description: + 'Ships currently reported by AIS inside an area, nearest the center ' + + 'first, optionally filtered by vessel type.', + inputSchema: { + type: 'object', + properties: { + area: AREA_SCHEMA, + type: { + type: 'string', + minLength: 1, + maxLength: 40, + description: 'Vessel type to match, such as "tanker" or "cargo".', + }, + limit: LIMIT_SCHEMA, + }, + required: ['area'], + additionalProperties: false, + }, + requires: ['vessels'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + const center = areaCenter(area); + const snapshot = await services.vessels.getSnapshot({}, { signal }); + const wanted = args.type?.toLowerCase(); + const rows = snapshot.records + .filter( + (record) => + areaContains(area, { lat: record.latitude, lon: record.longitude }) && + (!wanted || String(record.type).toLowerCase().includes(wanted)), + ) + .map((record) => vesselRow(record, center)) + .sort((a, b) => a.distance_km - b.distance_km); + const kind = args.type ? `${args.type} vessel` : 'vessel'; + return { + summary: + `${countNoun(rows.length, kind)} in ${area.label}` + + (snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'), + data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) }, + }; + }, +}); + +export const findVessel = defineTool({ + name: 'find_vessel', + title: 'Find a vessel', + description: + 'Find ships currently reported anywhere by MMSI, IMO number or name. ' + + 'Names match when they contain the given text.', + inputSchema: { + type: 'object', + properties: { + mmsi: MMSI_SCHEMA, + imo: { type: 'string', pattern: '^\\d{7}$' }, + name: { type: 'string', minLength: 2, maxLength: 60 }, + limit: LIMIT_SCHEMA, + }, + additionalProperties: false, + }, + requires: ['vessels'], + async run(args, { services, signal }) { + const given = ['mmsi', 'imo', 'name'].filter((key) => args[key] != null); + if (given.length !== 1) + throw new ToolError( + 'invalid_arguments', + 'Give exactly one of mmsi, imo or name', + ); + const [key] = given; + const wanted = args[key].trim().toUpperCase(); + const matches = { + mmsi: (record) => record.id === wanted, + imo: (record) => String(record.imo).replace(/^IMO/i, '') === wanted, + name: (record) => String(record.name).toUpperCase().includes(wanted), + }[key]; + const snapshot = await services.vessels.getSnapshot({}, { signal }); + const rows = snapshot.records + .filter(matches) + .map((record) => vesselRow(record)); + return { + summary: rows.length + ? `Found ${countNoun(rows.length, 'vessel')} with ${key} ${args[key]}.` + : `No vessel with ${key} ${args[key]} is currently reported.`, + data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) }, + }; + }, +}); + +export const getVesselTrack = defineTool({ + name: 'get_vessel_track', + title: 'Vessel track', + description: + 'Recent positions of one ship by MMSI, oldest first. Long tracks are ' + + `evenly thinned to at most ${MAX_TRACK_POINTS} points.`, + inputSchema: { + type: 'object', + properties: { mmsi: MMSI_SCHEMA }, + required: ['mmsi'], + additionalProperties: false, + }, + requires: ['vessels'], + async run(args, { services, signal }) { + const track = await services.vessels.getTrack(args.mmsi, { signal }); + const points = track.records + .filter((point) => Number.isFinite(point.observedAtMs)) + .sort((a, b) => a.observedAtMs - b.observedAtMs); + const kept = thinEvenly(points, MAX_TRACK_POINTS); + return { + summary: points.length + ? `${countNoun(points.length, 'position')} for ${args.mmsi} from ${isoTime(points[0].observedAtMs)} to ${isoTime(points.at(-1).observedAtMs)}.` + : `No recent track is available for ${args.mmsi}.`, + data: { + mmsi: args.mmsi, + total: points.length, + returned: kept.length, + thinned: kept.length < points.length, + points: kept.map((point) => ({ + time: isoTime(point.observedAtMs), + lat: round(point.latitude, 5), + lon: round(point.longitude, 5), + })), + }, + }; + }, +}); diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs new file mode 100644 index 000000000..00211992c --- /dev/null +++ b/src/tools/queries/maritime.test.mjs @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; +import { LiveSourceError } from '../../sources/live/contract.js'; + +const vessel = (id, latitude, longitude, extra = {}) => ({ + id, + reference: id, + latitude, + longitude, + name: `SHIP ${id}`, + imo: '', + type: 'Cargo', + destination: '', + speedMps: 6.17, + courseDeg: 181.2, + headingDeg: 180, + observedAtMs: Date.UTC(2026, 0, 1), + altitudeDatum: 'sea-surface', + ...extra, +}); +const records = [ + vessel('366999712', 37.81, -122.41, { + name: 'GOLDEN BEAR', + imo: '9123456', + type: 'Passenger', + }), + vessel('538001234', 37.75, -122.3, { + name: 'PACIFIC TRADER', + imo: 'IMO7654321', + type: 'Tanker', + destination: 'OAKLAND', + }), + vessel('235000001', 51.5, 1.5), +]; +const vessels = (extra = {}) => ({ + async getSnapshot() { + return { + records, + source: 'AISStream', + coverage: 'received AIS positions', + observedAtMs: Date.UTC(2026, 0, 1), + freshness: 'current', + ...extra, + }; + }, + async getTrack(mmsi) { + return { + records: [ + { + latitude: 37.8, + longitude: -122.4, + observedAtMs: Date.UTC(2026, 0, 1, 1), + }, + { + latitude: 37.7, + longitude: -122.5, + observedAtMs: Date.UTC(2026, 0, 1, 0), + }, + { latitude: 37.6, longitude: -122.6, observedAtMs: null }, + ].filter(() => mmsi === '366999712'), + complete: false, + }; + }, +}); +const bay = { lat: 37.78, lon: -122.4, radius_km: 20 }; + +test('vessels in an area are nearest first and filterable by type', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { vessels: vessels() }, + }); + const all = await catalog.call('vessels_in_area', { area: bay }); + assert.equal(all.summary, '2 vessels in 20 km around 37.780, -122.400.'); + assert.deepEqual( + all.data.rows.map((row) => row.mmsi), + ['366999712', '538001234'], + ); + assert.deepEqual(all.data.rows[0], { + mmsi: '366999712', + name: 'GOLDEN BEAR', + imo: '9123456', + type: 'Passenger', + destination: null, + lat: 37.81, + lon: -122.41, + speed_mps: 6.2, + course_deg: 181, + heading_deg: 180, + observed_at: '2026-01-01T00:00:00.000Z', + distance_km: 3.4, + }); + assert.equal(all.data.source, 'AISStream'); + const tankers = await catalog.call('vessels_in_area', { + area: bay, + type: 'tanker', + }); + assert.equal( + tankers.summary, + '1 tanker vessel in 20 km around 37.780, -122.400.', + ); + const stale = composeCatalog({ + tools: coreTools, + services: { vessels: vessels({ freshness: 'stale' }) }, + }); + assert.match( + (await stale.call('vessels_in_area', { area: bay })).summary, + /\(data may be stale\)\.$/, + ); +}); + +test('vessels are found by exactly one identifier', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { vessels: vessels() }, + }); + assert.deepEqual( + (await catalog.call('find_vessel', { mmsi: '366999712' })).data.rows.map( + (row) => row.name, + ), + ['GOLDEN BEAR'], + ); + assert.deepEqual( + (await catalog.call('find_vessel', { imo: '7654321' })).data.rows.map( + (row) => row.mmsi, + ), + ['538001234'], + ); + const byName = await catalog.call('find_vessel', { name: 'trader' }); + assert.equal(byName.summary, 'Found 1 vessel with name trader.'); + assert.equal( + (await catalog.call('find_vessel', { mmsi: '99999' })).summary, + 'No vessel with mmsi 99999 is currently reported.', + ); + await assert.rejects( + catalog.call('find_vessel', { mmsi: '366999712', name: 'xy' }), + /exactly one/, + ); + await assert.rejects( + catalog.call('find_vessel', { mmsi: 'abc' }), + /must match/, + ); +}); + +test('vessel tracks are ordered and drop untimed positions', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { vessels: vessels() }, + }); + const track = await catalog.call('get_vessel_track', { mmsi: '366999712' }); + assert.equal( + track.summary, + '2 positions for 366999712 from 2026-01-01T00:00:00.000Z to 2026-01-01T01:00:00.000Z.', + ); + assert.deepEqual( + track.data.points.map((point) => point.lat), + [37.7, 37.8], + ); + assert.equal( + (await catalog.call('get_vessel_track', { mmsi: '235000001' })).summary, + 'No recent track is available for 235000001.', + ); +}); + +test('a missing AIS key surfaces as an unavailable feed', async () => { + const keyless = { + getSnapshot: async () => { + throw new LiveSourceError('unavailable', 'AISSTREAM_API_KEY not set'); + }, + }; + const catalog = composeCatalog({ + tools: coreTools, + services: { vessels: keyless }, + }); + await assert.rejects( + catalog.call('vessels_in_area', { area: bay }), + (error) => + error.code === 'unavailable' && + error.message === 'AISSTREAM_API_KEY not set', + ); +}); From 9d12da0a79e27c7f3f855d614358210aa85d7bbd Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 12:36:00 -0700 Subject: [PATCH 012/111] feat: add a traffic flow tool Add get_traffic_flow over the traffic source's TomTom flow tiles. It reports average speed as a share of free-flow speed, congested and closed road length, and a breakdown by road category, stepping down a zoom level when an area needs more tiles than the source reads at once. --- CHANGELOG.md | 2 +- docs/CURRENT-STATE.md | 2 +- docs/TOOLS.md | 1 + server/mcp/services.js | 2 + src/tools/index.js | 7 +- src/tools/queries/mobility.js | 120 ++++++++++++++++++++++++++++ src/tools/queries/mobility.test.mjs | 110 +++++++++++++++++++++++++ 7 files changed, 241 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index daf8505e5..acb9205d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ - Add tools for language-model clients and a local MCP server. `npm run mcp` serves earthquake, active-fire, launch, aircraft, ship, satellite, camera, radio, - place, routing, bike-share, transit, weather, cyclone, fire-perimeter, + place, routing, bike-share, transit, traffic, weather, cyclone, fire-perimeter, terrain, installation and map-feature queries, plus a situation brief, over stdio to clients such as Claude Code, reading from a running app. Tools are defined diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index afacd89dc..b53207b8d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -11,7 +11,7 @@ area, by identifier, tracks, and type and route lookups), ships (in an area, by identifier, and tracks), satellites (next pass over a point, and those overhead now), public cameras (including a camera's current image), radio stations, place search, routing, bike-share -stations, transit vehicles, weather, regional briefs, tropical cyclones, fire +stations, transit vehicles, road traffic flow, weather, regional briefs, tropical cyclones, fire perimeters, terrain height, military installations and map features, plus a combined situation brief and the app's heads-up display caption. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 66c836cd7..0457944bd 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -112,6 +112,7 @@ public feeds the sources already use. | `plan_route` | `routing` | Walking, driving or cycling route over OpenStreetMap, with a simplified path | | `get_bike_share` | `bikeshare` | Live GBFS stations in an area, with bikes and docks available | | `get_transit_vehicles` | `transit` | Live GTFS-Realtime vehicle positions in an area, optionally one route | +| `get_traffic_flow` | `traffic` | TomTom flow in a city-sized area: speed vs free flow, congested and closed road | | `get_weather` | `weather` | Current conditions at a place or point | | `get_regional_brief` | `regional` | What and where a location is, its weather and recent headlines | | `get_cyclones` | `cyclones` | Active NHC/CPHC tropical cyclones, optionally in an area | diff --git a/server/mcp/services.js b/server/mcp/services.js index 1aa20af4e..b9df46bd4 100644 --- a/server/mcp/services.js +++ b/server/mcp/services.js @@ -14,6 +14,7 @@ import { createLaunchSource } from '../../src/layers/launches/source.js'; import { createWfigsPerimeterSource } from '../../src/layers/perimeters/source.js'; import { createRadioSource } from '../../src/layers/radio/source.js'; import { createSatelliteSource } from '../../src/layers/satellites/source.js'; +import { createTrafficSource } from '../../src/layers/traffic/source.js'; import { createTransitSource } from '../../src/layers/transit/source.js'; import { createApplicationRequestServices } from '../../src/services/requests.js'; import { @@ -70,6 +71,7 @@ export function createLocalToolServices(options = {}) { getStations: createBikeshareSource({ fetchImpl }).getStations, }, transit: createTransitSource({ fetchImpl }), + traffic: createTrafficSource({ fetchImpl, tileFetchImpl: fetchImpl }), weather: requests.weather, regional: requests.regional, terrain: requests.terrain, diff --git a/src/tools/index.js b/src/tools/index.js index 5a3af690d..a8d6f9eca 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -25,7 +25,11 @@ import { getVesselTrack, vesselsInArea, } from './queries/maritime.js'; -import { getBikeShare, getTransitVehicles } from './queries/mobility.js'; +import { + getBikeShare, + getTrafficFlow, + getTransitVehicles, +} from './queries/mobility.js'; import { placesNearby, planRoute, searchPlaces } from './queries/places.js'; import { findCctvCameras, @@ -81,6 +85,7 @@ export const coreTools = Object.freeze([ planRoute, getBikeShare, getTransitVehicles, + getTrafficFlow, getWeather, getRegionalBrief, getCyclones, diff --git a/src/tools/queries/mobility.js b/src/tools/queries/mobility.js index 8d975648c..0c9e3a7fe 100644 --- a/src/tools/queries/mobility.js +++ b/src/tools/queries/mobility.js @@ -222,3 +222,123 @@ export const getTransitVehicles = defineTool({ }; }, }); + +const FLOW_ZOOMS = [12, 11, 10, 9]; +const CONGESTED_LEVEL = 0.5; + +/** Length in kilometers of a `[lon, lat]` line. */ +function lineKm(coords) { + let total = 0; + for (let index = 1; index < coords.length; index += 1) + total += distanceKm( + { lat: coords[index - 1][1], lon: coords[index - 1][0] }, + { lat: coords[index][1], lon: coords[index][0] }, + ); + return total; +} + +async function readFlow(traffic, area, signal) { + const box = { + west: area.west, + south: area.south, + east: area.east, + north: area.north, + }; + for (const zoom of FLOW_ZOOMS) { + try { + return await traffic.fetchFlowForBounds(box, { zoom, signal }); + } catch (error) { + if (error?.code !== 'TILE_VIEW_TOO_WIDE') throw error; + } + } + throw new ToolError( + 'invalid_arguments', + 'The area is too large for traffic detail; use a city-sized area', + ); +} + +export const getTrafficFlow = defineTool({ + name: 'get_traffic_flow', + title: 'Traffic flow', + description: + 'Live road traffic in a city-sized area from TomTom: average speed as a ' + + 'share of free-flow speed, congested and closed road length, and a ' + + 'breakdown by road category.', + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['traffic'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + if (area.west > area.east) + throw new ToolError( + 'invalid_arguments', + 'The area must not cross the antimeridian', + ); + const status = await services.traffic.getStatus({ signal }); + if (!status.hasKey) + throw new ToolError( + 'unavailable', + 'Live traffic needs a TomTom key configured for this server', + ); + const segments = (await readFlow(services.traffic, area, signal)).filter( + (segment) => Array.isArray(segment.coords) && segment.coords.length > 1, + ); + const categories = new Map(); + let measuredKm = 0; + let weighted = 0; + let congestedKm = 0; + let closedKm = 0; + for (const segment of segments) { + const km = lineKm(segment.coords); + const key = segment.roadCategory || 'other'; + const entry = categories.get(key) || { + km: 0, + weighted: 0, + congestedKm: 0, + }; + entry.km += km; + if (segment.closure) closedKm += km; + if (Number.isFinite(segment.trafficLevel)) { + measuredKm += km; + weighted += segment.trafficLevel * km; + entry.weighted += segment.trafficLevel * km; + if (segment.trafficLevel < CONGESTED_LEVEL) { + congestedKm += km; + entry.congestedKm += km; + } + } + categories.set(key, entry); + } + const percent = (part, whole) => + whole > 0 ? Math.round((part / whole) * 100) : null; + const speedPct = percent(weighted, measuredKm); + const byCategory = [...categories.entries()] + .map(([category, entry]) => ({ + category, + road_km: round(entry.km, 1), + speed_pct_of_free_flow: percent(entry.weighted, entry.km), + congested_km: round(entry.congestedKm, 1), + })) + .sort((a, b) => b.road_km - a.road_km); + return { + summary: segments.length + ? `Traffic in ${area.label}: ${speedPct ?? 'unknown'}% of free-flow speed on average; ` + + `${round(congestedKm, 1)} km congested of ${round(measuredKm, 1)} km measured` + + (closedKm > 0 ? `; ${round(closedKm, 1)} km closed.` : '.') + : `No live traffic is reported in ${area.label}.`, + data: { + area: area.label, + speed_pct_of_free_flow: speedPct, + measured_km: round(measuredKm, 1), + congested_km: round(congestedKm, 1), + closed_km: round(closedKm, 1), + congested_below_pct: CONGESTED_LEVEL * 100, + by_category: byCategory, + }, + }; + }, +}); diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 72b7d4a42..cbf48b99d 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -260,3 +260,113 @@ test('transit reports uncovered areas and unavailable feeds', async () => { (error) => error.code === 'unavailable', ); }); + +test('traffic flow aggregates speed, congestion and closures by road length', async () => { + // Two 0.01° segments along the equator (about 1.11 km each) and one closure. + const segments = [ + { + coords: [ + [0, 0], + [0.01, 0], + ], + trafficLevel: 1, + roadCategory: 'motorway', + closure: false, + }, + { + coords: [ + [0, 0.001], + [0.01, 0.001], + ], + trafficLevel: 0.25, + roadCategory: 'street', + closure: false, + }, + { + coords: [ + [0, 0.002], + [0.005, 0.002], + ], + trafficLevel: 0, + roadCategory: 'street', + closure: true, + }, + { coords: [[0, 0]], trafficLevel: 1 }, + ]; + const zooms = []; + const traffic = { + getStatus: async () => ({ hasKey: true }), + async fetchFlowForBounds(box, { zoom }) { + zooms.push(zoom); + if (zoom > 10) + throw Object.assign(new Error('Zoom in'), { + code: 'TILE_VIEW_TOO_WIDE', + }); + return segments; + }, + }; + const catalog = composeCatalog({ tools: coreTools, services: { traffic } }); + const result = await catalog.call('get_traffic_flow', { + area: { bbox: [-0.1, -0.1, 0.1, 0.1] }, + }); + assert.deepEqual(zooms, [12, 11, 10]); + assert.equal( + result.summary, + 'Traffic in the requested box: 50% of free-flow speed on average; 1.7 km congested of 2.8 km measured; 0.6 km closed.', + ); + assert.equal(result.data.speed_pct_of_free_flow, 50); + assert.deepEqual(result.data.by_category, [ + { + category: 'street', + road_km: 1.7, + speed_pct_of_free_flow: 17, + congested_km: 1.7, + }, + { + category: 'motorway', + road_km: 1.1, + speed_pct_of_free_flow: 100, + congested_km: 0, + }, + ]); +}); + +test('traffic reports a missing key, empty areas and areas that are too wide', async () => { + const keyless = { + getStatus: async () => ({ hasKey: false }), + fetchFlowForBounds: async () => [], + }; + await assert.rejects( + composeCatalog({ tools: coreTools, services: { traffic: keyless } }).call( + 'get_traffic_flow', + { area: downtown }, + ), + (error) => error.code === 'unavailable' && /TomTom key/.test(error.message), + ); + const quiet = { + getStatus: async () => ({ hasKey: true }), + fetchFlowForBounds: async () => [], + }; + assert.equal( + ( + await composeCatalog({ + tools: coreTools, + services: { traffic: quiet }, + }).call('get_traffic_flow', { area: downtown }) + ).summary, + 'No live traffic is reported in 2 km around 30.267, -97.743.', + ); + const wide = { + getStatus: async () => ({ hasKey: true }), + fetchFlowForBounds: async () => { + throw Object.assign(new Error('Zoom in'), { code: 'TILE_VIEW_TOO_WIDE' }); + }, + }; + await assert.rejects( + composeCatalog({ tools: coreTools, services: { traffic: wide } }).call( + 'get_traffic_flow', + { area: { bbox: [-100, 30, -95, 35] } }, + ), + /too large for traffic detail/, + ); +}); From b6af8b34e06c5a5d2a901b43d6131a2757335587 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 12:42:08 -0700 Subject: [PATCH 013/111] feat: add a tool that links to an area in the app Add open_in_gods_eye_view. It builds a version 2 share link looking straight down on an area, with altitude chosen from the area's size, on the app address the application supplies. --- docs/CURRENT-STATE.md | 3 +- docs/TOOLS.md | 3 +- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 1 + server/mcp/services.js | 1 + src/tools/index.js | 2 ++ src/tools/queries/share.js | 55 ++++++++++++++++++++++++++++++++ src/tools/queries/share.test.mjs | 55 ++++++++++++++++++++++++++++++++ 8 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 src/tools/queries/share.js create mode 100644 src/tools/queries/share.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b53207b8d..14eb8ac27 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -13,7 +13,8 @@ pass over a point, and those overhead now), public cameras (including a camera's current image), radio stations, place search, routing, bike-share stations, transit vehicles, road traffic flow, weather, regional briefs, tropical cyclones, fire perimeters, terrain height, military installations and map features, plus a -combined situation brief and the app's heads-up display caption. Tools reuse the layers' portable source factories, take a +combined situation brief, the app's heads-up display caption, and a link that +opens the app over an area. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. The app does not import the tools, so its runtime behavior is unchanged. See [tools and the MCP server](TOOLS.md). diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 0457944bd..259963519 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -52,7 +52,7 @@ code runs wherever an application routes those paths. The `weather`, `regional`, `terrain`, `summary` and `features` services are the application request services from `gods-eye-view/application/requests`. `situation_brief` runs each section whose services are supplied and marks the -others unavailable. The `bikeshare` service is `{ systems, getStations }`: the system registry and +others unavailable. `app` is `{ baseUrl }`, the address links open. The `bikeshare` service is `{ systems, getStations }`: the system registry and the GBFS source. `createGeocodePlaceService` resolves place names through `/api/geocode`; `createPlaceSearchService` searches `/api/google/*` and reports when no search key is configured; `createRouteService` plans routes through `/api/route`. @@ -122,3 +122,4 @@ public feeds the sources already use. | `get_map_features` | `features` | Administrative areas, named places or monuments at a location (needs Overpass) | | `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, 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 looking straight down on an area | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c868f363a..e48d6bd17 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -877,5 +877,6 @@ "src/layers/transit/source.test.mjs", "src/tools/queries/mobility.test.mjs", "src/tools/queries/environment.test.mjs", - "src/tools/queries/maritime.test.mjs" + "src/tools/queries/maritime.test.mjs", + "src/tools/queries/share.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 21a5a9266..d9d5c93e0 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3097,6 +3097,7 @@ "src/tools/queries/environment.js", "src/tools/queries/brief.js", "src/tools/queries/maritime.js", + "src/tools/queries/share.js", "src/sources/gbfsStations.js", "src/sources/tle.js", "src/data/satellitePass.js" diff --git a/server/mcp/services.js b/server/mcp/services.js index b9df46bd4..fb3785223 100644 --- a/server/mcp/services.js +++ b/server/mcp/services.js @@ -52,6 +52,7 @@ export function createLocalToolServices(options = {}) { const fetchImpl = createApiFetch(options); const requests = createApplicationRequestServices({ fetchImpl }); return { + app: { baseUrl: options.apiBase ?? DEFAULT_API_BASE }, earthquakes: createUsgsEarthquakeSource({ fetchImpl }), fires: createFirmsSource({ fetchImpl }), launches: createLaunchSource({ fetchImpl }), diff --git a/src/tools/index.js b/src/tools/index.js index a8d6f9eca..0efcd84f5 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -31,6 +31,7 @@ import { getTransitVehicles, } from './queries/mobility.js'; import { placesNearby, planRoute, searchPlaces } from './queries/places.js'; +import { openInGodsEyeView } from './queries/share.js'; import { findCctvCameras, findRadioStations, @@ -95,4 +96,5 @@ export const coreTools = Object.freeze([ getMapFeatures, situationBrief, getHudCaption, + openInGodsEyeView, ]); diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js new file mode 100644 index 000000000..1c1aaf703 --- /dev/null +++ b/src/tools/queries/share.js @@ -0,0 +1,55 @@ +/** A link that opens God's Eye View on an area. */ + +import { defineTool, ToolError } from '../catalog.js'; +import { AREA_SCHEMA, areaCenter, areaRadiusKm, resolveArea } from '../area.js'; + +const MIN_ALTITUDE_M = 500; +const MAX_ALTITUDE_M = 15_000_000; +// A top-down view sees roughly this many meters of ground per meter of altitude. +const GROUND_PER_ALTITUDE = 0.55; + +export const openInGodsEyeView = defineTool({ + name: 'open_in_gods_eye_view', + title: "Open in God's Eye View", + description: + "A link that opens God's Eye View looking straight down on an area.", + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['app'], + async run(args, { services, signal }) { + let base; + try { + base = new URL(services.app.baseUrl); + } catch { + throw new ToolError('unavailable', "The app's address is not configured"); + } + const area = await resolveArea(args.area, { services, signal }); + const center = areaCenter(area); + const altitude = Math.round( + Math.min( + MAX_ALTITUDE_M, + Math.max( + MIN_ALTITUDE_M, + (areaRadiusKm(area) * 1000) / GROUND_PER_ALTITUDE, + ), + ), + ); + // The share-link camera fields; the app restores its defaults for the rest. + base.hash = new URLSearchParams({ + v: '2', + lat: center.lat.toFixed(4), + lon: center.lon.toFixed(4), + alt: String(altitude), + heading: '0', + pitch: '-90', + }).toString(); + return { + summary: `Open ${area.label} in God's Eye View: ${base.href}`, + data: { url: base.href, center, altitude_m: altitude }, + }; + }, +}); diff --git a/src/tools/queries/share.test.mjs b/src/tools/queries/share.test.mjs new file mode 100644 index 000000000..570bc1d0f --- /dev/null +++ b/src/tools/queries/share.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; + +test('links open the app looking straight down on the area', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { app: { baseUrl: 'https://maps.example/view?x=1' } }, + }); + const result = await catalog.call('open_in_gods_eye_view', { + area: { lat: 30.2672, lon: -97.7431, radius_km: 5 }, + }); + const url = new URL(result.data.url); + assert.equal( + url.origin + url.pathname + url.search, + 'https://maps.example/view?x=1', + ); + assert.deepEqual(Object.fromEntries(new URLSearchParams(url.hash.slice(1))), { + v: '2', + lat: '30.2672', + lon: '-97.7431', + alt: '9091', + heading: '0', + pitch: '-90', + }); + assert.equal( + result.summary, + `Open 5 km around 30.267, -97.743 in God's Eye View: ${url.href}`, + ); +}); + +test('altitude is bounded for tiny and planet-sized areas', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { app: { baseUrl: 'http://localhost:4173/' } }, + }); + const tiny = await catalog.call('open_in_gods_eye_view', { + area: { lat: 0, lon: 0, radius_km: 0.1 }, + }); + assert.equal(tiny.data.altitude_m, 500); + const world = await catalog.call('open_in_gods_eye_view', { + area: { bbox: [-180, -85, 180, 85] }, + }); + assert.equal(world.data.altitude_m, 15000000); + const broken = composeCatalog({ + tools: coreTools, + services: { app: { baseUrl: 'not a url' } }, + }); + await assert.rejects( + broken.call('open_in_gods_eye_view', { + area: { lat: 0, lon: 0, radius_km: 1 }, + }), + (error) => error.code === 'unavailable', + ); +}); From 469b1e8d8945bb30d30a6254b3e5a870f8078756 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 12:47:44 -0700 Subject: [PATCH 014/111] feat: serve the MCP server at /mcp on the local servers Mount the local MCP server at /mcp on the development and preview servers for clients that connect by URL. The route accepts only requests from this machine that name a loopback host and, when an Origin is sent, come from a loopback origin, and drains oversized bodies so the 413 response is delivered. The provider-order regression test now pins the route between the providers and the API fallback. --- CHANGELOG.md | 4 +- docs/CURRENT-STATE.md | 2 + docs/TOOLS.md | 15 +++- scripts/format-scope.json | 3 +- server/mcp/plugin.js | 119 ++++++++++++++++++++++++++++++ server/standalone/vite.config.js | 3 +- src/tooling/viteBuild.test.mjs | 6 +- src/tools/localRoute.test.mjs | 123 +++++++++++++++++++++++++++++++ 8 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 server/mcp/plugin.js create mode 100644 src/tools/localRoute.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index acb9205d7..4b32c2471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog -- Add tools for language-model clients and a local MCP server. `npm run mcp` +- Add tools for language-model clients and a local MCP server, served over + stdio by `npm run mcp` and over HTTP at `/mcp` on the development and preview + servers for local requests only. `npm run mcp` serves earthquake, active-fire, launch, aircraft, ship, satellite, camera, radio, place, routing, bike-share, transit, traffic, weather, cyclone, fire-perimeter, terrain, installation and map-feature queries, plus a situation brief, over diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 14eb8ac27..bf65cedf1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6,6 +6,8 @@ and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client, reading from a running app's `/api` routes (default `http://localhost:4173`). +The development and preview servers also serve the tools over HTTP at `/mcp`, +accepting only local requests with loopback hosts and origins. Queries cover earthquakes, active fires, recent launches, aircraft (in an area, by identifier, tracks, and type and route lookups), ships (in an area, by identifier, and tracks), satellites (next diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 259963519..cadd5712a 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -11,7 +11,7 @@ They are defined once and exposed through adapters; the Model Context Protocol | `src/tools/` | Tool definitions, catalog composition, argument validation, shared `area` and result helpers | | `src/tools/queries/` | Queries, one file per domain, reading only portable source contracts | | `src/tools/mcp/` | MCP protocol (JSON-RPC) and a stateless HTTP transport; knows the catalog interface, not queries | -| `server/mcp/` | Node composition: points the sources at a running app's `/api` routes and serves stdio | +| `server/mcp/` | Node composition: points the sources at a running app's `/api` routes; serves stdio and `/mcp` | Dependencies point downward only. `gods-eye-view/tools` and `gods-eye-view/tools/mcp` are portable exports: they reach no application, @@ -84,7 +84,18 @@ server with an MCP client, for example Claude Code: claude mcp add gods-eye-view -- npm --prefix /path/to/gods-eye-view run --silent mcp ``` -`npm run mcp -- --api-base http://localhost:4173` selects another server. The +`npm run mcp -- --api-base http://localhost:4173` selects another server. + +The development and preview servers also serve the same tools over HTTP at +`/mcp`, for clients that connect by URL: + +```bash +claude mcp add --transport http gods-eye-view http://localhost:4173/mcp +``` + +The route accepts only requests from this machine that name a loopback host +and, when a browser sends an `Origin`, come from a loopback origin. This is +local transport safety, not authentication. The local server makes no requests other than to the app's `/api` routes and the public feeds the sources already use. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index e48d6bd17..7f46193be 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -878,5 +878,6 @@ "src/tools/queries/mobility.test.mjs", "src/tools/queries/environment.test.mjs", "src/tools/queries/maritime.test.mjs", - "src/tools/queries/share.test.mjs" + "src/tools/queries/share.test.mjs", + "src/tools/localRoute.test.mjs" ] diff --git a/server/mcp/plugin.js b/server/mcp/plugin.js new file mode 100644 index 000000000..4c5c4ae24 --- /dev/null +++ b/server/mcp/plugin.js @@ -0,0 +1,119 @@ +/** + * Serves the local MCP server at /mcp on the development and preview servers. + * + * This is local transport safety, not authentication: requests must come from + * this machine, name a loopback host (blocking DNS rebinding) and, when a + * browser sends an Origin, come from a loopback origin. + */ + +import { createMcpHttpHandler } from '../../src/tools/mcp/index.js'; +import { createLocalMcpServer } from './server.js'; + +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); +const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']); +const MAX_BODY_BYTES = 1024 * 1024; + +/** Whether a request may reach the local MCP server. */ +export function isLocalMcpRequest({ remoteAddress, host, origin }) { + if (!LOOPBACK_ADDRESSES.has(remoteAddress)) return false; + let hostUrl; + try { + hostUrl = new URL(`http://${host}`); + } catch { + return false; + } + if (!LOOPBACK_HOSTS.has(hostUrl.hostname)) return false; + if (origin == null) return true; + try { + const originUrl = new URL(origin); + return ( + ['http:', 'https:'].includes(originUrl.protocol) && + LOOPBACK_HOSTS.has(originUrl.hostname) + ); + } catch { + return false; + } +} + +/** Vite plugin that mounts the local MCP server at /mcp. */ +export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) { + const handlers = new Map(); + const handlerFor = (apiBase) => { + if (!handlers.has(apiBase)) + handlers.set(apiBase, createMcpHttpHandler(createServer({ apiBase }))); + return handlers.get(apiBase); + }; + const install = (server) => { + server.middlewares.use('/mcp', async (req, res) => { + const host = req.headers.host || ''; + if ( + !isLocalMcpRequest({ + remoteAddress: req.socket?.remoteAddress, + host, + origin: req.headers.origin, + }) + ) { + res.writeHead(403, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: 'The MCP server only accepts local requests', + }), + ); + return; + } + try { + const body = req.method === 'POST' ? await readBody(req) : undefined; + const request = new Request(`http://${host}/mcp`, { + method: req.method, + headers: Object.entries(req.headers).flatMap(([name, value]) => + value === undefined ? [] : [[name, String(value)]], + ), + body, + }); + const response = await handlerFor(`http://${host}`)(request); + res.writeHead(response.status, Object.fromEntries(response.headers)); + res.end(Buffer.from(await response.arrayBuffer())); + } catch (error) { + if (res.headersSent) return res.destroy(); + res.writeHead(error?.status === 413 ? 413 : 400, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: error?.status === 413 ? 'Request too large' : 'Bad request', + }), + ); + } + }); + }; + return { + name: 'local-mcp', + configureServer: install, + configurePreviewServer: install, + }; +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + let tooLarge = false; + req.on('data', (chunk) => { + size += chunk.length; + // Keep draining without storing, so the 413 response can still be sent. + if (tooLarge || size <= MAX_BODY_BYTES) { + if (!tooLarge) chunks.push(chunk); + return; + } + tooLarge = true; + chunks.length = 0; + reject(Object.assign(new Error('Request too large'), { status: 413 })); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index ffa5a1e27..fd315bea8 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url'; import { defineConfig, loadEnv } from 'vite'; import { createBrowserViteConfig } from '../../build/vite.js'; import { localProviderPlugins } from '../providers/local.js'; +import { localMcpPlugin } from '../mcp/plugin.js'; import { apiNotFoundPlugin } from './api-not-found.js'; const root = fileURLToPath(new URL('../../', import.meta.url)); @@ -13,7 +14,7 @@ export default defineConfig(({ command, mode }) => { if (process.env[key] === undefined) process.env[key] = value; } return createBrowserViteConfig({ - plugins: [...localProviderPlugins(), apiNotFoundPlugin()], + plugins: [...localProviderPlugins(), localMcpPlugin(), apiNotFoundPlugin()], googleApiKey: process.env.GOOGLE_MAPS_API_KEY, cesiumToken: process.env.CESIUM_ION_TOKEN, host: process.env.HOST, diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 497035a40..6900420a6 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -63,10 +63,12 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }); assert.deepEqual( - config.plugins.slice(2, -1).map((plugin) => plugin.name), + config.plugins.slice(2, -2).map((plugin) => plugin.name), providers.localProviderPlugins().map((plugin) => plugin.name), ); - assert.equal(config.plugins.at(-2).name, 'gev-key-setup'); + assert.equal(config.plugins.at(-3).name, 'gev-key-setup'); + // The local MCP route follows every provider and precedes the API fallback. + assert.equal(config.plugins.at(-2).name, 'local-mcp'); assert.equal(config.plugins.at(-1).name, 'api-not-found'); }); diff --git a/src/tools/localRoute.test.mjs b/src/tools/localRoute.test.mjs new file mode 100644 index 000000000..bdeb6a0e4 --- /dev/null +++ b/src/tools/localRoute.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { createServer, request as httpRequest } from 'node:http'; +import test from 'node:test'; +import { isLocalMcpRequest, localMcpPlugin } from '../../server/mcp/plugin.js'; + +test('only loopback connections with loopback hosts and origins are local', () => { + const local = { remoteAddress: '127.0.0.1', host: 'localhost:4173' }; + assert.equal(isLocalMcpRequest(local), true); + assert.equal( + isLocalMcpRequest({ ...local, remoteAddress: '::1', host: '[::1]:4173' }), + true, + ); + assert.equal( + isLocalMcpRequest({ ...local, origin: 'http://127.0.0.1:5173' }), + true, + ); + for (const request of [ + { ...local, remoteAddress: '192.168.1.20' }, + { ...local, host: 'attacker.example:4173' }, + { ...local, host: 'localhost.attacker.example' }, + { ...local, host: '' }, + { ...local, origin: 'https://attacker.example' }, + { ...local, origin: 'null' }, + { ...local, origin: 'file:///tmp/page.html' }, + ]) + assert.equal(isLocalMcpRequest(request), false, JSON.stringify(request)); +}); + +test('the /mcp route answers local MCP requests and refuses others', async (t) => { + let middleware; + const created = []; + const plugin = localMcpPlugin({ + createServer: ({ apiBase }) => { + created.push(apiBase); + return { + handle: async (message) => + message.id === undefined + ? null + : { jsonrpc: '2.0', id: message.id, result: { apiBase } }, + }; + }, + }); + plugin.configureServer({ + middlewares: { use: (path, handler) => (middleware = handler) }, + }); + const http = createServer((req, res) => middleware(req, res)); + await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve)); + t.after(() => http.close()); + const { port } = http.address(); + const send = (method, body, headers = {}) => + new Promise((resolve, reject) => { + const request = httpRequest( + { + host: '127.0.0.1', + port, + path: '/mcp', + method, + headers: { + 'Content-Type': 'application/json', + Host: `localhost:${port}`, + ...headers, + }, + }, + (response) => { + let text = ''; + response.on('data', (chunk) => (text += chunk)); + response.on('end', () => + resolve({ + status: response.statusCode, + json: () => JSON.parse(text), + }), + ); + }, + ); + request.on('error', reject); + request.end( + body === undefined + ? undefined + : typeof body === 'string' + ? body + : JSON.stringify(body), + ); + }); + const post = (body, headers) => send('POST', body, headers); + + const ok = await post({ jsonrpc: '2.0', id: 1, method: 'ping' }); + assert.equal(ok.status, 200); + assert.deepEqual(await ok.json(), { + jsonrpc: '2.0', + id: 1, + result: { apiBase: `http://localhost:${port}` }, + }); + assert.equal( + (await post({ jsonrpc: '2.0', method: 'notifications/initialized' })) + .status, + 202, + ); + await post({ jsonrpc: '2.0', id: 2, method: 'ping' }); + assert.deepEqual(created, [`http://localhost:${port}`]); + + const foreign = await post( + { jsonrpc: '2.0', id: 3, method: 'ping' }, + { Origin: 'https://attacker.example' }, + ); + assert.equal(foreign.status, 403); + assert.deepEqual(await foreign.json(), { + error: 'The MCP server only accepts local requests', + }); + assert.equal((await post({}, { Host: 'attacker.example' })).status, 403); + assert.equal( + (await post({}, { Host: `localhost.attacker.example:${port}` })).status, + 403, + ); + assert.equal((await post('x'.repeat(1024 * 1024 + 1))).status, 413); + assert.equal( + ( + await fetch(`http://127.0.0.1:${port}/mcp`, { + headers: { Host: `localhost:${port}` }, + }) + ).status, + 405, + ); +}); From 0dbd6375fcbcaef4d4a9afa438aa8c6e99b4f1c6 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 12:50:15 -0700 Subject: [PATCH 015/111] fix: center named places on their own location A place's bounds can reach far from the place itself; Tokyo's include remote Pacific islands, so the middle of its box is open ocean. Keep the geocoder's point for a named place and use it as the area's center, while bounds still decide what lies inside the area. --- src/tools/area.js | 12 ++++++++++-- src/tools/area.test.mjs | 19 ++++++++++++++++++- src/tools/places.js | 12 ++++++++++-- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/tools/area.js b/src/tools/area.js index ff61077bc..e494c6782 100644 --- a/src/tools/area.js +++ b/src/tools/area.js @@ -75,7 +75,11 @@ export async function resolveArea(area, { services, signal } = {}) { 'invalid_arguments', `No place matched "${area.place}"; try a more specific name or a bbox`, ); - return { label: place.name || area.place, ...place.bounds }; + return { + label: place.name || area.place, + ...place.bounds, + ...(place.point ? { point: place.point } : {}), + }; } function circleArea(lat, lon, radiusKm) { @@ -151,9 +155,13 @@ export function distanceKm(a, b) { return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h))); } -/** The center of a resolved area, handling boxes across the antimeridian. */ +/** + * The center of a resolved area: a radius area's center, a named place's own + * point, or the middle of a box (handling boxes across the antimeridian). + */ export function areaCenter(area) { if (area.center) return { lat: area.center.lat, lon: area.center.lon }; + if (area.point) return { lat: area.point.lat, lon: area.point.lon }; const width = area.west <= area.east ? area.east - area.west diff --git a/src/tools/area.test.mjs b/src/tools/area.test.mjs index fac64690a..99df85684 100644 --- a/src/tools/area.test.mjs +++ b/src/tools/area.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { areaContains, distanceKm, resolveArea } from './area.js'; +import { areaCenter, areaContains, distanceKm, resolveArea } from './area.js'; import { placeFromGeocodeResult } from './places.js'; test('an area needs exactly one form', async () => { @@ -91,6 +91,7 @@ test('geocode results become bounded places', () => { { name: 'Oslo, Norway', bounds: { west: 10.5, south: 59.8, east: 10.9, north: 60 }, + point: { lat: 59.9, lon: 10.7 }, }, ); assert.deepEqual( @@ -100,3 +101,19 @@ test('geocode results become bounded places', () => { ); assert.equal(placeFromGeocodeResult({ geometry: {} }), null); }); + +test('a named place centers on its own point, not the middle of its bounds', async () => { + // Tokyo's prefecture bounds reach remote Pacific islands. + const places = { + resolve: async () => ({ + name: 'Tokyo, Japan', + bounds: { west: 136.07, south: 20.21, east: 153.99, north: 35.9 }, + point: { lat: 35.6769, lon: 139.7639 }, + }), + }; + const tokyo = await resolveArea({ place: 'Tokyo' }, { services: { places } }); + assert.deepEqual(areaCenter(tokyo), { lat: 35.6769, lon: 139.7639 }); + assert.equal(areaContains(tokyo, { lat: 27.09, lon: 142.19 }), true); + const box = await resolveArea({ bbox: [0, 0, 10, 10] }); + assert.deepEqual(areaCenter(box), { lat: 5, lon: 5 }); +}); diff --git a/src/tools/places.js b/src/tools/places.js index 64d9da201..e7f4f8084 100644 --- a/src/tools/places.js +++ b/src/tools/places.js @@ -13,7 +13,11 @@ import { ToolError } from './catalog.js'; const POINT_RADIUS_DEGREES = 0.25; -/** Resolve place names to `{ name, bounds: { west, south, east, north } }` or null. */ +/** + * Resolve place names to `{ name, bounds: { west, south, east, north }, point }` + * or null. `point` is the place's own location, which can lie far from the + * middle of its bounds (a region with remote islands, for example). + */ export function createGeocodePlaceService({ fetchImpl = (...args) => globalThis.fetch(...args), } = {}) { @@ -54,7 +58,11 @@ export function placeFromGeocodeResult(result) { east: Math.min(180, location.lng + POINT_RADIUS_DEGREES), north: Math.min(90, location.lat + POINT_RADIUS_DEGREES), }; - return { name: result.formatted_address || null, bounds }; + return { + name: result.formatted_address || null, + bounds, + point: { lat: location.lat, lon: location.lng }, + }; } /** Search points of interest; `configured` is false when the server has no search key. */ From a8b5860e9f4c82e731fb1aee4e1225c9c25af970 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:12:34 -0700 Subject: [PATCH 016/111] refactor: share the default tool services between surfaces Move the composition of the default tool services from the Node MCP server into gods-eye-view/tools/services, a portable export. Given a fetch that resolves the sources' /api paths, it builds the same services in a browser as on the local MCP server, so other surfaces can compose the same catalog. Queries still read only their services. --- docs/TOOLS.md | 4 +- package.json | 3 +- scripts/check-import-directions.mjs | 1 + scripts/package-boundaries.json | 54 +++++++++++++++++++++ server/mcp/services.js | 70 +++------------------------ src/tools/services.js | 74 +++++++++++++++++++++++++++++ 6 files changed, 141 insertions(+), 65 deletions(-) create mode 100644 src/tools/services.js diff --git a/docs/TOOLS.md b/docs/TOOLS.md index cadd5712a..8a9bec7ae 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -11,6 +11,7 @@ They are defined once and exposed through adapters; the Model Context Protocol | `src/tools/` | Tool definitions, catalog composition, argument validation, shared `area` and result helpers | | `src/tools/queries/` | Queries, one file per domain, reading only portable source contracts | | `src/tools/mcp/` | MCP protocol (JSON-RPC) and a stateless HTTP transport; knows the catalog interface, not queries | +| `src/tools/services.js` | The default services: the layers' source factories and request services, given a resolving fetch | | `server/mcp/` | Node composition: points the sources at a running app's `/api` routes; serves stdio and `/mcp` | Dependencies point downward only. `gods-eye-view/tools` and @@ -44,7 +45,8 @@ reported to clients without details. ## Services -Services are the portable source factories the layers already use, such as +`gods-eye-view/tools/services` builds the default set with +`createToolServices({ fetchImpl, appUrl })`. Services are the portable source factories the layers already use, such as `createUsgsEarthquakeSource`, `createFirmsSource` and `createLaunchSource`, plus a `places` service with `resolve(name, { signal })`. Sources request relative `/api/...` paths through an injected `fetchImpl`, so the same tool diff --git a/package.json b/package.json index 13b7bd8fd..7169a4c24 100644 --- a/package.json +++ b/package.json @@ -253,6 +253,7 @@ "./sources/transit-service": "./src/sources/transitService.js", "./sources/map-features": "./src/sources/overpassFeatures.js", "./tools": "./src/tools/index.js", - "./tools/mcp": "./src/tools/mcp/index.js" + "./tools/mcp": "./src/tools/mcp/index.js", + "./tools/services": "./src/tools/services.js" } } diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 72e163988..5eeca6ae2 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -39,6 +39,7 @@ const portableExport = (key) => './director', './tools', './tools/mcp', + './tools/services', './voice/action-schemas', './voice/session', './data/lifecycle', diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d9d5c93e0..03bc62849 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3114,5 +3114,59 @@ "src/tools/schema.js" ], "external": [] + }, + "tools-services": { + "exports": ["./tools/services"], + "modules": [ + "src/tools/services.js", + "src/layers/bikeshare/registry.js", + "src/layers/bikeshare/source.js", + "src/layers/cctv/source.js", + "src/layers/cctv/sourcePolicy.js", + "src/layers/cyclones/source.js", + "src/sources/httpBody.js", + "src/layers/earthquakes/source.js", + "src/layers/earthquakes/records.js", + "src/layers/firms/source.js", + "src/layers/installations/source.js", + "src/sources/requestDeadline.js", + "src/data/militaryNames.js", + "src/sources/militaryTileGeometry.js", + "src/data/bundledJson.js", + "src/data/retryableLoad.js", + "src/data/local_data/osm_military_names/names.json", + "src/sources/openFreeMap.js", + "src/sources/vectorTiles.js", + "src/sources/phaseTiming.js", + "src/data/tomtomTiles.js", + "src/sources/capability.js", + "src/data/militaryInstallationData.js", + "src/layers/launches/source.js", + "src/layers/perimeters/source.js", + "src/layers/radio/source.js", + "src/layers/radio/policy.js", + "src/layers/satellites/source.js", + "src/layers/traffic/source.js", + "src/layers/traffic/roadModes.js", + "src/data/flowMatch.js", + "src/layers/traffic/flowSource.js", + "src/layers/traffic/flowDecode.js", + "src/sources/overpassRoads.js", + "src/layers/transit/source.js", + "src/sources/transitHistory.js", + "src/services/requests.js", + "src/sources/overpassFeatures.js", + "src/sources/featureSource.js", + "src/sources/overpassFeaturesRecords.js", + "src/sources/featureGeometry.js", + "src/sources/live/standalone.js", + "src/sources/live/contract.js", + "src/sources/live/aircraft.js", + "src/sources/live/vessels.js", + "src/tools/places.js", + "src/tools/catalog.js", + "src/tools/schema.js" + ], + "external": ["@mapbox/vector-tile", "pbf"] } } diff --git a/server/mcp/services.js b/server/mcp/services.js index fb3785223..a4ac48965 100644 --- a/server/mcp/services.js +++ b/server/mcp/services.js @@ -1,32 +1,9 @@ /** - * Local composition of tool services: the portable source factories, pointed - * at a running God's Eye View server's `/api` routes. + * Local composition of tool services: Core's default services, pointed at a + * running God's Eye View server's `/api` routes. */ -import { GBFS_CITY_REGISTRY } from '../../src/layers/bikeshare/registry.js'; -import { createBikeshareSource } from '../../src/layers/bikeshare/source.js'; -import { createCctvSource } from '../../src/layers/cctv/source.js'; -import { createCycloneSource } from '../../src/layers/cyclones/source.js'; -import { createUsgsEarthquakeSource } from '../../src/layers/earthquakes/source.js'; -import { createFirmsSource } from '../../src/layers/firms/source.js'; -import { createInstallationSource } from '../../src/layers/installations/source.js'; -import { createLaunchSource } from '../../src/layers/launches/source.js'; -import { createWfigsPerimeterSource } from '../../src/layers/perimeters/source.js'; -import { createRadioSource } from '../../src/layers/radio/source.js'; -import { createSatelliteSource } from '../../src/layers/satellites/source.js'; -import { createTrafficSource } from '../../src/layers/traffic/source.js'; -import { createTransitSource } from '../../src/layers/transit/source.js'; -import { createApplicationRequestServices } from '../../src/services/requests.js'; -import { - createAdsbLolSource, - createAisStreamSource, - createOpenSkySource, -} from '../../src/sources/live/standalone.js'; -import { - createGeocodePlaceService, - createPlaceSearchService, - createRouteService, -} from '../../src/tools/places.js'; +import { createToolServices } from '../../src/tools/services.js'; export const DEFAULT_API_BASE = 'http://localhost:4173'; @@ -49,41 +26,8 @@ export function createApiFetch({ /** Construct every service Core's tools read, backed by the local server. */ export function createLocalToolServices(options = {}) { - const fetchImpl = createApiFetch(options); - const requests = createApplicationRequestServices({ fetchImpl }); - return { - app: { baseUrl: options.apiBase ?? DEFAULT_API_BASE }, - earthquakes: createUsgsEarthquakeSource({ fetchImpl }), - fires: createFirmsSource({ fetchImpl }), - launches: createLaunchSource({ fetchImpl }), - aircraft: createOpenSkySource({ fetchImpl }), - military: createAdsbLolSource({ fetchImpl }), - vessels: createAisStreamSource({ - fetchImpl, - origin: () => new URL(options.apiBase ?? DEFAULT_API_BASE).origin, - }), - satellites: createSatelliteSource({ fetchImpl }), - cctv: createCctvSource({ fetchImpl }), - radio: createRadioSource({ fetchImpl }), - placeSearch: createPlaceSearchService({ fetchImpl }), - routing: createRouteService({ fetchImpl }), - bikeshare: { - systems: GBFS_CITY_REGISTRY, - getStations: createBikeshareSource({ fetchImpl }).getStations, - }, - transit: createTransitSource({ fetchImpl }), - traffic: createTrafficSource({ fetchImpl, tileFetchImpl: fetchImpl }), - weather: requests.weather, - regional: requests.regional, - terrain: requests.terrain, - summary: requests.summary, - features: requests.features, - cyclones: createCycloneSource({ fetchImpl }), - perimeters: createWfigsPerimeterSource({ fetchImpl }), - installations: createInstallationSource({ - fetchImpl, - tileFetchImpl: fetchImpl, - }), - places: createGeocodePlaceService({ fetchImpl }), - }; + return createToolServices({ + fetchImpl: createApiFetch(options), + appUrl: options.apiBase ?? DEFAULT_API_BASE, + }); } diff --git a/src/tools/services.js b/src/tools/services.js new file mode 100644 index 000000000..8071425ed --- /dev/null +++ b/src/tools/services.js @@ -0,0 +1,74 @@ +/** + * The default services Core's tools read: the layers' portable source + * factories and the application request services. `fetchImpl` must resolve + * the sources' relative `/api/...` paths; in a browser the page's own fetch + * does, and elsewhere a caller supplies a resolving fetch. `appUrl` is the + * address the app is served from. + */ + +import { GBFS_CITY_REGISTRY } from '../layers/bikeshare/registry.js'; +import { createBikeshareSource } from '../layers/bikeshare/source.js'; +import { createCctvSource } from '../layers/cctv/source.js'; +import { createCycloneSource } from '../layers/cyclones/source.js'; +import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js'; +import { createFirmsSource } from '../layers/firms/source.js'; +import { createInstallationSource } from '../layers/installations/source.js'; +import { createLaunchSource } from '../layers/launches/source.js'; +import { createWfigsPerimeterSource } from '../layers/perimeters/source.js'; +import { createRadioSource } from '../layers/radio/source.js'; +import { createSatelliteSource } from '../layers/satellites/source.js'; +import { createTrafficSource } from '../layers/traffic/source.js'; +import { createTransitSource } from '../layers/transit/source.js'; +import { createApplicationRequestServices } from '../services/requests.js'; +import { + createAdsbLolSource, + createAisStreamSource, + createOpenSkySource, +} from '../sources/live/standalone.js'; +import { + createGeocodePlaceService, + createPlaceSearchService, + createRouteService, +} from './places.js'; + +/** Construct every service Core's tools read. */ +export function createToolServices({ fetchImpl, appUrl }) { + if (typeof fetchImpl !== 'function') + throw new TypeError('A fetch implementation is required'); + const requests = createApplicationRequestServices({ fetchImpl }); + return { + app: { baseUrl: appUrl }, + earthquakes: createUsgsEarthquakeSource({ fetchImpl }), + fires: createFirmsSource({ fetchImpl }), + launches: createLaunchSource({ fetchImpl }), + aircraft: createOpenSkySource({ fetchImpl }), + military: createAdsbLolSource({ fetchImpl }), + vessels: createAisStreamSource({ + fetchImpl, + origin: () => new URL(appUrl).origin, + }), + satellites: createSatelliteSource({ fetchImpl }), + cctv: createCctvSource({ fetchImpl }), + radio: createRadioSource({ fetchImpl }), + placeSearch: createPlaceSearchService({ fetchImpl }), + routing: createRouteService({ fetchImpl }), + bikeshare: { + systems: GBFS_CITY_REGISTRY, + getStations: createBikeshareSource({ fetchImpl }).getStations, + }, + transit: createTransitSource({ fetchImpl }), + traffic: createTrafficSource({ fetchImpl, tileFetchImpl: fetchImpl }), + weather: requests.weather, + regional: requests.regional, + terrain: requests.terrain, + summary: requests.summary, + features: requests.features, + cyclones: createCycloneSource({ fetchImpl }), + perimeters: createWfigsPerimeterSource({ fetchImpl }), + installations: createInstallationSource({ + fetchImpl, + tileFetchImpl: fetchImpl, + }), + places: createGeocodePlaceService({ fetchImpl }), + }; +} From 9de2705eaf9ab66d3a25339502fe384f7d700203 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:16:19 -0700 Subject: [PATCH 017/111] refactor: move the layer state coordinator into its own module layerState.js held both the share-link layer codec and the coordinator that restores layer choices into the running app and persists them to browser storage. Move the coordinator, unchanged, to layerStateCoordinator.js so the codec stays a pure data module that code outside the browser can use to write share links. The registry stays in layerState.js, where the token tooling reads it, and the aircraft barrel re-exports both. --- scripts/package-boundaries.json | 4 + src/data/layerState.js | 725 +---------------------------- src/data/layerState.test.mjs | 2 +- src/data/layerStateCoordinator.js | 742 ++++++++++++++++++++++++++++++ src/layers/aircraft/index.js | 1 + src/ui/shareRestoration.js | 2 +- 6 files changed, 751 insertions(+), 725 deletions(-) create mode 100644 src/data/layerStateCoordinator.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 03bc62849..60c3edfb9 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -513,6 +513,7 @@ "src/data/detectionPolicy.js", "src/data/installationFeedback.js", "src/data/layerState.js", + "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/data/militaryAwarenessEngine.js", "src/data/scenePick.js", @@ -834,6 +835,7 @@ "src/data/contactMatch.js", "src/data/iconOrientation.js", "src/data/layerState.js", + "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", "src/data/motionModel.js", @@ -1435,6 +1437,7 @@ "src/data/issPass.js", "src/data/labelArbiter.js", "src/data/layerState.js", + "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/data/lifecycle.js", "src/data/localGeojson.js", @@ -2611,6 +2614,7 @@ "modules": [ "src/data/installationFeedback.js", "src/data/layerState.js", + "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/loadingFeedback.js", "src/navigationPolicy.js", diff --git a/src/data/layerState.js b/src/data/layerState.js index eccc555f6..df67bee9f 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -11,8 +11,6 @@ export const LAYER_STATE_TOKEN_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; const LAYER_STATE_TOKEN_PATTERN = /^[a-z0-9]{1,2}$/; const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/; -/** Re-check cadence while a shared subject waits for its feed row to arrive. */ -const PENDING_TRACKING_POLL_MS = 1_000; /** * A tracking ID is a transponder address, not free text: 6 hex digits for an * ICAO24, with slack for TIS-B (`~abc123`) and similar prefixed forms. Bounding @@ -433,12 +431,6 @@ const OPTION_GROUPS = Object.freeze({ ]), }); -const TRACKING_OPTION_KEY_BY_LAYER = Object.freeze({ - flights: 'selectedFlightsTrackingId', - military: 'selectedMilitaryTrackingId', - satellites: 'selectedSatTrackingId', -}); - export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({ flights: Object.freeze({ optionOwner: 'flights', @@ -740,7 +732,7 @@ export function validateLayerStateAllocations( return true; } -const REGISTRY_BY_ID = new Map( +export const REGISTRY_BY_ID = new Map( LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]), ); const REGISTRY_BY_TOKEN = new Map( @@ -752,7 +744,7 @@ const OPTION_OWNER_IDS = Object.freeze([ ), ]); -function optionSpecs(ownerId) { +export function optionSpecs(ownerId) { return OPTION_GROUPS[ownerId] || []; } @@ -1055,716 +1047,3 @@ export function layerOptionsForRestore(state, layerId) { if (!entry?.optionOwner) return null; return { ...normalizeLayerState(state).options[entry.optionOwner] }; } - -function safeStorage() { - try { - return globalThis.localStorage || null; - } catch { - return null; - } -} - -function currentLayerOutcome(dataManager, layerId) { - const state = dataManager.getLayerLifecycleState?.(layerId); - return { - settledEnabled: Boolean(state?.enabled), - lifecycleState: state?.lifecycleState || 'missing', - lifecycleUncertain: Boolean(state?.uncertain), - }; -} - -/** - * Owns durable user layer preferences independently from transient runtime - * choreography, and coordinates passive post-registration restoration. - */ -export class LayerStateCoordinator { - constructor( - dataManager, - shareLinkManager, - { - storage = safeStorage(), - restoreGate = null, - onDurableStateChange = null, - onTrackingRestoreStatus = null, - now = () => Date.now(), - // Injectable so the pending-window behavior is deterministically testable - // without sleeping out a 90 s expiry. - setTimer = (fn, ms) => setTimeout(fn, ms), - clearTimer = (handle) => clearTimeout(handle), - } = {}, - ) { - if (!dataManager?.registrationsFinalized) { - throw new Error( - 'Layer state requires finalized data-layer registrations', - ); - } - this.dataManager = dataManager; - this.shareLinkManager = shareLinkManager || null; - this.storage = storage; - this.restoreGate = restoreGate; - this.onDurableStateChange = onDurableStateChange; - this.onTrackingRestoreStatus = onTrackingRestoreStatus; - this.now = now; - this.setTimer = setTimer; - this.clearTimer = clearTimer; - this._durableState = createDefaultLayerState(); - this._source = 'defaults'; - this._destroyed = false; - this._restoreControllers = new Map(); - this._shareCreatedAtMs = null; - this._trackingRestoreController = null; - this._trackingRestoreGeneration = 0; - this._pendingTrackingTimer = null; - this._pendingTrackingContext = null; - this._unsubscribe = this.dataManager.subscribe((change) => - this._handleManagerChange(change), - ); - this._unsubscribeVisibilityRequests = - this.dataManager.subscribeVisibilityRequests((change) => - this._handleVisibilityRequest(change), - ); - this.restorePromise = Promise.resolve([]); - this.lastRestoreResults = []; - } - - start({ - shareLayerState = null, - allowLocalState = true, - shareCreatedAtMs = null, - } = {}) { - if (this._destroyed) - throw new Error('Layer-state coordinator is destroyed'); - let selected = shareLayerState - ? normalizeLayerState(shareLayerState) - : null; - if (selected) { - this._source = 'share'; - this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs) - ? shareCreatedAtMs - : null; - } else if (allowLocalState) { - let stored = null; - try { - stored = parseStoredLayerState( - this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY), - ); - } catch { - /* best effort */ - } - if (stored) { - selected = stored; - this._source = 'local'; - } - } else { - // A valid historical camera/style share with no v2 layer payload keeps - // the exact legacy default-layer behavior. It must not inherit an - // unrelated recipient's saved local layer preferences. - this._source = 'legacy-share'; - } - this._durableState = selected || createDefaultLayerState(); - this.shareLinkManager?.setLayerStateProvider?.(() => - this.getDurableState(), - ); - this.shareLinkManager?.onLayerStateChange?.(); - this._notifyDurableState(); - if (!selected) return this.restorePromise; - this.restorePromise = this._restoreSelectedState( - this._source === 'share' - ? LAYER_RESTORE_ORIGINS.share - : LAYER_RESTORE_ORIGINS.local, - ); - return this.restorePromise; - } - - get source() { - return this._source; - } - - getDurableState() { - return cloneLayerState(this._durableState); - } - - _notifyDurableState() { - try { - this.onDurableStateChange?.(this.getDurableState()); - } catch { - /* UI sync is best effort */ - } - } - - _handleVisibilityRequest(change) { - if (!isExplicitLayerStateOrigin(change?.origin)) return; - this._restoreControllers - .get(change.layerId) - ?.abort('superseded-by-explicit-visibility'); - if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) { - this._revokePendingTrackingWatch('superseded-by-explicit-visibility'); - } - } - - /** Revoke every passive restore before explicit navigation can be reclaimed. */ - cancelPendingRestores(reason = 'superseded-by-explicit-navigation') { - for (const controller of this._restoreControllers.values()) - controller.abort(reason); - this._revokePendingTrackingWatch(reason); - } - - /** - * Revoke a pending shared Follow. Physical navigation may also clear only - * the exact passive selection, without writing recipient preferences. - */ - cancelPendingShareTracking( - reason = 'superseded-by-explicit-navigation', - { clearSelection = false } = {}, - ) { - this._revokePendingTrackingWatch(reason); - if (!clearSelection) return false; - const selected = this._selectedShareTrackingTarget(); - return selected ? this._passivelyClearTrackingSelection(selected) : false; - } - - _handleManagerChange(change) { - if (!change || this._destroyed) return; - // Parameter and visibility ownership are independent. A newer explicit - // option request may replace passive share options, but it must not abort - // the same layer's visibility lifecycle. - if (change.type === 'params-requested') { - if ( - isExplicitLayerStateOrigin(change.origin) && - SHARE_TRACKING_RESTORE_POLICIES[change.layerId] - ) { - this._revokePendingTrackingWatch('superseded-by-explicit-params'); - } - return; - } - // A layer that goes away takes its latch with it, at ANY origin — a - // programmatic disable or teardown never reaches the explicit-intent path - // below, so revoke here before that early return. - if ( - change.type === 'visibility' && - change.enabled === false && - SHARE_TRACKING_RESTORE_POLICIES[change.layerId] - ) { - this._revokePendingTrackingWatch('owner-layer-disabled'); - } - if (!isExplicitLayerStateOrigin(change.origin)) return; - if (change.type === 'visibility') { - this._restoreControllers - .get(change.layerId) - ?.abort('superseded-by-explicit-visibility'); - if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) { - this._revokePendingTrackingWatch('superseded-by-explicit-visibility'); - } - const enabled = new Set(this._durableState.enabledLayerIds); - if (change.enabled) enabled.add(change.layerId); - else enabled.delete(change.layerId); - this._commitExplicit({ - ...this._durableState, - enabledLayerIds: [...enabled], - }); - return; - } - if (change.type !== 'params') return; - const entry = REGISTRY_BY_ID.get(change.layerId); - if (!entry?.optionOwner) return; - const ownerId = entry.optionOwner; - const nextOwnerOptions = { ...this._durableState.options[ownerId] }; - const requestedParams = change.requestedParams || {}; - const trackingOptionKey = - TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null; - let changed = false; - for (const spec of optionSpecs(ownerId)) { - // Only persist keys present in this explicit request. getLayerParams() - // can return a wider live snapshot containing transient or passively - // changed values that this user action did not own. Tracking is the one - // exception: an unrelated explicit option cancels a pending restoration - // in that family, so its wider live value (active ID or null) must replace - // the formerly durable pending ID instead of allowing reload resurrection. - const explicitlyRequested = Object.hasOwn(requestedParams, spec.key); - const implicitTrackingSync = - !explicitlyRequested && spec.key === trackingOptionKey; - if (!explicitlyRequested && !implicitTrackingSync) continue; - const value = spec.normalize(change.params[spec.key]); - if (value === null) { - if (implicitTrackingSync) { - nextOwnerOptions[spec.key] = null; - changed = true; - continue; - } - if (spec.defaultValue !== null || change.params[spec.key] !== null) - continue; - } - nextOwnerOptions[spec.key] = value; - changed = true; - } - if (!changed) return; - this._commitExplicit({ - ...this._durableState, - options: { ...this._durableState.options, [ownerId]: nextOwnerOptions }, - }); - } - - _commitExplicit(candidate) { - this._durableState = normalizeLayerState(candidate); - const serialized = serializeStoredLayerState(this._durableState); - try { - if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) { - this.storage?.setItem?.(LAYER_STATE_STORAGE_KEY, serialized); - } - } catch { - /* storage can be unavailable or quota-limited */ - } - this.shareLinkManager?.onLayerStateChange?.(); - this._notifyDurableState(); - } - - async _waitForRestoreGate() { - if (!this.restoreGate) return; - await (typeof this.restoreGate === 'function' - ? this.restoreGate() - : this.restoreGate); - } - - async _restoreSelectedState(origin) { - for (const entry of LAYER_STATE_REGISTRY) { - this._restoreControllers.set(entry.id, new AbortController()); - } - try { - await this._waitForRestoreGate(); - const enabled = new Set(this._durableState.enabledLayerIds); - const settled = await Promise.allSettled( - LAYER_STATE_REGISTRY.map(async (entry) => { - const controller = this._restoreControllers.get(entry.id); - const targetEnabled = enabled.has(entry.id); - const options = layerOptionsForRestore(this._durableState, entry.id); - if (origin === LAYER_RESTORE_ORIGINS.share && options) { - for (const trackingKey of Object.values( - TRACKING_OPTION_KEY_BY_LAYER, - )) { - delete options[trackingKey]; - } - } - if (this._destroyed || controller?.signal.aborted) { - return { - layerId: entry.id, - targetEnabled, - origin, - phase: 'reserved', - ...currentLayerOutcome(this.dataManager, entry.id), - appliedOptions: {}, - cancellationReason: this._destroyed ? 'destroyed' : 'superseded', - errorClass: 'cancelled', - persistenceWrite: false, - succeeded: false, - }; - } - // Reserve passive option state before any asynchronous lifecycle work. - // A later explicit params intent then wins on its own lane without - // cancelling or being overwritten by the visibility restore. - const paramsSucceeded = - !options || - Object.keys(options).length === 0 || - this.dataManager.setLayerParams(entry.id, options, { origin }); - return this.dataManager - .restoreLayerState( - entry.id, - { - enabled: targetEnabled, - params: null, - }, - { origin, signal: controller.signal }, - ) - .then((result) => ({ - ...result, - appliedOptions: paramsSucceeded && options ? options : {}, - errorClass: paramsSucceeded - ? result.errorClass - : 'ParamsRejected', - succeeded: paramsSucceeded && result.succeeded, - })); - }), - ); - this.lastRestoreResults = settled.map((result, index) => { - if (result.status === 'fulfilled') return result.value; - const entry = LAYER_STATE_REGISTRY[index]; - return { - layerId: entry.id, - targetEnabled: enabled.has(entry.id), - origin, - phase: 'coordinator', - ...currentLayerOutcome(this.dataManager, entry.id), - appliedOptions: {}, - cancellationReason: null, - errorClass: result.reason?.name || 'Error', - error: String(result.reason?.message || result.reason), - persistenceWrite: false, - succeeded: false, - }; - }); - return this.lastRestoreResults.map((result) => ({ ...result })); - } finally { - this._restoreControllers.clear(); - this._notifyDurableState(); - } - } - - _selectedShareTrackingTarget() { - if (this._source !== 'share') return null; - for (const [layerId, policy] of Object.entries( - SHARE_TRACKING_RESTORE_POLICIES, - )) { - const targetId = - this._durableState.options?.[policy.optionOwner]?.[policy.optionKey]; - if (targetId !== null && targetId !== undefined && targetId !== '') { - return { layerId, targetId, ...policy }; - } - } - return null; - } - - _passivelyClearTrackingSelection(selected) { - const current = - this._durableState.options?.[selected.optionOwner]?.[selected.optionKey]; - if (String(current) !== String(selected.targetId)) return false; - const ownerOptions = { - ...this._durableState.options[selected.optionOwner], - [selected.optionKey]: null, - }; - this._durableState = normalizeLayerState({ - ...this._durableState, - options: { - ...this._durableState.options, - [selected.optionOwner]: ownerOptions, - }, - }); - this.dataManager.setLayerParams?.( - selected.layerId, - { [selected.optionKey]: null }, - { origin: LAYER_RESTORE_ORIGINS.share }, - ); - this.shareLinkManager?.onLayerStateChange?.(); - this._notifyDurableState(); - return true; - } - - /** - * `atMs` is the moment the subject was first found ABSENT, not the moment the - * verdict is delivered. Waiting out the pending window must not by itself - * push a fresh link into the "expired" wording — that word describes the - * SHARE's age, not how long this client watched for the subject. - */ - _classifyMissingTrackingTarget(selected, atMs = this.now()) { - const copiedAt = this._shareCreatedAtMs; - if (!Number.isFinite(copiedAt)) return 'unavailable'; - const ageMs = atMs - copiedAt; - return ageMs > selected.expiryWindowMs ? 'expired' : 'unavailable'; - } - - /** Whether the owning layer currently follows the shared subject. */ - _trackingTargetLatched(selected) { - const params = this.dataManager.getLayerParams?.(selected.layerId); - const active = params?.[selected.optionKey]; - return ( - active !== null && - active !== undefined && - String(active) === String(selected.targetId) - ); - } - - /** Stop watching a pending shared subject without deciding its fate. */ - _cancelPendingTrackingWatch() { - if (this._pendingTrackingTimer !== null) - this.clearTimer(this._pendingTrackingTimer); - this._pendingTrackingTimer = null; - const pending = this._pendingTrackingContext; - if (pending?.signal && pending.abortHandler) { - pending.signal.removeEventListener('abort', pending.abortHandler); - pending.abortHandler = null; - } - } - - /** Publish a share-follow lifecycle update without allowing UI errors to own state. */ - _publishTrackingRestoreStatus(status) { - try { - this.onTrackingRestoreStatus?.(status); - } catch { - /* status UI is best effort */ - } - } - - /** - * Revoke a pending shared Follow wholesale. - * - * The watch and the LAYER's own deferred-restore latch are two halves of one - * mechanism, so they must die together. Aborting only the restore controller - * left the timer alive: the controller has already settled by the time the - * watch exists, so the abort was a no-op and the orphaned timer went on to - * announce "Shared … unavailable" for a subject whose latch had been - * cancelled — a notice about work no longer being attempted. - */ - _revokePendingTrackingWatch(reason) { - this._trackingRestoreController?.abort(reason); - this._trackingRestoreGeneration += 1; - this._cancelPendingTrackingWatch(); - const pending = this._pendingTrackingContext; - this._pendingTrackingContext = null; - if (pending) { - this.dataManager.cancelPendingLayerRestore?.(pending.selected.layerId, { - origin: LAYER_RESTORE_ORIGINS.share, - reason: String(reason || 'cancelled'), - }); - this._publishTrackingRestoreStatus({ - ...pending.probe, - ...pending.selected, - status: 'cancelled', - classification: 'cancelled', - reason: String(reason || 'cancelled'), - cleared: false, - }); - } - } - - /** - * Hold a not-yet-arrived shared subject PENDING instead of declaring it gone. - * - * A recipient's first authoritative refresh routinely lands without a given - * contact — the feed is polled, coverage is partial, and rendering trails the - * snapshot by a poll. Reload-from-local already survives this: its restore - * arms the layer's own deferred-restore latch, which re-attempts on every - * later poll. The shared path used to decide on that single refresh, clear - * the subject from durable state AND from the URL, then post a failure notice - * seconds into startup — so the same link healed on reload but never on the - * share. - * - * Arm the SAME latch, then watch it in the background: the caller is never - * blocked (startup must not wait out a 90 s window before it may write the - * URL again), and the terminal verdict is deferred until the source-specific - * window has genuinely expired. The existing wordings are unchanged. - */ - async _beginPendingTrackingRestore(selected, probe, signal = null) { - const generation = this._trackingRestoreGeneration; - const absentAtMs = this.now(); - // Arm the layer's deferred-restore latch under the passive share origin, so - // it re-attempts each poll and never rewrites recipient preferences. - let armed = false; - let armError = null; - try { - armed = - (await this.dataManager.setLayerParams?.( - selected.layerId, - { [selected.optionKey]: selected.targetId }, - { origin: LAYER_RESTORE_ORIGINS.share }, - )) === true; - } catch (error) { - armError = error; - } - if ( - this._destroyed || - generation !== this._trackingRestoreGeneration || - signal?.aborted - ) { - if (armed) { - this.dataManager.cancelPendingLayerRestore?.(selected.layerId, { - origin: LAYER_RESTORE_ORIGINS.share, - reason: String(signal?.reason || 'superseded'), - }); - } - return { - ...probe, - ...selected, - status: 'cancelled', - classification: 'cancelled', - reason: String(signal?.reason || 'superseded'), - cleared: false, - }; - } - if (!armed) { - const terminal = { - ...probe, - ...selected, - status: 'source-unavailable', - classification: 'source-unavailable', - reason: String( - armError?.message || armError || 'tracking restore latch rejected', - ), - cleared: this._passivelyClearTrackingSelection(selected), - }; - this._publishTrackingRestoreStatus(terminal); - return terminal; - } - const deadline = this.now() + selected.expiryWindowMs; - const settle = (terminal) => { - if (this._pendingTrackingContext?.generation !== generation) return; - this._cancelPendingTrackingWatch(); - this._pendingTrackingContext = null; - this._publishTrackingRestoreStatus(terminal); - }; - const poll = () => { - this._pendingTrackingTimer = null; - if (this._destroyed || generation !== this._trackingRestoreGeneration) - return; - // The layer that owns the latch may have gone away since the last tick - // (disable, teardown, replacement). There is nothing left attempting this - // restore, so abandon it silently rather than announcing a verdict. - if (this.dataManager.isEffectivelyEnabled?.(selected.layerId) === false) { - this._revokePendingTrackingWatch('owner-layer-disabled'); - return; - } - if (this._trackingTargetLatched(selected)) { - settle({ - ...probe, - ...selected, - status: 'found', - classification: 'followed', - cleared: false, - }); - return; - } - if (this.now() >= deadline) { - // The window really has elapsed — only now does the verdict apply. - const classification = - probe.status === 'missing' - ? this._classifyMissingTrackingTarget(selected, absentAtMs) - : 'source-unavailable'; - const cleared = this._passivelyClearTrackingSelection(selected); - settle({ ...probe, ...selected, classification, cleared }); - return; - } - this._pendingTrackingTimer = this.setTimer( - poll, - PENDING_TRACKING_POLL_MS, - ); - this._pendingTrackingTimer?.unref?.(); - }; - this._cancelPendingTrackingWatch(); - const pending = { - ...probe, - ...selected, - status: 'pending', - classification: 'pending', - cleared: false, - }; - const pendingContext = { - generation, - selected, - probe, - signal, - abortHandler: null, - }; - if (signal) { - pendingContext.abortHandler = () => { - if (this._pendingTrackingContext?.generation !== generation) return; - this._revokePendingTrackingWatch(signal.reason || 'aborted'); - }; - signal.addEventListener('abort', pendingContext.abortHandler, { - once: true, - }); - } - this._pendingTrackingContext = pendingContext; - this._publishTrackingRestoreStatus(pending); - this._pendingTrackingTimer = this.setTimer(poll, PENDING_TRACKING_POLL_MS); - this._pendingTrackingTimer?.unref?.(); - return pending; - } - - /** - * Refresh and restore the one shareable tracked target after destination - * camera and ordinary layer restoration have settled. - */ - async restoreShareTrackingSelection({ signal = null } = {}) { - const selected = this._selectedShareTrackingTarget(); - if (!selected || this._destroyed) - return { status: 'skipped', reason: 'no-shared-target' }; - this._revokePendingTrackingWatch('superseded-by-newer-restore'); - const controller = new AbortController(); - const combinedSignal = signal - ? AbortSignal.any([signal, controller.signal]) - : controller.signal; - this._trackingRestoreController = controller; - const generation = ++this._trackingRestoreGeneration; - let result; - try { - result = await this.dataManager.resolveLayerTrackingTarget( - selected.layerId, - selected.targetId, - { signal: combinedSignal, origin: LAYER_RESTORE_ORIGINS.share }, - ); - } catch (error) { - result = combinedSignal.aborted - ? { - status: 'cancelled', - reason: String(combinedSignal.reason || 'aborted'), - } - : { - status: 'source-unavailable', - reason: String(error?.message || error), - }; - } - if ( - generation !== this._trackingRestoreGeneration || - this._destroyed || - combinedSignal.aborted - ) { - if (this._trackingRestoreController === controller) - this._trackingRestoreController = null; - return { - ...result, - status: 'cancelled', - reason: String(combinedSignal.reason || 'superseded'), - }; - } - if (this._trackingRestoreController === controller) - this._trackingRestoreController = null; - - if (result.status === 'found') { - const terminal = { - ...result, - ...selected, - classification: 'followed', - cleared: false, - }; - this._publishTrackingRestoreStatus(terminal); - return terminal; - } - if (['cancelled', 'superseded', 'destroyed'].includes(result.status)) - return result; - - // A subject that is simply not here YET is not a subject that is gone. Hold - // it on the layer's own deferred-restore latch for its source-specific - // window before any verdict is reached or shown. `unsupported` layers have - // no latch to arm, so they still decide immediately. - if (result.status === 'missing' || result.status === 'source-unavailable') { - return this._beginPendingTrackingRestore( - selected, - result, - combinedSignal, - ); - } - - const classification = - result.status === 'missing' - ? this._classifyMissingTrackingTarget(selected) - : 'source-unavailable'; - const cleared = this._passivelyClearTrackingSelection(selected); - const terminal = { ...result, ...selected, classification, cleared }; - this._publishTrackingRestoreStatus(terminal); - return terminal; - } - - destroy() { - if (this._destroyed) return; - this._destroyed = true; - for (const controller of this._restoreControllers.values()) - controller.abort('coordinator-destroyed'); - this._restoreControllers.clear(); - this._revokePendingTrackingWatch('coordinator-destroyed'); - this._trackingRestoreController = null; - this._unsubscribe?.(); - this._unsubscribe = null; - this._unsubscribeVisibilityRequests?.(); - this._unsubscribeVisibilityRequests = null; - this.shareLinkManager?.setLayerStateProvider?.(null); - this.onDurableStateChange = null; - this.onTrackingRestoreStatus = null; - } -} diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 1b504a439..b7ec02174 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -11,7 +11,6 @@ import { LAYER_STATE_STORAGE_KEY, LAYER_STATE_TOKEN_ALPHABET, LAYER_STATE_TOKEN_RESERVATIONS, - LayerStateCoordinator, REGISTERED_LAYER_IDS, SHARE_TRACKING_RESTORE_POLICIES, createDefaultLayerState, @@ -24,6 +23,7 @@ import { validateLayerStateAllocations, validateLayerStateRegistry, } from './layerState.js'; +import { LayerStateCoordinator } from './layerStateCoordinator.js'; import radioLayer from './radio.js'; import { stampInitialShareGesture } from '../navigationPolicy.js'; diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js new file mode 100644 index 000000000..86b6af8e7 --- /dev/null +++ b/src/data/layerStateCoordinator.js @@ -0,0 +1,742 @@ +/** + * Applies layer state to the running application: restores shared and stored + * layer choices and persists changes to browser storage. + */ + +import { + LAYER_RESTORE_ORIGINS, + LAYER_STATE_REGISTRY, + LAYER_STATE_STORAGE_KEY, + REGISTRY_BY_ID, + SHARE_TRACKING_RESTORE_POLICIES, + cloneLayerState, + createDefaultLayerState, + isExplicitLayerStateOrigin, + layerOptionsForRestore, + normalizeLayerState, + optionSpecs, + parseStoredLayerState, + serializeStoredLayerState, +} from './layerState.js'; + +/** Re-check cadence while a shared subject waits for its feed row to arrive. */ +const PENDING_TRACKING_POLL_MS = 1_000; + +const TRACKING_OPTION_KEY_BY_LAYER = Object.freeze({ + flights: 'selectedFlightsTrackingId', + military: 'selectedMilitaryTrackingId', + satellites: 'selectedSatTrackingId', +}); + +function safeStorage() { + try { + return globalThis.localStorage || null; + } catch { + return null; + } +} + +function currentLayerOutcome(dataManager, layerId) { + const state = dataManager.getLayerLifecycleState?.(layerId); + return { + settledEnabled: Boolean(state?.enabled), + lifecycleState: state?.lifecycleState || 'missing', + lifecycleUncertain: Boolean(state?.uncertain), + }; +} + +/** + * Owns durable user layer preferences independently from transient runtime + * choreography, and coordinates passive post-registration restoration. + */ +export class LayerStateCoordinator { + constructor( + dataManager, + shareLinkManager, + { + storage = safeStorage(), + restoreGate = null, + onDurableStateChange = null, + onTrackingRestoreStatus = null, + now = () => Date.now(), + // Injectable so the pending-window behavior is deterministically testable + // without sleeping out a 90 s expiry. + setTimer = (fn, ms) => setTimeout(fn, ms), + clearTimer = (handle) => clearTimeout(handle), + } = {}, + ) { + if (!dataManager?.registrationsFinalized) { + throw new Error( + 'Layer state requires finalized data-layer registrations', + ); + } + this.dataManager = dataManager; + this.shareLinkManager = shareLinkManager || null; + this.storage = storage; + this.restoreGate = restoreGate; + this.onDurableStateChange = onDurableStateChange; + this.onTrackingRestoreStatus = onTrackingRestoreStatus; + this.now = now; + this.setTimer = setTimer; + this.clearTimer = clearTimer; + this._durableState = createDefaultLayerState(); + this._source = 'defaults'; + this._destroyed = false; + this._restoreControllers = new Map(); + this._shareCreatedAtMs = null; + this._trackingRestoreController = null; + this._trackingRestoreGeneration = 0; + this._pendingTrackingTimer = null; + this._pendingTrackingContext = null; + this._unsubscribe = this.dataManager.subscribe((change) => + this._handleManagerChange(change), + ); + this._unsubscribeVisibilityRequests = + this.dataManager.subscribeVisibilityRequests((change) => + this._handleVisibilityRequest(change), + ); + this.restorePromise = Promise.resolve([]); + this.lastRestoreResults = []; + } + + start({ + shareLayerState = null, + allowLocalState = true, + shareCreatedAtMs = null, + } = {}) { + if (this._destroyed) + throw new Error('Layer-state coordinator is destroyed'); + let selected = shareLayerState + ? normalizeLayerState(shareLayerState) + : null; + if (selected) { + this._source = 'share'; + this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs) + ? shareCreatedAtMs + : null; + } else if (allowLocalState) { + let stored = null; + try { + stored = parseStoredLayerState( + this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY), + ); + } catch { + /* best effort */ + } + if (stored) { + selected = stored; + this._source = 'local'; + } + } else { + // A valid historical camera/style share with no v2 layer payload keeps + // the exact legacy default-layer behavior. It must not inherit an + // unrelated recipient's saved local layer preferences. + this._source = 'legacy-share'; + } + this._durableState = selected || createDefaultLayerState(); + this.shareLinkManager?.setLayerStateProvider?.(() => + this.getDurableState(), + ); + this.shareLinkManager?.onLayerStateChange?.(); + this._notifyDurableState(); + if (!selected) return this.restorePromise; + this.restorePromise = this._restoreSelectedState( + this._source === 'share' + ? LAYER_RESTORE_ORIGINS.share + : LAYER_RESTORE_ORIGINS.local, + ); + return this.restorePromise; + } + + get source() { + return this._source; + } + + getDurableState() { + return cloneLayerState(this._durableState); + } + + _notifyDurableState() { + try { + this.onDurableStateChange?.(this.getDurableState()); + } catch { + /* UI sync is best effort */ + } + } + + _handleVisibilityRequest(change) { + if (!isExplicitLayerStateOrigin(change?.origin)) return; + this._restoreControllers + .get(change.layerId) + ?.abort('superseded-by-explicit-visibility'); + if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) { + this._revokePendingTrackingWatch('superseded-by-explicit-visibility'); + } + } + + /** Revoke every passive restore before explicit navigation can be reclaimed. */ + cancelPendingRestores(reason = 'superseded-by-explicit-navigation') { + for (const controller of this._restoreControllers.values()) + controller.abort(reason); + this._revokePendingTrackingWatch(reason); + } + + /** + * Revoke a pending shared Follow. Physical navigation may also clear only + * the exact passive selection, without writing recipient preferences. + */ + cancelPendingShareTracking( + reason = 'superseded-by-explicit-navigation', + { clearSelection = false } = {}, + ) { + this._revokePendingTrackingWatch(reason); + if (!clearSelection) return false; + const selected = this._selectedShareTrackingTarget(); + return selected ? this._passivelyClearTrackingSelection(selected) : false; + } + + _handleManagerChange(change) { + if (!change || this._destroyed) return; + // Parameter and visibility ownership are independent. A newer explicit + // option request may replace passive share options, but it must not abort + // the same layer's visibility lifecycle. + if (change.type === 'params-requested') { + if ( + isExplicitLayerStateOrigin(change.origin) && + SHARE_TRACKING_RESTORE_POLICIES[change.layerId] + ) { + this._revokePendingTrackingWatch('superseded-by-explicit-params'); + } + return; + } + // A layer that goes away takes its latch with it, at ANY origin — a + // programmatic disable or teardown never reaches the explicit-intent path + // below, so revoke here before that early return. + if ( + change.type === 'visibility' && + change.enabled === false && + SHARE_TRACKING_RESTORE_POLICIES[change.layerId] + ) { + this._revokePendingTrackingWatch('owner-layer-disabled'); + } + if (!isExplicitLayerStateOrigin(change.origin)) return; + if (change.type === 'visibility') { + this._restoreControllers + .get(change.layerId) + ?.abort('superseded-by-explicit-visibility'); + if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) { + this._revokePendingTrackingWatch('superseded-by-explicit-visibility'); + } + const enabled = new Set(this._durableState.enabledLayerIds); + if (change.enabled) enabled.add(change.layerId); + else enabled.delete(change.layerId); + this._commitExplicit({ + ...this._durableState, + enabledLayerIds: [...enabled], + }); + return; + } + if (change.type !== 'params') return; + const entry = REGISTRY_BY_ID.get(change.layerId); + if (!entry?.optionOwner) return; + const ownerId = entry.optionOwner; + const nextOwnerOptions = { ...this._durableState.options[ownerId] }; + const requestedParams = change.requestedParams || {}; + const trackingOptionKey = + TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null; + let changed = false; + for (const spec of optionSpecs(ownerId)) { + // Only persist keys present in this explicit request. getLayerParams() + // can return a wider live snapshot containing transient or passively + // changed values that this user action did not own. Tracking is the one + // exception: an unrelated explicit option cancels a pending restoration + // in that family, so its wider live value (active ID or null) must replace + // the formerly durable pending ID instead of allowing reload resurrection. + const explicitlyRequested = Object.hasOwn(requestedParams, spec.key); + const implicitTrackingSync = + !explicitlyRequested && spec.key === trackingOptionKey; + if (!explicitlyRequested && !implicitTrackingSync) continue; + const value = spec.normalize(change.params[spec.key]); + if (value === null) { + if (implicitTrackingSync) { + nextOwnerOptions[spec.key] = null; + changed = true; + continue; + } + if (spec.defaultValue !== null || change.params[spec.key] !== null) + continue; + } + nextOwnerOptions[spec.key] = value; + changed = true; + } + if (!changed) return; + this._commitExplicit({ + ...this._durableState, + options: { ...this._durableState.options, [ownerId]: nextOwnerOptions }, + }); + } + + _commitExplicit(candidate) { + this._durableState = normalizeLayerState(candidate); + const serialized = serializeStoredLayerState(this._durableState); + try { + if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) { + this.storage?.setItem?.(LAYER_STATE_STORAGE_KEY, serialized); + } + } catch { + /* storage can be unavailable or quota-limited */ + } + this.shareLinkManager?.onLayerStateChange?.(); + this._notifyDurableState(); + } + + async _waitForRestoreGate() { + if (!this.restoreGate) return; + await (typeof this.restoreGate === 'function' + ? this.restoreGate() + : this.restoreGate); + } + + async _restoreSelectedState(origin) { + for (const entry of LAYER_STATE_REGISTRY) { + this._restoreControllers.set(entry.id, new AbortController()); + } + try { + await this._waitForRestoreGate(); + const enabled = new Set(this._durableState.enabledLayerIds); + const settled = await Promise.allSettled( + LAYER_STATE_REGISTRY.map(async (entry) => { + const controller = this._restoreControllers.get(entry.id); + const targetEnabled = enabled.has(entry.id); + const options = layerOptionsForRestore(this._durableState, entry.id); + if (origin === LAYER_RESTORE_ORIGINS.share && options) { + for (const trackingKey of Object.values( + TRACKING_OPTION_KEY_BY_LAYER, + )) { + delete options[trackingKey]; + } + } + if (this._destroyed || controller?.signal.aborted) { + return { + layerId: entry.id, + targetEnabled, + origin, + phase: 'reserved', + ...currentLayerOutcome(this.dataManager, entry.id), + appliedOptions: {}, + cancellationReason: this._destroyed ? 'destroyed' : 'superseded', + errorClass: 'cancelled', + persistenceWrite: false, + succeeded: false, + }; + } + // Reserve passive option state before any asynchronous lifecycle work. + // A later explicit params intent then wins on its own lane without + // cancelling or being overwritten by the visibility restore. + const paramsSucceeded = + !options || + Object.keys(options).length === 0 || + this.dataManager.setLayerParams(entry.id, options, { origin }); + return this.dataManager + .restoreLayerState( + entry.id, + { + enabled: targetEnabled, + params: null, + }, + { origin, signal: controller.signal }, + ) + .then((result) => ({ + ...result, + appliedOptions: paramsSucceeded && options ? options : {}, + errorClass: paramsSucceeded + ? result.errorClass + : 'ParamsRejected', + succeeded: paramsSucceeded && result.succeeded, + })); + }), + ); + this.lastRestoreResults = settled.map((result, index) => { + if (result.status === 'fulfilled') return result.value; + const entry = LAYER_STATE_REGISTRY[index]; + return { + layerId: entry.id, + targetEnabled: enabled.has(entry.id), + origin, + phase: 'coordinator', + ...currentLayerOutcome(this.dataManager, entry.id), + appliedOptions: {}, + cancellationReason: null, + errorClass: result.reason?.name || 'Error', + error: String(result.reason?.message || result.reason), + persistenceWrite: false, + succeeded: false, + }; + }); + return this.lastRestoreResults.map((result) => ({ ...result })); + } finally { + this._restoreControllers.clear(); + this._notifyDurableState(); + } + } + + _selectedShareTrackingTarget() { + if (this._source !== 'share') return null; + for (const [layerId, policy] of Object.entries( + SHARE_TRACKING_RESTORE_POLICIES, + )) { + const targetId = + this._durableState.options?.[policy.optionOwner]?.[policy.optionKey]; + if (targetId !== null && targetId !== undefined && targetId !== '') { + return { layerId, targetId, ...policy }; + } + } + return null; + } + + _passivelyClearTrackingSelection(selected) { + const current = + this._durableState.options?.[selected.optionOwner]?.[selected.optionKey]; + if (String(current) !== String(selected.targetId)) return false; + const ownerOptions = { + ...this._durableState.options[selected.optionOwner], + [selected.optionKey]: null, + }; + this._durableState = normalizeLayerState({ + ...this._durableState, + options: { + ...this._durableState.options, + [selected.optionOwner]: ownerOptions, + }, + }); + this.dataManager.setLayerParams?.( + selected.layerId, + { [selected.optionKey]: null }, + { origin: LAYER_RESTORE_ORIGINS.share }, + ); + this.shareLinkManager?.onLayerStateChange?.(); + this._notifyDurableState(); + return true; + } + + /** + * `atMs` is the moment the subject was first found ABSENT, not the moment the + * verdict is delivered. Waiting out the pending window must not by itself + * push a fresh link into the "expired" wording — that word describes the + * SHARE's age, not how long this client watched for the subject. + */ + _classifyMissingTrackingTarget(selected, atMs = this.now()) { + const copiedAt = this._shareCreatedAtMs; + if (!Number.isFinite(copiedAt)) return 'unavailable'; + const ageMs = atMs - copiedAt; + return ageMs > selected.expiryWindowMs ? 'expired' : 'unavailable'; + } + + /** Whether the owning layer currently follows the shared subject. */ + _trackingTargetLatched(selected) { + const params = this.dataManager.getLayerParams?.(selected.layerId); + const active = params?.[selected.optionKey]; + return ( + active !== null && + active !== undefined && + String(active) === String(selected.targetId) + ); + } + + /** Stop watching a pending shared subject without deciding its fate. */ + _cancelPendingTrackingWatch() { + if (this._pendingTrackingTimer !== null) + this.clearTimer(this._pendingTrackingTimer); + this._pendingTrackingTimer = null; + const pending = this._pendingTrackingContext; + if (pending?.signal && pending.abortHandler) { + pending.signal.removeEventListener('abort', pending.abortHandler); + pending.abortHandler = null; + } + } + + /** Publish a share-follow lifecycle update without allowing UI errors to own state. */ + _publishTrackingRestoreStatus(status) { + try { + this.onTrackingRestoreStatus?.(status); + } catch { + /* status UI is best effort */ + } + } + + /** + * Revoke a pending shared Follow wholesale. + * + * The watch and the LAYER's own deferred-restore latch are two halves of one + * mechanism, so they must die together. Aborting only the restore controller + * left the timer alive: the controller has already settled by the time the + * watch exists, so the abort was a no-op and the orphaned timer went on to + * announce "Shared … unavailable" for a subject whose latch had been + * cancelled — a notice about work no longer being attempted. + */ + _revokePendingTrackingWatch(reason) { + this._trackingRestoreController?.abort(reason); + this._trackingRestoreGeneration += 1; + this._cancelPendingTrackingWatch(); + const pending = this._pendingTrackingContext; + this._pendingTrackingContext = null; + if (pending) { + this.dataManager.cancelPendingLayerRestore?.(pending.selected.layerId, { + origin: LAYER_RESTORE_ORIGINS.share, + reason: String(reason || 'cancelled'), + }); + this._publishTrackingRestoreStatus({ + ...pending.probe, + ...pending.selected, + status: 'cancelled', + classification: 'cancelled', + reason: String(reason || 'cancelled'), + cleared: false, + }); + } + } + + /** + * Hold a not-yet-arrived shared subject PENDING instead of declaring it gone. + * + * A recipient's first authoritative refresh routinely lands without a given + * contact — the feed is polled, coverage is partial, and rendering trails the + * snapshot by a poll. Reload-from-local already survives this: its restore + * arms the layer's own deferred-restore latch, which re-attempts on every + * later poll. The shared path used to decide on that single refresh, clear + * the subject from durable state AND from the URL, then post a failure notice + * seconds into startup — so the same link healed on reload but never on the + * share. + * + * Arm the SAME latch, then watch it in the background: the caller is never + * blocked (startup must not wait out a 90 s window before it may write the + * URL again), and the terminal verdict is deferred until the source-specific + * window has genuinely expired. The existing wordings are unchanged. + */ + async _beginPendingTrackingRestore(selected, probe, signal = null) { + const generation = this._trackingRestoreGeneration; + const absentAtMs = this.now(); + // Arm the layer's deferred-restore latch under the passive share origin, so + // it re-attempts each poll and never rewrites recipient preferences. + let armed = false; + let armError = null; + try { + armed = + (await this.dataManager.setLayerParams?.( + selected.layerId, + { [selected.optionKey]: selected.targetId }, + { origin: LAYER_RESTORE_ORIGINS.share }, + )) === true; + } catch (error) { + armError = error; + } + if ( + this._destroyed || + generation !== this._trackingRestoreGeneration || + signal?.aborted + ) { + if (armed) { + this.dataManager.cancelPendingLayerRestore?.(selected.layerId, { + origin: LAYER_RESTORE_ORIGINS.share, + reason: String(signal?.reason || 'superseded'), + }); + } + return { + ...probe, + ...selected, + status: 'cancelled', + classification: 'cancelled', + reason: String(signal?.reason || 'superseded'), + cleared: false, + }; + } + if (!armed) { + const terminal = { + ...probe, + ...selected, + status: 'source-unavailable', + classification: 'source-unavailable', + reason: String( + armError?.message || armError || 'tracking restore latch rejected', + ), + cleared: this._passivelyClearTrackingSelection(selected), + }; + this._publishTrackingRestoreStatus(terminal); + return terminal; + } + const deadline = this.now() + selected.expiryWindowMs; + const settle = (terminal) => { + if (this._pendingTrackingContext?.generation !== generation) return; + this._cancelPendingTrackingWatch(); + this._pendingTrackingContext = null; + this._publishTrackingRestoreStatus(terminal); + }; + const poll = () => { + this._pendingTrackingTimer = null; + if (this._destroyed || generation !== this._trackingRestoreGeneration) + return; + // The layer that owns the latch may have gone away since the last tick + // (disable, teardown, replacement). There is nothing left attempting this + // restore, so abandon it silently rather than announcing a verdict. + if (this.dataManager.isEffectivelyEnabled?.(selected.layerId) === false) { + this._revokePendingTrackingWatch('owner-layer-disabled'); + return; + } + if (this._trackingTargetLatched(selected)) { + settle({ + ...probe, + ...selected, + status: 'found', + classification: 'followed', + cleared: false, + }); + return; + } + if (this.now() >= deadline) { + // The window really has elapsed — only now does the verdict apply. + const classification = + probe.status === 'missing' + ? this._classifyMissingTrackingTarget(selected, absentAtMs) + : 'source-unavailable'; + const cleared = this._passivelyClearTrackingSelection(selected); + settle({ ...probe, ...selected, classification, cleared }); + return; + } + this._pendingTrackingTimer = this.setTimer( + poll, + PENDING_TRACKING_POLL_MS, + ); + this._pendingTrackingTimer?.unref?.(); + }; + this._cancelPendingTrackingWatch(); + const pending = { + ...probe, + ...selected, + status: 'pending', + classification: 'pending', + cleared: false, + }; + const pendingContext = { + generation, + selected, + probe, + signal, + abortHandler: null, + }; + if (signal) { + pendingContext.abortHandler = () => { + if (this._pendingTrackingContext?.generation !== generation) return; + this._revokePendingTrackingWatch(signal.reason || 'aborted'); + }; + signal.addEventListener('abort', pendingContext.abortHandler, { + once: true, + }); + } + this._pendingTrackingContext = pendingContext; + this._publishTrackingRestoreStatus(pending); + this._pendingTrackingTimer = this.setTimer(poll, PENDING_TRACKING_POLL_MS); + this._pendingTrackingTimer?.unref?.(); + return pending; + } + + /** + * Refresh and restore the one shareable tracked target after destination + * camera and ordinary layer restoration have settled. + */ + async restoreShareTrackingSelection({ signal = null } = {}) { + const selected = this._selectedShareTrackingTarget(); + if (!selected || this._destroyed) + return { status: 'skipped', reason: 'no-shared-target' }; + this._revokePendingTrackingWatch('superseded-by-newer-restore'); + const controller = new AbortController(); + const combinedSignal = signal + ? AbortSignal.any([signal, controller.signal]) + : controller.signal; + this._trackingRestoreController = controller; + const generation = ++this._trackingRestoreGeneration; + let result; + try { + result = await this.dataManager.resolveLayerTrackingTarget( + selected.layerId, + selected.targetId, + { signal: combinedSignal, origin: LAYER_RESTORE_ORIGINS.share }, + ); + } catch (error) { + result = combinedSignal.aborted + ? { + status: 'cancelled', + reason: String(combinedSignal.reason || 'aborted'), + } + : { + status: 'source-unavailable', + reason: String(error?.message || error), + }; + } + if ( + generation !== this._trackingRestoreGeneration || + this._destroyed || + combinedSignal.aborted + ) { + if (this._trackingRestoreController === controller) + this._trackingRestoreController = null; + return { + ...result, + status: 'cancelled', + reason: String(combinedSignal.reason || 'superseded'), + }; + } + if (this._trackingRestoreController === controller) + this._trackingRestoreController = null; + + if (result.status === 'found') { + const terminal = { + ...result, + ...selected, + classification: 'followed', + cleared: false, + }; + this._publishTrackingRestoreStatus(terminal); + return terminal; + } + if (['cancelled', 'superseded', 'destroyed'].includes(result.status)) + return result; + + // A subject that is simply not here YET is not a subject that is gone. Hold + // it on the layer's own deferred-restore latch for its source-specific + // window before any verdict is reached or shown. `unsupported` layers have + // no latch to arm, so they still decide immediately. + if (result.status === 'missing' || result.status === 'source-unavailable') { + return this._beginPendingTrackingRestore( + selected, + result, + combinedSignal, + ); + } + + const classification = + result.status === 'missing' + ? this._classifyMissingTrackingTarget(selected) + : 'source-unavailable'; + const cleared = this._passivelyClearTrackingSelection(selected); + const terminal = { ...result, ...selected, classification, cleared }; + this._publishTrackingRestoreStatus(terminal); + return terminal; + } + + destroy() { + if (this._destroyed) return; + this._destroyed = true; + for (const controller of this._restoreControllers.values()) + controller.abort('coordinator-destroyed'); + this._restoreControllers.clear(); + this._revokePendingTrackingWatch('coordinator-destroyed'); + this._trackingRestoreController = null; + this._unsubscribe?.(); + this._unsubscribe = null; + this._unsubscribeVisibilityRequests?.(); + this._unsubscribeVisibilityRequests = null; + this.shareLinkManager?.setLayerStateProvider?.(null); + this.onDurableStateChange = null; + this.onTrackingRestoreStatus = null; + } +} diff --git a/src/layers/aircraft/index.js b/src/layers/aircraft/index.js index b9b7315f7..e5f048164 100644 --- a/src/layers/aircraft/index.js +++ b/src/layers/aircraft/index.js @@ -9,6 +9,7 @@ export * from '../../data/cockpitContactDot.js'; export * from '../../data/contactMatch.js'; export * from '../../data/iconOrientation.js'; export * from '../../data/layerState.js'; +export * from '../../data/layerStateCoordinator.js'; export * from '../../data/modelVisualAnchor.js'; export * from '../../data/motionModel.js'; export * from '../../data/renderAltitude.js'; diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 7e14b339a..135d70d75 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -1,4 +1,4 @@ -import { LayerStateCoordinator } from '../data/layerState.js'; +import { LayerStateCoordinator } from '../data/layerStateCoordinator.js'; import { stampInitialShareGesture } from '../navigationPolicy.js'; import { canPresentDeferredStatusNotice } from '../loadingFeedback.js'; import { UiLifetime } from './uiLifetime.js'; From fe69f22346a958c5f8b40727d3512114ab73dc79 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:17:15 -0700 Subject: [PATCH 018/111] feat: turn layers on in links to an area open_in_gods_eye_view accepts layers and writes them with the app's own share-link layer codec, so the link opens with those layers on. Layer names are limited to the registered layer ids. --- docs/TOOLS.md | 2 +- scripts/package-boundaries.json | 2 ++ src/tools/queries/share.js | 34 ++++++++++++++++++++++++++------ src/tools/queries/share.test.mjs | 25 +++++++++++++++++++++++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 8a9bec7ae..2c102da85 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -135,4 +135,4 @@ public feeds the sources already use. | `get_map_features` | `features` | Administrative areas, named places or monuments at a location (needs Overpass) | | `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, 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 looking straight down on an area | +| `open_in_gods_eye_view` | `app` | A share link looking straight down on an area, with chosen layers on | diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 60c3edfb9..ecde8cac4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3102,6 +3102,8 @@ "src/tools/queries/brief.js", "src/tools/queries/maritime.js", "src/tools/queries/share.js", + "src/data/layerState.js", + "src/data/layerStateTokenReservations.json", "src/sources/gbfsStations.js", "src/sources/tle.js", "src/data/satellitePass.js" diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index 1c1aaf703..cbb3e8bab 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -1,5 +1,10 @@ /** A link that opens God's Eye View on an area. */ +import { + REGISTERED_LAYER_IDS, + createDefaultLayerState, + encodeLayerStateParams, +} from '../../data/layerState.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, areaCenter, areaRadiusKm, resolveArea } from '../area.js'; @@ -12,10 +17,19 @@ export const openInGodsEyeView = defineTool({ name: 'open_in_gods_eye_view', title: "Open in God's Eye View", description: - "A link that opens God's Eye View looking straight down on an area.", + "A link that opens God's Eye View looking straight down on an area, " + + 'optionally with chosen data layers turned on.', inputSchema: { type: 'object', - properties: { area: AREA_SCHEMA }, + properties: { + area: AREA_SCHEMA, + layers: { + type: 'array', + maxItems: REGISTERED_LAYER_IDS.length, + items: { type: 'string', enum: [...REGISTERED_LAYER_IDS] }, + description: 'Layers to turn on, such as "flights" or "earthquakes".', + }, + }, required: ['area'], additionalProperties: false, }, @@ -38,18 +52,26 @@ export const openInGodsEyeView = defineTool({ ), ), ); - // The share-link camera fields; the app restores its defaults for the rest. - base.hash = new URLSearchParams({ + // The share-link camera and layer fields; the app restores its defaults + // for everything else. + const params = new URLSearchParams({ v: '2', lat: center.lat.toFixed(4), lon: center.lon.toFixed(4), alt: String(altitude), heading: '0', pitch: '-90', - }).toString(); + }); + const layers = [...new Set(args.layers || [])]; + if (layers.length) + encodeLayerStateParams(params, { + ...createDefaultLayerState(), + enabledLayerIds: layers, + }); + base.hash = params.toString(); return { summary: `Open ${area.label} in God's Eye View: ${base.href}`, - data: { url: base.href, center, altitude_m: altitude }, + data: { url: base.href, center, altitude_m: altitude, layers }, }; }, }); diff --git a/src/tools/queries/share.test.mjs b/src/tools/queries/share.test.mjs index 570bc1d0f..038e1eb88 100644 --- a/src/tools/queries/share.test.mjs +++ b/src/tools/queries/share.test.mjs @@ -53,3 +53,28 @@ test('altitude is bounded for tiny and planet-sized areas', async () => { (error) => error.code === 'unavailable', ); }); + +test('links turn on the requested layers with the app share-link codec', async () => { + const { decodeLayerStateParams } = await import('../../data/layerState.js'); + const catalog = composeCatalog({ + tools: coreTools, + services: { app: { baseUrl: 'http://localhost:4173/' } }, + }); + const result = await catalog.call('open_in_gods_eye_view', { + area: { lat: 30.27, lon: -97.74, radius_km: 10 }, + layers: ['earthquakes', 'flights', 'flights'], + }); + assert.deepEqual(result.data.layers, ['earthquakes', 'flights']); + const params = new URLSearchParams(new URL(result.data.url).hash.slice(1)); + assert.deepEqual(decodeLayerStateParams(params).enabledLayerIds.sort(), [ + 'earthquakes', + 'flights', + ]); + await assert.rejects( + catalog.call('open_in_gods_eye_view', { + area: { lat: 0, lon: 0, radius_km: 1 }, + layers: ['not-a-layer'], + }), + /must be one of/, + ); +}); From c43c94ba5f70294c0d00c4996be2057037e34c38 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:18:01 -0700 Subject: [PATCH 019/111] feat: read weather image frames through the weather source Add getImage() to the weather source. It reads one frame through the bounded image route the layer already uses and returns its content type and bytes, so code outside the layer can show the current map. --- src/layers/weather/source.js | 14 +++++++++++ src/layers/weather/source.test.mjs | 37 ++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/layers/weather/source.js b/src/layers/weather/source.js index ec92cd5a2..65bcd4a51 100644 --- a/src/layers/weather/source.js +++ b/src/layers/weather/source.js @@ -132,5 +132,19 @@ export function createWeatherSource({ signal?.removeEventListener('abort', abort); } }, + /** Read one image frame through the bounded image route. */ + async getImage({ product, time, size, bbox = null, signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl( + weatherImageUrl(product, time, size, bbox), + { signal, redirect: 'error' }, + ); + if (!response.ok) + throw new Error(`Weather image HTTP ${response.status}`); + const bytes = new Uint8Array(await response.arrayBuffer()); + signal?.throwIfAborted(); + const type = response.headers.get('content-type') || ''; + return { contentType: type.split(';')[0].trim().toLowerCase(), bytes }; + }, }; } diff --git a/src/layers/weather/source.test.mjs b/src/layers/weather/source.test.mjs index 8913d462e..83ab24e76 100644 --- a/src/layers/weather/source.test.mjs +++ b/src/layers/weather/source.test.mjs @@ -162,3 +162,40 @@ test('detail-window image URLs carry the bbox and omit the default detail size', /size/, ); }); + +test('image frames are read through the bounded image route', async () => { + const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]); + const requested = []; + const source = createWeatherSource({ + fetchImpl: async (url, init) => { + requested.push({ url, redirect: init.redirect }); + return new Response(png, { headers: { 'Content-Type': 'image/PNG' } }); + }, + }); + const frame = await source.getImage({ + product: 'radar', + time, + size: { width: 1024, height: 512 }, + bbox: { west: -100, south: 30, east: -96, north: 32 }, + }); + assert.equal(frame.contentType, 'image/png'); + assert.deepEqual([...frame.bytes], [...png]); + assert.deepEqual(requested, [ + { + url: weatherImageUrl( + 'radar', + time, + { width: 1024, height: 512 }, + { west: -100, south: 30, east: -96, north: 32 }, + ), + redirect: 'error', + }, + ]); + const failing = createWeatherSource({ + fetchImpl: async () => new Response(null, { status: 409 }), + }); + await assert.rejects( + failing.getImage({ product: 'radar', time }), + /Weather image HTTP 409/, + ); +}); From a3aa058569709361dc787565a266851d498474ec Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:22:16 -0700 Subject: [PATCH 020/111] feat: add weather map and wind tools Add get_weather_map, which returns the latest NOAA radar, satellite or lightning map over an area as an image, choosing a window the image route accepts and reporting areas outside the product's coverage, and get_wind, which samples GFS or IFS wind at a location with the wind layer's own grid sampling. --- docs/CURRENT-STATE.md | 3 +- docs/TOOLS.md | 2 + scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 11 +- src/tools/index.js | 3 + src/tools/queries/atmosphere.js | 195 ++++++++++++++++++++++++++ src/tools/queries/atmosphere.test.mjs | 158 +++++++++++++++++++++ src/tools/queries/media.js | 12 +- src/tools/results.js | 8 ++ src/tools/services.js | 4 + 10 files changed, 385 insertions(+), 14 deletions(-) create mode 100644 src/tools/queries/atmosphere.js create mode 100644 src/tools/queries/atmosphere.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bf65cedf1..483b5919d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -13,7 +13,8 @@ area, by identifier, tracks, and type and route lookups), ships (in an area, by identifier, and tracks), satellites (next pass over a point, and those overhead now), public cameras (including a camera's current image), radio stations, place search, routing, bike-share -stations, transit vehicles, road traffic flow, weather, regional briefs, tropical cyclones, fire +stations, transit vehicles, road traffic flow, weather, weather map images +(radar, satellite, lightning), wind, regional briefs, tropical cyclones, fire perimeters, terrain height, military installations and map features, plus a combined situation brief, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 2c102da85..0e195db2a 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -127,6 +127,8 @@ public feeds the sources already use. | `get_transit_vehicles` | `transit` | Live GTFS-Realtime vehicle positions in an area, optionally one route | | `get_traffic_flow` | `traffic` | TomTom flow in a city-sized area: speed vs free flow, congested and closed road | | `get_weather` | `weather` | Current conditions at a place or point | +| `get_weather_map` | `weatherMaps` | The latest NOAA radar, satellite or lightning map image over an area | +| `get_wind` | `wind` | GFS or IFS model wind 10 m above ground at a location | | `get_regional_brief` | `regional` | What and where a location is, its weather and recent headlines | | `get_cyclones` | `cyclones` | Active NHC/CPHC tropical cyclones, optionally in an area | | `get_fire_perimeters` | `perimeters` | Mapped WFIGS wildfire perimeters in an area, largest first | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7f46193be..3b32173a3 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -879,5 +879,6 @@ "src/tools/queries/environment.test.mjs", "src/tools/queries/maritime.test.mjs", "src/tools/queries/share.test.mjs", - "src/tools/localRoute.test.mjs" + "src/tools/localRoute.test.mjs", + "src/tools/queries/atmosphere.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ecde8cac4..ffa6795ba 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3106,7 +3106,11 @@ "src/data/layerStateTokenReservations.json", "src/sources/gbfsStations.js", "src/sources/tle.js", - "src/data/satellitePass.js" + "src/data/satellitePass.js", + "src/tools/queries/atmosphere.js", + "src/layers/wind/inspection.js", + "src/layers/wind/model.js", + "src/layers/wind/fields.js" ], "external": ["satellite.js"] }, @@ -3171,7 +3175,10 @@ "src/sources/live/vessels.js", "src/tools/places.js", "src/tools/catalog.js", - "src/tools/schema.js" + "src/tools/schema.js", + "src/layers/weather/source.js", + "src/layers/wind/source.js", + "src/sources/windBody.js" ], "external": ["@mapbox/vector-tile", "pbf"] } diff --git a/src/tools/index.js b/src/tools/index.js index 0efcd84f5..5c0d328ed 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -9,6 +9,7 @@ import { getAircraftInfo, getAircraftTrack, } from './queries/aviation.js'; +import { getWeatherMap, getWind } from './queries/atmosphere.js'; import { getHudCaption, situationBrief } from './queries/brief.js'; import { findMilitaryInstallations, @@ -88,6 +89,8 @@ export const coreTools = Object.freeze([ getTransitVehicles, getTrafficFlow, getWeather, + getWeatherMap, + getWind, getRegionalBrief, getCyclones, getFirePerimeters, diff --git a/src/tools/queries/atmosphere.js b/src/tools/queries/atmosphere.js new file mode 100644 index 000000000..838e4849f --- /dev/null +++ b/src/tools/queries/atmosphere.js @@ -0,0 +1,195 @@ +/** Weather map imagery (radar, satellite, lightning) and wind at a point. */ + +import { windFrom } from '../../layers/wind/inspection.js'; +import { sampleWind, windSpeed } from '../../layers/wind/model.js'; +import { defineTool, ToolError } from '../catalog.js'; +import { + AREA_SCHEMA, + POINT_SCHEMA, + areaCenter, + areaRadiusKm, + resolveArea, + resolvePoint, +} from '../area.js'; +import { toBase64 } from '../results.js'; + +/** Map names offered to callers, and the weather products behind them. */ +const MAPS = { + radar: 'radar', + satellite: 'clouds-regional', + global_satellite: 'clouds', + lightning: 'lightning', +}; +const IMAGE_SIZE = { width: 1024, height: 512 }; +const SNAP_DEGREES = 0.25; +const MIN_HALF_HEIGHT_DEGREES = 0.5; +const KM_PER_DEGREE = 111.2; +const MAX_IMAGE_BYTES = 4 * 1024 * 1024; + +const snapDown = (value) => Math.floor(value / SNAP_DEGREES) * SNAP_DEGREES; +const snapUp = (value) => Math.ceil(value / SNAP_DEGREES) * SNAP_DEGREES; +const round = (value, digits) => + Number.isFinite(value) ? Number(value.toFixed(digits)) : null; + +/** + * A 2:1 box snapped to 0.25° around the area, moved inside the product's + * bounds. Returns null when the box would not fit, meaning the whole + * extent should be shown instead. + */ +export function weatherWindow(area, bounds) { + const center = areaCenter(area); + const halfHeight = Math.max( + MIN_HALF_HEIGHT_DEGREES, + areaRadiusKm(area) / KM_PER_DEGREE, + ); + const height = snapUp(halfHeight * 2); + const width = height * 2; + if (width > bounds.east - bounds.west || height > bounds.north - bounds.south) + return null; + let west = snapDown(center.lon - width / 2); + let south = snapDown(center.lat - height / 2); + west = Math.min( + Math.max(west, snapUp(bounds.west)), + snapDown(bounds.east - width), + ); + south = Math.min( + Math.max(south, snapUp(bounds.south)), + snapDown(bounds.north - height), + ); + const mapBox = { west, south, east: west + width, north: south + height }; + return mapBox.west >= bounds.west && + mapBox.east <= bounds.east && + mapBox.south >= bounds.south && + mapBox.north <= bounds.north + ? mapBox + : null; +} + +export const getWeatherMap = defineTool({ + name: 'get_weather_map', + title: 'Weather map', + description: + 'The latest NOAA weather map image over an area: precipitation radar ' + + '(contiguous US), regional or global infrared satellite clouds, or ' + + 'lightning density (the Americas). Returns the image itself.', + inputSchema: { + type: 'object', + properties: { + map: { type: 'string', enum: Object.keys(MAPS) }, + area: AREA_SCHEMA, + }, + required: ['map'], + additionalProperties: false, + }, + requires: ['weatherMaps'], + async run(args, { services, signal }) { + const product = MAPS[args.map]; + const snapshot = await services.weatherMaps.getSnapshot({ + product, + signal, + }); + if (snapshot.unavailable) + throw new ToolError( + 'unavailable', + `The ${args.map} map is unavailable right now`, + ); + const area = args.area + ? await resolveArea(args.area, { services, signal }) + : null; + let mapBox = null; + if (area) { + const { bounds } = snapshot; + const center = areaCenter(area); + if ( + center.lon < bounds.west || + center.lon > bounds.east || + center.lat < bounds.south || + center.lat > bounds.north + ) + throw new ToolError( + 'invalid_arguments', + `${area.label} is outside the ${args.map} map's coverage: ${snapshot.coverage || 'see its bounds'}`, + ); + mapBox = weatherWindow(area, bounds); + } + const image = await services.weatherMaps.getImage({ + product, + time: snapshot.latest, + size: IMAGE_SIZE, + bbox: mapBox, + signal, + }); + if (!/^image\/(png|jpeg)$/.test(image.contentType)) + throw new ToolError('unavailable', 'The weather map returned no image'); + if (image.bytes.byteLength > MAX_IMAGE_BYTES) + throw new ToolError('unavailable', 'The weather map image is too large'); + const where = area ? ` over ${area.label}` : ''; + return { + summary: `${snapshot.title || args.map} at ${snapshot.latest}${where}.`, + data: { + map: args.map, + title: snapshot.title ?? null, + description: snapshot.description ?? null, + coverage: snapshot.coverage ?? null, + time: snapshot.latest, + box: mapBox ?? snapshot.bounds, + source: snapshot.source ?? null, + attribution: snapshot.attribution ?? null, + }, + images: [{ mimeType: image.contentType, data: toBase64(image.bytes) }], + }; + }, +}); + +export const getWind = defineTool({ + name: 'get_wind', + title: 'Wind', + description: + 'Forecast wind 10 m above ground at a location from the GFS or IFS ' + + 'global model: speed and the direction it blows from.', + inputSchema: { + type: 'object', + properties: { + location: POINT_SCHEMA, + model: { type: 'string', enum: ['gfs', 'ifs'] }, + }, + required: ['location'], + additionalProperties: false, + }, + requires: ['wind'], + async run(args, { services, signal }) { + const point = await resolvePoint(args.location, { services, signal }); + const model = args.model ?? 'gfs'; + const snapshot = await services.wind.getSnapshot({ model, signal }); + if (snapshot.unavailable || !snapshot.u || !snapshot.v) + throw new ToolError( + 'unavailable', + 'Wind forecasts are unavailable right now', + ); + const vector = sampleWind( + { ...snapshot.grid, u: snapshot.u, v: snapshot.v }, + point.lon, + point.lat, + ); + const speed = windSpeed(vector.u, vector.v); + const from = windFrom(vector.u, vector.v); + const kph = speed * 3.6; + return { + summary: + `Wind at ${point.label}: ${kph.toFixed(0)} km/h` + + (from === 'Calm' ? ', calm.' : ` from the ${from}.`), + data: { + location: point, + speed_mps: round(speed, 1), + speed_kph: round(kph, 1), + from: from === 'Calm' ? null : from, + calm: from === 'Calm', + u_mps: round(vector.u, 2), + v_mps: round(vector.v, 2), + level: snapshot.level ?? '10 m above ground', + model, + cycle: snapshot.cycle ?? null, + }, + }; + }, +}); diff --git a/src/tools/queries/atmosphere.test.mjs b/src/tools/queries/atmosphere.test.mjs new file mode 100644 index 000000000..8623904ab --- /dev/null +++ b/src/tools/queries/atmosphere.test.mjs @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; +import { weatherWindow } from './atmosphere.js'; + +const CONUS = { west: -130, south: 20, east: -60, north: 55 }; +const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]); +const weatherMaps = (snapshot = {}) => ({ + requests: [], + async getSnapshot({ product }) { + return { + schemaVersion: 1, + product, + title: 'MRMS radar', + coverage: 'Contiguous United States', + source: 'NOAA nowCOAST', + attribution: 'NOAA', + bounds: CONUS, + times: ['2026-10-01T19:00:00.000Z'], + latest: '2026-10-01T19:00:00.000Z', + ...snapshot, + }; + }, + async getImage(request) { + this.requests.push(request); + return { contentType: 'image/png', bytes: PNG }; + }, +}); + +test('weather windows are 2:1, snapped to 0.25° and kept inside the bounds', () => { + const austin = weatherWindow( + { + center: { lat: 30.27, lon: -97.74, radiusKm: 50 }, + west: -98.3, + east: -97.2, + south: 29.8, + north: 30.7, + }, + CONUS, + ); + assert.deepEqual(austin, { + west: -98.75, + south: 29.75, + east: -96.75, + north: 30.75, + }); + const edge = weatherWindow( + { + center: { lat: 21, lon: -129, radiusKm: 200 }, + west: -131, + east: -127, + south: 19, + north: 23, + }, + CONUS, + ); + assert.equal(edge.west, -130); + assert.equal(edge.south, 20); + assert.equal((edge.east - edge.west) / (edge.north - edge.south), 2); + assert.equal( + weatherWindow( + { bbox: true, west: -180, east: 180, south: -80, north: 80 }, + CONUS, + ), + null, + ); +}); + +test('weather maps return the latest frame as an image over the area', async () => { + const maps = weatherMaps(); + const catalog = composeCatalog({ + tools: coreTools, + services: { weatherMaps: maps }, + }); + const result = await catalog.call('get_weather_map', { + map: 'radar', + area: { lat: 30.27, lon: -97.74, radius_km: 50 }, + }); + assert.equal( + result.summary, + 'MRMS radar at 2026-10-01T19:00:00.000Z over 50 km around 30.270, -97.740.', + ); + assert.deepEqual(result.images, [ + { mimeType: 'image/png', data: 'iVBORw==' }, + ]); + assert.deepEqual(maps.requests[0], { + product: 'radar', + time: '2026-10-01T19:00:00.000Z', + size: { width: 1024, height: 512 }, + bbox: { west: -98.75, south: 29.75, east: -96.75, north: 30.75 }, + signal: undefined, + }); + const whole = await catalog.call('get_weather_map', { map: 'satellite' }); + assert.equal(maps.requests[1].product, 'clouds-regional'); + assert.equal(maps.requests[1].bbox, null); + assert.deepEqual(whole.data.box, CONUS); + await assert.rejects( + catalog.call('get_weather_map', { + map: 'radar', + area: { lat: 51.5, lon: -0.1, radius_km: 50 }, + }), + /outside the radar map's coverage: Contiguous United States/, + ); + const down = composeCatalog({ + tools: coreTools, + services: { weatherMaps: weatherMaps({ unavailable: true }) }, + }); + await assert.rejects( + down.call('get_weather_map', { map: 'lightning' }), + (error) => error.code === 'unavailable', + ); +}); + +test('wind is sampled from the model grid at the location', async () => { + // A 1° global grid where every cell blows 10 m/s toward the east (from the west). + const nx = 360; + const ny = 181; + const u = new Float32Array(nx * ny).fill(10); + const v = new Float32Array(nx * ny).fill(0); + const wind = { + requested: [], + async getSnapshot({ model }) { + this.requested.push(model); + return { + model, + cycle: '2026-10-01T12:00:00Z', + level: '10 m above ground', + grid: { nx, ny, lo1: 0, la1: 90, dx: 1, dy: 1 }, + u, + v, + }; + }, + }; + const catalog = composeCatalog({ tools: coreTools, services: { wind } }); + const result = await catalog.call('get_wind', { + location: { lat: 30.27, lon: -97.74 }, + model: 'ifs', + }); + assert.equal( + result.summary, + 'Wind at 30.2700, -97.7400: 36 km/h from the W.', + ); + assert.equal(result.data.speed_mps, 10); + assert.equal(result.data.from, 'W'); + assert.deepEqual(wind.requested, ['ifs']); + u.fill(0); + const calm = await catalog.call('get_wind', { location: { lat: 0, lon: 0 } }); + assert.equal(calm.summary, 'Wind at 0.0000, 0.0000: 0 km/h, calm.'); + assert.equal(calm.data.calm, true); + const down = composeCatalog({ + tools: coreTools, + services: { wind: { getSnapshot: async () => ({ unavailable: true }) } }, + }); + await assert.rejects( + down.call('get_wind', { location: { lat: 0, lon: 0 } }), + (error) => error.code === 'unavailable', + ); +}); diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js index 2ad0f78e3..a0a551196 100644 --- a/src/tools/queries/media.js +++ b/src/tools/queries/media.js @@ -8,7 +8,7 @@ import { distanceKm, resolveArea, } from '../area.js'; -import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js'; +import { LIMIT_SCHEMA, capRows, countNoun, toBase64 } from '../results.js'; const FRAME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); const MAX_FRAME_BYTES = 3 * 1024 * 1024; @@ -107,7 +107,7 @@ export const getCctvSnapshot = defineTool({ mime_type: frame.contentType, bytes: frame.bytes.byteLength, }, - images: [{ mimeType: frame.contentType, data: base64(frame.bytes) }], + images: [{ mimeType: frame.contentType, data: toBase64(frame.bytes) }], }; }, }); @@ -192,11 +192,3 @@ export const findRadioStations = defineTool({ }; }, }); - -/** Base64-encode bytes with the platform's btoa, in chunks. */ -function base64(bytes) { - let binary = ''; - for (let offset = 0; offset < bytes.length; offset += 0x8000) - binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); - return btoa(binary); -} diff --git a/src/tools/results.js b/src/tools/results.js index 33213da0b..51ed82bbb 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -41,3 +41,11 @@ export function thinEvenly(items, max) { (_, index) => items[Math.round(index * step)], ); } + +/** Base64-encode bytes with the platform's btoa, in chunks. */ +export function toBase64(bytes) { + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += 0x8000) + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + return btoa(binary); +} diff --git a/src/tools/services.js b/src/tools/services.js index 8071425ed..7798d8d88 100644 --- a/src/tools/services.js +++ b/src/tools/services.js @@ -19,6 +19,8 @@ import { createRadioSource } from '../layers/radio/source.js'; import { createSatelliteSource } from '../layers/satellites/source.js'; import { createTrafficSource } from '../layers/traffic/source.js'; import { createTransitSource } from '../layers/transit/source.js'; +import { createWeatherSource } from '../layers/weather/source.js'; +import { createWindSource } from '../layers/wind/source.js'; import { createApplicationRequestServices } from '../services/requests.js'; import { createAdsbLolSource, @@ -59,6 +61,8 @@ export function createToolServices({ fetchImpl, appUrl }) { transit: createTransitSource({ fetchImpl }), traffic: createTrafficSource({ fetchImpl, tileFetchImpl: fetchImpl }), weather: requests.weather, + weatherMaps: createWeatherSource({ fetchImpl }), + wind: createWindSource({ fetchImpl }), regional: requests.regional, terrain: requests.terrain, summary: requests.summary, From 9be90fff448fb9990e9270f9f0bc37c195b14d76 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:25:00 -0700 Subject: [PATCH 021/111] feat: add a recent satellite imagery tool Add get_recent_imagery. It searches the recent Harmonized Landsat and Sentinel-2 catalog for an area, picks the image the layer would show with the layer's own ranking, and returns it with its date, sensor and cloud cover, falling back to the VIIRS daily overview. --- docs/CURRENT-STATE.md | 3 +- docs/TOOLS.md | 1 + scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 7 +- src/tools/index.js | 2 + src/tools/queries/imagery.js | 122 +++++++++++++++++++++++++++++ src/tools/queries/imagery.test.mjs | 91 +++++++++++++++++++++ src/tools/services.js | 26 ++++++ 8 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 src/tools/queries/imagery.js create mode 100644 src/tools/queries/imagery.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 483b5919d..a7a0cb788 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -14,7 +14,8 @@ by identifier, and tracks), satellites (next pass over a point, and those overhead now), public cameras (including a camera's current image), radio stations, place search, routing, bike-share stations, transit vehicles, road traffic flow, weather, weather map images -(radar, satellite, lightning), wind, regional briefs, tropical cyclones, fire +(radar, satellite, lightning), wind, the most recent satellite image of an +area, regional briefs, tropical cyclones, fire perimeters, terrain height, military installations and map features, plus a combined situation brief, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 0e195db2a..e29195ca2 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -129,6 +129,7 @@ public feeds the sources already use. | `get_weather` | `weather` | Current conditions at a place or point | | `get_weather_map` | `weatherMaps` | The latest NOAA radar, satellite or lightning map image over an area | | `get_wind` | `wind` | GFS or IFS model wind 10 m above ground at a location | +| `get_recent_imagery` | `imagery` | The most recent clear Landsat/Sentinel-2 image of an area (VIIRS fallback) | | `get_regional_brief` | `regional` | What and where a location is, its weather and recent headlines | | `get_cyclones` | `cyclones` | Active NHC/CPHC tropical cyclones, optionally in an area | | `get_fire_perimeters` | `perimeters` | Mapped WFIGS wildfire perimeters in an area, largest first | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 3b32173a3..91e86a958 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -880,5 +880,6 @@ "src/tools/queries/maritime.test.mjs", "src/tools/queries/share.test.mjs", "src/tools/localRoute.test.mjs", - "src/tools/queries/atmosphere.test.mjs" + "src/tools/queries/atmosphere.test.mjs", + "src/tools/queries/imagery.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ffa6795ba..f37849011 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3110,7 +3110,8 @@ "src/tools/queries/atmosphere.js", "src/layers/wind/inspection.js", "src/layers/wind/model.js", - "src/layers/wind/fields.js" + "src/layers/wind/fields.js", + "src/tools/queries/imagery.js" ], "external": ["satellite.js"] }, @@ -3178,7 +3179,9 @@ "src/tools/schema.js", "src/layers/weather/source.js", "src/layers/wind/source.js", - "src/sources/windBody.js" + "src/sources/windBody.js", + "src/layers/recentImagery/catalog.js", + "src/layers/recentImagery/model.js" ], "external": ["@mapbox/vector-tile", "pbf"] } diff --git a/src/tools/index.js b/src/tools/index.js index 5c0d328ed..cf4e5d2a2 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -21,6 +21,7 @@ import { getWeather, } from './queries/environment.js'; import { getActiveFires, getEarthquakes } from './queries/hazards.js'; +import { getRecentImagery } from './queries/imagery.js'; import { findVessel, getVesselTrack, @@ -91,6 +92,7 @@ export const coreTools = Object.freeze([ getWeather, getWeatherMap, getWind, + getRecentImagery, getRegionalBrief, getCyclones, getFirePerimeters, diff --git a/src/tools/queries/imagery.js b/src/tools/queries/imagery.js new file mode 100644 index 000000000..5f412c5e2 --- /dev/null +++ b/src/tools/queries/imagery.js @@ -0,0 +1,122 @@ +/** The most recent satellite image of an area. */ + +import { defineTool, ToolError } from '../catalog.js'; +import { AREA_SCHEMA, areaRadiusKm, resolveArea } from '../area.js'; +import { toBase64 } from '../results.js'; + +const MAX_SIDE_KM = 1000; +const MAX_LATITUDE = 85; +const IMAGE_WIDTH = 1024; +const MAX_IMAGE_BYTES = 6 * 1024 * 1024; +const SENSORS = { + S30: 'Sentinel-2 (30 m)', + L30: 'Landsat 8/9 (30 m)', + VIIRS: 'VIIRS daily overview (250 m)', +}; + +export const getRecentImagery = defineTool({ + name: 'get_recent_imagery', + title: 'Recent satellite image', + description: + 'The most recent clear satellite image of an area from NASA Harmonized ' + + 'Landsat and Sentinel-2 (30 m), falling back to the VIIRS daily ' + + `overview. Areas up to ${MAX_SIDE_KM} km across. Returns the image itself.`, + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['imagery'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + if (area.west > area.east) + throw new ToolError( + 'invalid_arguments', + 'The area must not cross the antimeridian', + ); + if ( + Math.abs(area.south) > MAX_LATITUDE || + Math.abs(area.north) > MAX_LATITUDE + ) + throw new ToolError( + 'invalid_arguments', + `Imagery stops at ±${MAX_LATITUDE}° latitude`, + ); + if (areaRadiusKm(area) * 2 > MAX_SIDE_KM * Math.SQRT2) + throw new ToolError( + 'invalid_arguments', + `The area is too large for imagery; use one up to ${MAX_SIDE_KM} km across`, + ); + const box = { + west: area.west, + south: area.south, + east: area.east, + north: area.north, + }; + let latest; + try { + latest = await services.imagery.latest({ box, signal }); + } catch (error) { + if (error instanceof TypeError) + throw new ToolError('invalid_arguments', error.message); + throw error; + } + const { candidate, reason } = latest; + if (!candidate) + return { + summary: `No recent satellite image covers ${area.label}.`, + data: { area: area.label, image: null }, + }; + const height = Math.max( + 64, + Math.min( + 1024, + Math.round( + (IMAGE_WIDTH * (box.north - box.south)) / (box.east - box.west), + ), + ), + ); + const image = await services.imagery.getSnapshot({ + product: candidate.product, + day: candidate.day, + box, + width: IMAGE_WIDTH, + height, + signal, + }); + if (!/^image\/(png|jpeg)$/.test(image.contentType)) + throw new ToolError( + 'unavailable', + 'The imagery service returned no image', + ); + if (image.bytes.byteLength > MAX_IMAGE_BYTES) + throw new ToolError('unavailable', 'The image is too large to return'); + const sensor = SENSORS[candidate.product] || candidate.product; + const cloud = candidate.cloud + ? `${Math.round(candidate.cloud.min)}–${Math.round(candidate.cloud.max)}% cloud` + : 'cloud cover unknown'; + const quality = { + clear: 'clear', + cloudy: 'cloudy', + partial: 'partly covering the area', + overview: 'coarse overview', + }[reason]; + return { + summary: `Most recent image of ${area.label}: ${sensor}, ${candidate.day}, ${quality}${reason === 'overview' ? '' : `, ${cloud}`}.`, + data: { + area: area.label, + image: { + product: candidate.product, + sensor, + day: candidate.day, + quality: reason, + cloud_pct: candidate.cloud ?? null, + coverage: candidate.coverage ?? null, + }, + search_errors: latest.errors?.map((error) => error.product) ?? [], + }, + images: [{ mimeType: image.contentType, data: toBase64(image.bytes) }], + }; + }, +}); diff --git a/src/tools/queries/imagery.test.mjs b/src/tools/queries/imagery.test.mjs new file mode 100644 index 000000000..75ef94312 --- /dev/null +++ b/src/tools/queries/imagery.test.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; + +const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]); +const imagery = (latest) => ({ + snapshots: [], + boxes: [], + async latest({ box }) { + this.boxes.push(box); + return latest; + }, + async getSnapshot(request) { + this.snapshots.push(request); + return { contentType: 'image/png', bytes: PNG }; + }, +}); +const austin = { bbox: [-97.9, 30.1, -97.5, 30.5] }; + +test('the most recent clear image is returned with its date and sensor', async () => { + const service = imagery({ + candidate: { + product: 'S30', + day: '2026-09-28', + cloud: { min: 2, max: 6 }, + coverage: 'full', + }, + reason: 'clear', + certain: true, + errors: [], + }); + const catalog = composeCatalog({ + tools: coreTools, + services: { imagery: service }, + }); + const result = await catalog.call('get_recent_imagery', { area: austin }); + assert.equal( + result.summary, + 'Most recent image of the requested box: Sentinel-2 (30 m), 2026-09-28, clear, 2–6% cloud.', + ); + assert.deepEqual(result.images, [ + { mimeType: 'image/png', data: 'iVBORw==' }, + ]); + assert.deepEqual(service.snapshots[0], { + product: 'S30', + day: '2026-09-28', + box: { west: -97.9, south: 30.1, east: -97.5, north: 30.5 }, + width: 1024, + height: 1024, + signal: undefined, + }); +}); + +test('overview fallback, empty results and invalid areas are reported', async () => { + const overview = composeCatalog({ + tools: coreTools, + services: { + imagery: imagery({ + candidate: { product: 'VIIRS', day: '2026-10-01', cloud: null }, + reason: 'overview', + errors: [{ product: 'S30' }], + }), + }, + }); + const coarse = await overview.call('get_recent_imagery', { area: austin }); + assert.equal( + coarse.summary, + 'Most recent image of the requested box: VIIRS daily overview (250 m), 2026-10-01, coarse overview.', + ); + assert.deepEqual(coarse.data.search_errors, ['S30']); + const none = composeCatalog({ + tools: coreTools, + services: { imagery: imagery({ candidate: null, reason: null }) }, + }); + assert.equal( + (await none.call('get_recent_imagery', { area: austin })).summary, + 'No recent satellite image covers the requested box.', + ); + await assert.rejects( + none.call('get_recent_imagery', { area: { bbox: [-120, 20, -60, 50] } }), + /too large for imagery/, + ); + await assert.rejects( + none.call('get_recent_imagery', { area: { bbox: [170, 0, -170, 5] } }), + /antimeridian/, + ); + await assert.rejects( + none.call('get_recent_imagery', { area: { bbox: [0, 85.5, 1, 86] } }), + /±85° latitude/, + ); +}); diff --git a/src/tools/services.js b/src/tools/services.js index 7798d8d88..18f90c4b3 100644 --- a/src/tools/services.js +++ b/src/tools/services.js @@ -16,6 +16,8 @@ import { createInstallationSource } from '../layers/installations/source.js'; import { createLaunchSource } from '../layers/launches/source.js'; import { createWfigsPerimeterSource } from '../layers/perimeters/source.js'; import { createRadioSource } from '../layers/radio/source.js'; +import { searchHls } from '../layers/recentImagery/catalog.js'; +import { rankLatest, wvsSnapshotUrl } from '../layers/recentImagery/model.js'; import { createSatelliteSource } from '../layers/satellites/source.js'; import { createTrafficSource } from '../layers/traffic/source.js'; import { createTransitSource } from '../layers/transit/source.js'; @@ -63,6 +65,7 @@ export function createToolServices({ fetchImpl, appUrl }) { weather: requests.weather, weatherMaps: createWeatherSource({ fetchImpl }), wind: createWindSource({ fetchImpl }), + imagery: createImageryService({ fetchImpl }), regional: requests.regional, terrain: requests.terrain, summary: requests.summary, @@ -76,3 +79,26 @@ export function createToolServices({ fetchImpl, appUrl }) { places: createGeocodePlaceService({ fetchImpl }), }; } + +/** Recent keyless satellite imagery: the clearest recent day and its image. */ +function createImageryService({ fetchImpl }) { + return { + async latest({ box, signal }) { + const result = await searchHls({ box, fetchImpl, signal }); + return { + ...rankLatest(result.candidates, { truncated: result.truncated }), + errors: result.errors, + }; + }, + async getSnapshot({ product, day, box, width, height, signal }) { + const response = await fetchImpl( + wvsSnapshotUrl({ product, day, box, width, height }), + { signal }, + ); + if (!response.ok) throw new Error(`Imagery HTTP ${response.status}`); + const bytes = new Uint8Array(await response.arrayBuffer()); + const type = response.headers.get('content-type') || ''; + return { contentType: type.split(';')[0].trim().toLowerCase(), bytes }; + }, + }; +} From c1957d884e628414a726bab7f0c4a0ac261bcf5a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:28:02 -0700 Subject: [PATCH 022/111] feat: add a submarine cable tool Add find_submarine_cables over the bundled TeleGeography data: cables passing through or landing in an area, or matching a name, with landing points and the dataset's attribution. The local MCP server's fetch reads the bundled data files a browser would load by URL, and nothing outside that directory. --- docs/CURRENT-STATE.md | 2 +- docs/TOOLS.md | 1 + scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 6 +- server/mcp/services.js | 36 +++++++++++- src/tools/index.js | 2 + src/tools/queries/cables.js | 86 ++++++++++++++++++++++++++++ src/tools/queries/cables.test.mjs | 95 +++++++++++++++++++++++++++++++ src/tools/services.js | 2 + 9 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 src/tools/queries/cables.js create mode 100644 src/tools/queries/cables.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a7a0cb788..29d956cfb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -15,7 +15,7 @@ pass over a point, and those overhead now), public cameras (including a camera's current image), radio stations, place search, routing, bike-share stations, transit vehicles, road traffic flow, weather, weather map images (radar, satellite, lightning), wind, the most recent satellite image of an -area, regional briefs, tropical cyclones, fire +area, submarine cables, regional briefs, tropical cyclones, fire perimeters, terrain height, military installations and map features, plus a combined situation brief, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a diff --git a/docs/TOOLS.md b/docs/TOOLS.md index e29195ca2..383f6417c 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -130,6 +130,7 @@ public feeds the sources already use. | `get_weather_map` | `weatherMaps` | The latest NOAA radar, satellite or lightning map image over an area | | `get_wind` | `wind` | GFS or IFS model wind 10 m above ground at a location | | `get_recent_imagery` | `imagery` | The most recent clear Landsat/Sentinel-2 image of an area (VIIRS fallback) | +| `find_submarine_cables` | `cables` | TeleGeography cables and landing points by area or name (CC BY-NC-SA 3.0) | | `get_regional_brief` | `regional` | What and where a location is, its weather and recent headlines | | `get_cyclones` | `cyclones` | Active NHC/CPHC tropical cyclones, optionally in an area | | `get_fire_perimeters` | `perimeters` | Mapped WFIGS wildfire perimeters in an area, largest first | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 91e86a958..206e5f739 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -881,5 +881,6 @@ "src/tools/queries/share.test.mjs", "src/tools/localRoute.test.mjs", "src/tools/queries/atmosphere.test.mjs", - "src/tools/queries/imagery.test.mjs" + "src/tools/queries/imagery.test.mjs", + "src/tools/queries/cables.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f37849011..f97195d31 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3111,7 +3111,8 @@ "src/layers/wind/inspection.js", "src/layers/wind/model.js", "src/layers/wind/fields.js", - "src/tools/queries/imagery.js" + "src/tools/queries/imagery.js", + "src/tools/queries/cables.js" ], "external": ["satellite.js"] }, @@ -3181,7 +3182,8 @@ "src/layers/wind/source.js", "src/sources/windBody.js", "src/layers/recentImagery/catalog.js", - "src/layers/recentImagery/model.js" + "src/layers/recentImagery/model.js", + "src/layers/submarineCables/bundledSource.js" ], "external": ["@mapbox/vector-tile", "pbf"] } diff --git a/server/mcp/services.js b/server/mcp/services.js index a4ac48965..513bc146f 100644 --- a/server/mcp/services.js +++ b/server/mcp/services.js @@ -3,11 +3,20 @@ * running God's Eye View server's `/api` routes. */ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; import { createToolServices } from '../../src/tools/services.js'; +// Bundled data some sources read by module-relative URL. +const BUNDLED_DATA = new URL('../../src/data/local_data/', import.meta.url); + export const DEFAULT_API_BASE = 'http://localhost:4173'; -/** Resolve the sources' relative `/api/...` requests against `apiBase`. */ +/** + * Resolve the sources' relative `/api/...` requests against `apiBase`, and + * serve `file:` URLs inside the bundled data directory from disk, as a + * browser would load those module-relative assets. + */ export function createApiFetch({ apiBase = DEFAULT_API_BASE, fetchImpl = (...args) => globalThis.fetch(...args), @@ -15,13 +24,34 @@ export function createApiFetch({ const base = new URL(apiBase); if (!['http:', 'https:'].includes(base.protocol)) throw new TypeError(`apiBase must be an http(s) URL: ${apiBase}`); - return (input, init) => - fetchImpl( + return (input, init) => { + const url = String(input instanceof Request ? input.url : input); + if (url.startsWith('file:')) return readBundled(url); + return fetchImpl( typeof input === 'string' && input.startsWith('/') ? new URL(input, base) : input, init, ); + }; +} + +async function readBundled(url) { + const href = new URL(url).href; + if (!href.startsWith(BUNDLED_DATA.href) || href.includes('/../')) + return new Response(null, { status: 404 }); + try { + const body = await readFile(fileURLToPath(href)); + return new Response(body, { + headers: { + 'Content-Type': href.endsWith('.json') + ? 'application/json' + : 'application/octet-stream', + }, + }); + } catch { + return new Response(null, { status: 404 }); + } } /** Construct every service Core's tools read, backed by the local server. */ diff --git a/src/tools/index.js b/src/tools/index.js index cf4e5d2a2..d7ca50b4e 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -10,6 +10,7 @@ import { getAircraftTrack, } from './queries/aviation.js'; import { getWeatherMap, getWind } from './queries/atmosphere.js'; +import { findSubmarineCables } from './queries/cables.js'; import { getHudCaption, situationBrief } from './queries/brief.js'; import { findMilitaryInstallations, @@ -93,6 +94,7 @@ export const coreTools = Object.freeze([ getWeatherMap, getWind, getRecentImagery, + findSubmarineCables, getRegionalBrief, getCyclones, getFirePerimeters, diff --git a/src/tools/queries/cables.js b/src/tools/queries/cables.js new file mode 100644 index 000000000..e7f5ec6f7 --- /dev/null +++ b/src/tools/queries/cables.js @@ -0,0 +1,86 @@ +/** Submarine cables and their landing points. */ + +import { defineTool, ToolError } from '../catalog.js'; +import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; +import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js'; + +const ATTRIBUTION = '© TeleGeography — submarinecablemap.com (CC BY-NC-SA 3.0)'; + +function cableLines(feature) { + const geometry = feature?.geometry; + if (geometry?.type === 'LineString') return [geometry.coordinates]; + if (geometry?.type === 'MultiLineString') return geometry.coordinates; + return []; +} + +export const findSubmarineCables = defineTool({ + name: 'find_submarine_cables', + title: 'Submarine cables', + description: + 'Undersea telecommunication cables and landing points from the ' + + 'TeleGeography map: those passing through or landing in an area, ' + + 'and/or those whose name matches.', + inputSchema: { + type: 'object', + properties: { + area: AREA_SCHEMA, + name: { type: 'string', minLength: 2, maxLength: 80 }, + limit: LIMIT_SCHEMA, + }, + additionalProperties: false, + }, + requires: ['cables'], + async run(args, { services, signal }) { + if (!args.area && !args.name) + throw new ToolError('invalid_arguments', 'Give an area, a name or both'); + const area = args.area + ? await resolveArea(args.area, { services, signal }) + : null; + const wanted = args.name?.toLowerCase(); + const { cables, landingPoints } = await services.cables.fetch(signal); + const landingRows = (landingPoints?.features || []) + .map((feature) => ({ + id: feature.properties?.id ?? null, + name: feature.properties?.name ?? null, + lon: feature.geometry?.coordinates?.[0], + lat: feature.geometry?.coordinates?.[1], + })) + .filter( + (point) => + point.name && + Number.isFinite(point.lat) && + (!area || areaContains(area, point)), + ); + const rows = (cables?.features || []) + .filter((feature) => { + const name = String(feature.properties?.name || ''); + if (!name || (wanted && !name.toLowerCase().includes(wanted))) + return false; + return ( + !area || + cableLines(feature).some((line) => + line.some(([lon, lat]) => areaContains(area, { lat, lon })), + ) + ); + }) + .map((feature) => ({ + id: feature.properties.id ?? null, + name: feature.properties.name, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + const where = area ? ` in ${area.label}` : ''; + const what = wanted ? ` matching "${args.name}"` : ''; + return { + summary: + `${countNoun(rows.length, 'submarine cable')}${what}${where}` + + (area + ? `, with ${countNoun(landingRows.length, 'landing point')}.` + : '.'), + data: { + ...capRows(rows, args.limit), + landing_points: area ? capRows(landingRows, args.limit) : null, + attribution: ATTRIBUTION, + }, + }; + }, +}); diff --git a/src/tools/queries/cables.test.mjs b/src/tools/queries/cables.test.mjs new file mode 100644 index 000000000..754032b6e --- /dev/null +++ b/src/tools/queries/cables.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; +import { createApiFetch } from '../../../server/mcp/services.js'; +import { createBundledCableSource } from '../../layers/submarineCables/bundledSource.js'; + +const cables = { + async fetch() { + return { + cables: { + features: [ + { + properties: { id: 'marea', name: 'MAREA' }, + geometry: { + type: 'MultiLineString', + coordinates: [ + [ + [-76, 36.8], + [-40, 40], + [-2.9, 43.4], + ], + ], + }, + }, + { + properties: { id: 'unity', name: 'Unity' }, + geometry: { + type: 'LineString', + coordinates: [ + [-118, 34], + [139, 35], + ], + }, + }, + ], + }, + landingPoints: { + features: [ + { + properties: { id: 'vb', name: 'Virginia Beach, VA, United States' }, + geometry: { type: 'Point', coordinates: [-75.98, 36.85] }, + }, + { + properties: { id: 'bilbao', name: 'Bilbao, Spain' }, + geometry: { type: 'Point', coordinates: [-2.93, 43.26] }, + }, + ], + }, + }; + }, +}; + +test('cables are found by area and name, with landing points and attribution', async () => { + const catalog = composeCatalog({ tools: coreTools, services: { cables } }); + const virginia = await catalog.call('find_submarine_cables', { + area: { lat: 36.85, lon: -75.98, radius_km: 50 }, + }); + assert.equal( + virginia.summary, + '1 submarine cable in 50 km around 36.850, -75.980, with 1 landing point.', + ); + assert.deepEqual(virginia.data.rows, [{ id: 'marea', name: 'MAREA' }]); + assert.equal( + virginia.data.landing_points.rows[0].name, + 'Virginia Beach, VA, United States', + ); + assert.match(virginia.data.attribution, /TeleGeography.*CC BY-NC-SA 3\.0/); + const named = await catalog.call('find_submarine_cables', { name: 'uni' }); + assert.equal(named.summary, '1 submarine cable matching "uni".'); + assert.equal(named.data.landing_points, null); + await assert.rejects( + catalog.call('find_submarine_cables', {}), + /area, a name or both/, + ); +}); + +test('the local fetch serves bundled data files and nothing outside them', async () => { + const fetchImpl = createApiFetch({ + apiBase: 'http://127.0.0.1:1', + fetchImpl: async () => assert.fail('no network'), + }); + const { cables: collection, landingPoints } = await createBundledCableSource({ + fetchImpl, + }).fetch(); + assert.ok(collection.features.length > 500); + assert.ok(landingPoints.features.length > 1000); + const outside = await fetchImpl( + new URL('../../../package.json', import.meta.url).href, + ); + assert.equal(outside.status, 404); + const missing = await fetchImpl( + new URL('../../data/local_data/missing.json', import.meta.url).href, + ); + assert.equal(missing.status, 404); +}); diff --git a/src/tools/services.js b/src/tools/services.js index 18f90c4b3..a09e84bb1 100644 --- a/src/tools/services.js +++ b/src/tools/services.js @@ -16,6 +16,7 @@ import { createInstallationSource } from '../layers/installations/source.js'; import { createLaunchSource } from '../layers/launches/source.js'; import { createWfigsPerimeterSource } from '../layers/perimeters/source.js'; import { createRadioSource } from '../layers/radio/source.js'; +import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js'; import { searchHls } from '../layers/recentImagery/catalog.js'; import { rankLatest, wvsSnapshotUrl } from '../layers/recentImagery/model.js'; import { createSatelliteSource } from '../layers/satellites/source.js'; @@ -66,6 +67,7 @@ export function createToolServices({ fetchImpl, appUrl }) { weatherMaps: createWeatherSource({ fetchImpl }), wind: createWindSource({ fetchImpl }), imagery: createImageryService({ fetchImpl }), + cables: createBundledCableSource({ fetchImpl }), regional: requests.regional, terrain: requests.terrain, summary: requests.summary, From 0a454815cbe2958184e3b1093917932eb1b4bad1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:31:31 -0700 Subject: [PATCH 023/111] feat: add a license plate reader camera tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add find_alpr_cameras over the layer's OpenStreetMap camera extracts: cameras in a US or Canadian area of at most 3° per side, nearest first, with operator, manufacturer and facing direction, and the source's attribution. Areas outside coverage or too wide are reported. --- docs/CURRENT-STATE.md | 2 +- docs/TOOLS.md | 1 + scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 9 ++- src/tools/index.js | 2 + src/tools/queries/surveillance.js | 91 +++++++++++++++++++++++ src/tools/queries/surveillance.test.mjs | 97 +++++++++++++++++++++++++ src/tools/services.js | 2 + 8 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 src/tools/queries/surveillance.js create mode 100644 src/tools/queries/surveillance.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 29d956cfb..3a330eb6c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -12,7 +12,7 @@ Queries cover earthquakes, active fires, recent launches, aircraft (in an area, by identifier, tracks, and type and route lookups), ships (in an area, by identifier, and tracks), satellites (next pass over a point, and those overhead now), public cameras (including a -camera's current image), radio stations, place search, routing, bike-share +camera's current image), license plate reader cameras, radio stations, place search, routing, bike-share stations, transit vehicles, road traffic flow, weather, weather map images (radar, satellite, lightning), wind, the most recent satellite image of an area, submarine cables, regional briefs, tropical cyclones, fire diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 383f6417c..403b37513 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -119,6 +119,7 @@ public feeds the sources already use. | `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a point now, highest first | | `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first | | `get_cctv_snapshot` | `cctv` | The current image from one camera, returned as image content | +| `find_alpr_cameras` | `alpr` | OpenStreetMap-mapped license plate readers in a US/Canadian area up to 3° | | `find_radio_stations` | `radio` | Radio Browser stations by area and/or search terms, with stream URLs | | `search_places` | `placeSearch` | Points of interest matching a query within an area (Google Places) | | `places_nearby` | `placeSearch` | Notable places around a point (Google Places) | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 206e5f739..2352e3df4 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -882,5 +882,6 @@ "src/tools/localRoute.test.mjs", "src/tools/queries/atmosphere.test.mjs", "src/tools/queries/imagery.test.mjs", - "src/tools/queries/cables.test.mjs" + "src/tools/queries/cables.test.mjs", + "src/tools/queries/surveillance.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f97195d31..158624b8a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3112,7 +3112,8 @@ "src/layers/wind/model.js", "src/layers/wind/fields.js", "src/tools/queries/imagery.js", - "src/tools/queries/cables.js" + "src/tools/queries/cables.js", + "src/tools/queries/surveillance.js" ], "external": ["satellite.js"] }, @@ -3183,7 +3184,11 @@ "src/sources/windBody.js", "src/layers/recentImagery/catalog.js", "src/layers/recentImagery/model.js", - "src/layers/submarineCables/bundledSource.js" + "src/layers/submarineCables/bundledSource.js", + "src/layers/alpr/source.js", + "src/layers/alpr/policy.js", + "src/layers/alpr/records.js", + "src/layers/alpr/tileRecords.js" ], "external": ["@mapbox/vector-tile", "pbf"] } diff --git a/src/tools/index.js b/src/tools/index.js index d7ca50b4e..d1fff6f35 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -35,6 +35,7 @@ import { } from './queries/mobility.js'; import { placesNearby, planRoute, searchPlaces } from './queries/places.js'; import { openInGodsEyeView } from './queries/share.js'; +import { findAlprCameras } from './queries/surveillance.js'; import { findCctvCameras, findRadioStations, @@ -83,6 +84,7 @@ export const coreTools = Object.freeze([ satellitesOverhead, findCctvCameras, getCctvSnapshot, + findAlprCameras, findRadioStations, searchPlaces, placesNearby, diff --git a/src/tools/queries/surveillance.js b/src/tools/queries/surveillance.js new file mode 100644 index 000000000..d6fbd346a --- /dev/null +++ b/src/tools/queries/surveillance.js @@ -0,0 +1,91 @@ +/** Automated license plate reader cameras mapped in OpenStreetMap. */ + +import { defineTool, ToolError } from '../catalog.js'; +import { + AREA_SCHEMA, + areaCenter, + areaContains, + distanceKm, + resolveArea, +} from '../area.js'; +import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js'; + +const MAX_SIDE_DEGREES = 3; + +const round = (value, digits) => + Number.isFinite(value) ? Number(value.toFixed(digits)) : null; + +export const findAlprCameras = defineTool({ + name: 'find_alpr_cameras', + title: 'License plate reader cameras', + description: + 'Automated license plate reader (ALPR) cameras mapped in OpenStreetMap ' + + 'in a US or Canadian area of at most 3° per side, nearest the center ' + + 'first, with operator, manufacturer and facing direction.', + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['alpr'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + if ( + area.west > area.east || + area.north - area.south > MAX_SIDE_DEGREES || + area.east - area.west > MAX_SIDE_DEGREES + ) + throw new ToolError( + 'invalid_arguments', + `The area must be at most ${MAX_SIDE_DEGREES}° on each side and not cross the antimeridian`, + ); + const box = { + west: area.west, + south: area.south, + east: area.east, + north: area.north, + }; + const result = await services.alpr.fetch(box, signal); + if (result.noCoverage) + return { + summary: `${area.label} is outside the US and Canadian camera coverage.`, + data: { ...capRows([], args.limit), coverage: 'US and Canada' }, + }; + if (result.zoomIn) + throw new ToolError( + 'invalid_arguments', + 'Use a smaller, city-sized area', + ); + const center = areaCenter(area); + const rows = result.records + .map((camera) => ({ + camera, + point: { lat: camera.latitude, lon: camera.longitude }, + })) + .filter(({ point }) => areaContains(area, point)) + .map(({ camera, point }) => ({ + id: camera.id, + lat: round(point.lat, 6), + lon: round(point.lon, 6), + operator: camera.operator || null, + manufacturer: camera.manufacturer || null, + camera_type: camera.cameraType || null, + zone: camera.zone || null, + direction_deg: camera.directionDeg ?? null, + last_verified: camera.lastVerified || null, + distance_km: round(distanceKm(center, point), 2), + })) + .sort((a, b) => a.distance_km - b.distance_km); + return { + summary: + `${countNoun(rows.length, 'license plate reader camera')} mapped in ${area.label}` + + (result.stale ? ' (data may be stale).' : '.'), + data: { + ...capRows(rows, args.limit), + source: services.alpr.label ?? null, + attribution: services.alpr.attribution ?? null, + }, + }; + }, +}); diff --git a/src/tools/queries/surveillance.test.mjs b/src/tools/queries/surveillance.test.mjs new file mode 100644 index 000000000..8b4a151bc --- /dev/null +++ b/src/tools/queries/surveillance.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; + +const camera = (id, latitude, longitude, extra = {}) => ({ + id, + osmId: id, + latitude, + longitude, + operator: 'City PD', + manufacturer: 'Flock Safety', + cameraType: 'fixed', + zone: 'traffic', + directionDeg: 270, + ref: null, + lastVerified: '2026-05-01', + source: null, + ...extra, +}); +const alpr = (result) => ({ + label: 'OpenStreetMap · community mapped', + attribution: { text: 'OpenStreetMap contributors' }, + boxes: [], + async fetch(box) { + this.boxes.push(box); + return result; + }, +}); + +test('mapped cameras are listed nearest first with their details', async () => { + const source = alpr({ + records: [ + camera('alpr:1', 30.3, -97.7), + camera('alpr:2', 30.27, -97.74, { directionDeg: null }), + camera('alpr:far', 31.5, -97.7), + ], + stale: false, + }); + const catalog = composeCatalog({ + tools: coreTools, + services: { alpr: source }, + }); + const result = await catalog.call('find_alpr_cameras', { + area: { lat: 30.27, lon: -97.74, radius_km: 10 }, + }); + assert.equal( + result.summary, + '2 license plate reader cameras mapped in 10 km around 30.270, -97.740.', + ); + assert.deepEqual( + result.data.rows.map((row) => row.id), + ['alpr:2', 'alpr:1'], + ); + assert.deepEqual(result.data.rows[1], { + id: 'alpr:1', + lat: 30.3, + lon: -97.7, + operator: 'City PD', + manufacturer: 'Flock Safety', + camera_type: 'fixed', + zone: 'traffic', + direction_deg: 270, + last_verified: '2026-05-01', + distance_km: 5.09, + }); + assert.equal(result.data.source, 'OpenStreetMap · community mapped'); + assert.ok(source.boxes[0].north - source.boxes[0].south < 1); +}); + +test('coverage, size and zoom limits are reported', async () => { + const outside = composeCatalog({ + tools: coreTools, + services: { alpr: alpr({ records: [], noCoverage: true }) }, + }); + assert.equal( + ( + await outside.call('find_alpr_cameras', { + area: { lat: 51.5, lon: -0.1, radius_km: 5 }, + }) + ).summary, + '5 km around 51.500, -0.100 is outside the US and Canadian camera coverage.', + ); + await assert.rejects( + outside.call('find_alpr_cameras', { area: { bbox: [-100, 30, -95, 35] } }), + /at most 3° on each side/, + ); + const zoom = composeCatalog({ + tools: coreTools, + services: { alpr: alpr({ records: [], zoomIn: true }) }, + }); + await assert.rejects( + zoom.call('find_alpr_cameras', { + area: { lat: 30, lon: -97, radius_km: 100 }, + }), + /smaller, city-sized area/, + ); +}); diff --git a/src/tools/services.js b/src/tools/services.js index a09e84bb1..a77494fc7 100644 --- a/src/tools/services.js +++ b/src/tools/services.js @@ -6,6 +6,7 @@ * address the app is served from. */ +import { createAlprTileSource } from '../layers/alpr/source.js'; import { GBFS_CITY_REGISTRY } from '../layers/bikeshare/registry.js'; import { createBikeshareSource } from '../layers/bikeshare/source.js'; import { createCctvSource } from '../layers/cctv/source.js'; @@ -68,6 +69,7 @@ export function createToolServices({ fetchImpl, appUrl }) { wind: createWindSource({ fetchImpl }), imagery: createImageryService({ fetchImpl }), cables: createBundledCableSource({ fetchImpl }), + alpr: createAlprTileSource({ tileFetchImpl: fetchImpl }), regional: requests.regional, terrain: requests.terrain, summary: requests.summary, From a8e5ff939085dc3cd89ded918418f040e60a4163 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:34:26 -0700 Subject: [PATCH 024/111] refactor: move infrastructure data parsing into a portable source module The datacenter and dam layers parsed their bundled GeoJSON Lines and built analyst records inside the Cesium-backed layer module. Move the data URLs, the line parser, mapAnalystRecord and layerTitle unchanged to src/sources/infrastructureData.js; the layer module imports them and still exports mapAnalystRecord. The consumer-build pin lists the new module. --- scripts/package-boundaries.json | 9 ++- src/data/infrastructure.js | 10 +-- src/data/infrastructure.test.mjs | 1 + src/data/localGeojsonCore.js | 89 ++------------------------ src/sources/infrastructureData.js | 103 ++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 92 deletions(-) create mode 100644 src/sources/infrastructureData.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 158624b8a..1b7e90f56 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,7 +10,8 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", "src/data/inputOwnership.js", - "src/data/infrastructureOverlayEntry.js" + "src/data/infrastructureOverlayEntry.js", + "src/sources/infrastructureData.js" ], "external": ["cesium"] }, @@ -2034,7 +2035,8 @@ "src/sources/requestDeadline.js", "src/data/local_data/osm_military_names/names.json", "src/layers/installations/namedMarkers.js", - "src/data/infrastructureOverlayEntry.js" + "src/data/infrastructureOverlayEntry.js", + "src/sources/infrastructureData.js" ], "external": [ "hls.js", @@ -2520,7 +2522,8 @@ "src/sources/requestDeadline.js", "src/data/local_data/osm_military_names/names.json", "src/layers/installations/namedMarkers.js", - "src/data/infrastructureOverlayEntry.js" + "src/data/infrastructureOverlayEntry.js", + "src/sources/infrastructureData.js" ], "external": [ "hls.js", diff --git a/src/data/infrastructure.js b/src/data/infrastructure.js index 59cc09893..ea78494c8 100644 --- a/src/data/infrastructure.js +++ b/src/data/infrastructure.js @@ -1,12 +1,8 @@ import { createLocalGeoJsonLayer } from './localGeojsonCore.js'; +import { INFRASTRUCTURE_DATA_URLS } from '../sources/infrastructureData.js'; -// Resolved by Vite in builds and relative to this module in other consumers. -const datacentersUrl = new URL( - './local_data/datacenters/datacenters.geojsonl', - import.meta.url, -).href; -const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url) - .href; +const datacentersUrl = INFRASTRUCTURE_DATA_URLS['local-datacenters']; +const damsUrl = INFRASTRUCTURE_DATA_URLS['local-dams']; /** * Create fresh datacenter and dam layers without starting or loading them. diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index 2baf5e580..45fb3d317 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -192,6 +192,7 @@ test('consumer build includes only infrastructure code and resolves assets under const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js')); assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [ 'infrastructure.js', + 'infrastructureData.js', 'infrastructureOverlayEntry.js', 'localGeojsonCore.js', 'localGeojsonLod.js', diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index 834361c8c..1d04fa46c 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -1,4 +1,9 @@ import * as Cesium from 'cesium'; +import { + layerTitle, + mapAnalystRecord, + parseGeojsonLines, +} from '../sources/infrastructureData.js'; import { createInfrastructureOverlayEntry } from './infrastructureOverlayEntry.js'; import { isPointerFree } from './inputOwnership.js'; import { @@ -86,78 +91,6 @@ export function localInfrastructureOverlayCopy(properties, layerId) { return { title, details }; } -/** - * Map one local infrastructure feature to a JSON-safe analyst record - * (analyst query engine seam). Pure — no Cesium types. Missing/unknown - * fields are null, never NaN/undefined. Layer-specific fields that do not - * apply (river/output on datacenters, capacity on dams) stay null so a - * shared compact payload can copy them without inventing values. - * Names are unclamped — overlay cards shorten for paint; queries need the - * full source string ("Usina Hidrelétrica de Itaipu"). - * @param {Object|null|undefined} raw - {id, lat, lon, properties}. - * @param {string} [layerId] Local layer id (`local-datacenters` / `local-dams`). - * @returns {{id: string, name: string|null, lat: number|null, lon: number|null, - * operator: string|null, capacity: string|null, river: string|null, - * output: string|null}} - */ -export function mapAnalystRecord(raw, layerId = '') { - const num = (v) => (Number.isFinite(v) ? v : null); - const text = (v) => { - const t = String(v ?? '').trim(); - return t && t !== 'undefined' && t !== 'null' ? t : null; - }; - const props = - raw?.properties && - typeof raw.properties === 'object' && - !Array.isArray(raw.properties) - ? raw.properties - : {}; - const tags = - props.tags && typeof props.tags === 'object' && !Array.isArray(props.tags) - ? props.tags - : {}; - const name = - text(props.name) || - text(tags.name) || - text(tags['name:en']) || - text(tags.official_name) || - null; - const operator = - text(tags.operator) || - text(props.operator) || - text(tags['operator:short']) || - null; - const capacity = - layerId === 'local-datacenters' - ? text(tags['capacity:it_load']) || - text(tags.it_load) || - text(tags.capacity) || - text(props.capacity) - : null; - const river = - layerId === 'local-dams' - ? text(tags.associated_river) || - text(props.associated_river) || - text(tags.river) || - text(props.river) || - text(tags['river:name']) - : null; - const output = - layerId === 'local-dams' - ? text(props.output) || text(tags['plant:output:electricity']) - : null; - return { - id: name || text(raw?.id) || layerTitle(layerId), - name, - lat: num(raw?.lat), - lon: num(raw?.lon), - operator, - capacity, - river, - output, - }; -} - /** * Produce one normalized-contract input owned by a local infrastructure layer. * The host revalidates the authoritative `source` value while normalizing it. @@ -664,11 +597,7 @@ export function createLocalGeoJsonLayer( } const text = await response.text(); if (_destroyed) return; - const lines = text - .split('\n') - .filter((l) => l.trim().length > 0); - - features = lines.map((line) => JSON.parse(line)); + features = parseGeojsonLines(text); _cachedFeatures = features; } @@ -1360,8 +1289,4 @@ function clampCardLine(value) { return text.length > 48 ? `${text.slice(0, 45)}...` : text; } -function layerTitle(layerId) { - if (layerId === 'local-datacenters') return 'Datacenter'; - if (layerId === 'local-dams') return 'Dam'; - return 'Feature'; -} +export { mapAnalystRecord }; diff --git a/src/sources/infrastructureData.js b/src/sources/infrastructureData.js new file mode 100644 index 000000000..9c19fdc87 --- /dev/null +++ b/src/sources/infrastructureData.js @@ -0,0 +1,103 @@ +/** + * Bundled OpenStreetMap infrastructure layers (datacenters and dams): their + * data files, line-delimited GeoJSON parsing and analyst records. + */ + +// Resolved by Vite in builds and relative to this module in other consumers. +export const INFRASTRUCTURE_DATA_URLS = Object.freeze({ + 'local-datacenters': new URL( + '../data/local_data/datacenters/datacenters.geojsonl', + import.meta.url, + ).href, + 'local-dams': new URL( + '../data/local_data/dams/dams.geojsonl', + import.meta.url, + ).href, +}); + +/** Parse GeoJSON Lines: one Feature per non-empty line. */ +export function parseGeojsonLines(text) { + return String(text) + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line)); +} + +/** The singular label for a local infrastructure layer. */ +export function layerTitle(layerId) { + if (layerId === 'local-datacenters') return 'Datacenter'; + if (layerId === 'local-dams') return 'Dam'; + return 'Feature'; +} + +/** + * Map one local infrastructure feature to a JSON-safe analyst record + * (analyst query engine seam). Pure — no Cesium types. Missing/unknown + * fields are null, never NaN/undefined. Layer-specific fields that do not + * apply (river/output on datacenters, capacity on dams) stay null so a + * shared compact payload can copy them without inventing values. + * Names are unclamped — overlay cards shorten for paint; queries need the + * full source string ("Usina Hidrelétrica de Itaipu"). + * @param {Object|null|undefined} raw - {id, lat, lon, properties}. + * @param {string} [layerId] Local layer id (`local-datacenters` / `local-dams`). + * @returns {{id: string, name: string|null, lat: number|null, lon: number|null, + * operator: string|null, capacity: string|null, river: string|null, + * output: string|null}} + */ +export function mapAnalystRecord(raw, layerId = '') { + const num = (v) => (Number.isFinite(v) ? v : null); + const text = (v) => { + const t = String(v ?? '').trim(); + return t && t !== 'undefined' && t !== 'null' ? t : null; + }; + const props = + raw?.properties && + typeof raw.properties === 'object' && + !Array.isArray(raw.properties) + ? raw.properties + : {}; + const tags = + props.tags && typeof props.tags === 'object' && !Array.isArray(props.tags) + ? props.tags + : {}; + const name = + text(props.name) || + text(tags.name) || + text(tags['name:en']) || + text(tags.official_name) || + null; + const operator = + text(tags.operator) || + text(props.operator) || + text(tags['operator:short']) || + null; + const capacity = + layerId === 'local-datacenters' + ? text(tags['capacity:it_load']) || + text(tags.it_load) || + text(tags.capacity) || + text(props.capacity) + : null; + const river = + layerId === 'local-dams' + ? text(tags.associated_river) || + text(props.associated_river) || + text(tags.river) || + text(props.river) || + text(tags['river:name']) + : null; + const output = + layerId === 'local-dams' + ? text(props.output) || text(tags['plant:output:electricity']) + : null; + return { + id: name || text(raw?.id) || layerTitle(layerId), + name, + lat: num(raw?.lat), + lon: num(raw?.lon), + operator, + capacity, + river, + output, + }; +} From 28cfd39d1a80cb0e5fa743c31a8987ca2fda353a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:38:38 -0700 Subject: [PATCH 025/111] feat: add a datacenter and dam tool --- docs/CURRENT-STATE.md | 2 +- docs/TOOLS.md | 1 + scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 6 +- src/sources/infrastructureData.js | 60 +++++++++ src/tools/index.js | 2 + src/tools/queries/infrastructure.js | 70 +++++++++++ src/tools/queries/infrastructure.test.mjs | 143 ++++++++++++++++++++++ src/tools/services.js | 2 + 9 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 src/tools/queries/infrastructure.js create mode 100644 src/tools/queries/infrastructure.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3a330eb6c..df6793080 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -15,7 +15,7 @@ pass over a point, and those overhead now), public cameras (including a camera's current image), license plate reader cameras, radio stations, place search, routing, bike-share stations, transit vehicles, road traffic flow, weather, weather map images (radar, satellite, lightning), wind, the most recent satellite image of an -area, submarine cables, regional briefs, tropical cyclones, fire +area, submarine cables, datacenters and dams, regional briefs, tropical cyclones, fire perimeters, terrain height, military installations and map features, plus a combined situation brief, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 403b37513..811a47022 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -132,6 +132,7 @@ public feeds the sources already use. | `get_wind` | `wind` | GFS or IFS model wind 10 m above ground at a location | | `get_recent_imagery` | `imagery` | The most recent clear Landsat/Sentinel-2 image of an area (VIIRS fallback) | | `find_submarine_cables` | `cables` | TeleGeography cables and landing points by area or name (CC BY-NC-SA 3.0) | +| `find_infrastructure` | `infrastructure` | OpenStreetMap datacenters or dams in an area, nearest first (ODbL) | | `get_regional_brief` | `regional` | What and where a location is, its weather and recent headlines | | `get_cyclones` | `cyclones` | Active NHC/CPHC tropical cyclones, optionally in an area | | `get_fire_perimeters` | `perimeters` | Mapped WFIGS wildfire perimeters in an area, largest first | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2352e3df4..75bb30a7c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -883,5 +883,6 @@ "src/tools/queries/atmosphere.test.mjs", "src/tools/queries/imagery.test.mjs", "src/tools/queries/cables.test.mjs", - "src/tools/queries/surveillance.test.mjs" + "src/tools/queries/surveillance.test.mjs", + "src/tools/queries/infrastructure.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 1b7e90f56..f33058197 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3116,7 +3116,8 @@ "src/layers/wind/fields.js", "src/tools/queries/imagery.js", "src/tools/queries/cables.js", - "src/tools/queries/surveillance.js" + "src/tools/queries/surveillance.js", + "src/tools/queries/infrastructure.js" ], "external": ["satellite.js"] }, @@ -3191,7 +3192,8 @@ "src/layers/alpr/source.js", "src/layers/alpr/policy.js", "src/layers/alpr/records.js", - "src/layers/alpr/tileRecords.js" + "src/layers/alpr/tileRecords.js", + "src/sources/infrastructureData.js" ], "external": ["@mapbox/vector-tile", "pbf"] } diff --git a/src/sources/infrastructureData.js b/src/sources/infrastructureData.js index 9c19fdc87..6936b9aa5 100644 --- a/src/sources/infrastructureData.js +++ b/src/sources/infrastructureData.js @@ -101,3 +101,63 @@ export function mapAnalystRecord(raw, layerId = '') { output, }; } + +/** A representative point for a feature: its point, or its outline's average vertex. */ +export function featurePoint(feature) { + const geometry = feature?.geometry; + const outline = + geometry?.type === 'Point' + ? [geometry.coordinates] + : geometry?.type === 'LineString' + ? geometry.coordinates + : geometry?.type === 'Polygon' + ? geometry.coordinates?.[0] + : geometry?.type === 'MultiPolygon' + ? geometry.coordinates?.[0]?.[0] + : null; + const points = (outline || []).filter( + (point) => Number.isFinite(point?.[0]) && Number.isFinite(point?.[1]), + ); + if (!points.length) return null; + return { + lon: points.reduce((sum, point) => sum + point[0], 0) / points.length, + lat: points.reduce((sum, point) => sum + point[1], 0) / points.length, + }; +} + +/** Read the bundled layers' analyst records through a supplied transport. */ +export function createInfrastructureSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + const cache = new Map(); + return { + async getRecords(layerId, { signal } = {}) { + const url = INFRASTRUCTURE_DATA_URLS[layerId]; + if (!url) throw new TypeError(`Unknown infrastructure layer: ${layerId}`); + if (!cache.has(layerId)) { + signal?.throwIfAborted(); + const response = await fetchImpl(url, { signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const records = parseGeojsonLines(await response.text()).flatMap( + (feature) => { + const point = featurePoint(feature); + return point + ? [ + mapAnalystRecord( + { + id: feature.properties?.osm_id ?? feature.id, + ...point, + properties: feature.properties, + }, + layerId, + ), + ] + : []; + }, + ); + cache.set(layerId, records); + } + return cache.get(layerId); + }, + }; +} diff --git a/src/tools/index.js b/src/tools/index.js index d1fff6f35..1c35bdcc6 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -23,6 +23,7 @@ import { } from './queries/environment.js'; import { getActiveFires, getEarthquakes } from './queries/hazards.js'; import { getRecentImagery } from './queries/imagery.js'; +import { findInfrastructure } from './queries/infrastructure.js'; import { findVessel, getVesselTrack, @@ -97,6 +98,7 @@ export const coreTools = Object.freeze([ getWind, getRecentImagery, findSubmarineCables, + findInfrastructure, getRegionalBrief, getCyclones, getFirePerimeters, diff --git a/src/tools/queries/infrastructure.js b/src/tools/queries/infrastructure.js new file mode 100644 index 000000000..fd64f5fa5 --- /dev/null +++ b/src/tools/queries/infrastructure.js @@ -0,0 +1,70 @@ +/** Bundled OpenStreetMap infrastructure: datacenters and dams. */ + +import { defineTool } from '../catalog.js'; +import { + AREA_SCHEMA, + areaCenter, + areaContains, + distanceKm, + resolveArea, +} from '../area.js'; +import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js'; + +const KINDS = { + datacenters: { layerId: 'local-datacenters', noun: 'datacenter' }, + dams: { layerId: 'local-dams', noun: 'dam' }, +}; +const ATTRIBUTION = { + datacenters: '© OpenStreetMap contributors (ODbL 1.0)', + dams: '© OpenStreetMap contributors (ODbL 1.0), via Open Infrastructure Map', +}; + +const round = (value, digits) => + Number.isFinite(value) ? Number(value.toFixed(digits)) : null; + +export const findInfrastructure = defineTool({ + name: 'find_infrastructure', + title: 'Datacenters and dams', + description: + 'Datacenters (with operator and capacity) or dams (with river and power ' + + 'output) mapped in OpenStreetMap within an area, nearest the center first.', + inputSchema: { + type: 'object', + properties: { + kind: { type: 'string', enum: Object.keys(KINDS) }, + area: AREA_SCHEMA, + limit: LIMIT_SCHEMA, + }, + required: ['kind', 'area'], + additionalProperties: false, + }, + requires: ['infrastructure'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + const center = areaCenter(area); + const { layerId, noun } = KINDS[args.kind]; + const records = await services.infrastructure.getRecords(layerId, { + signal, + }); + const rows = records + .filter((record) => areaContains(area, record)) + .map((record) => ({ + name: record.name, + operator: record.operator, + ...(args.kind === 'datacenters' + ? { capacity: record.capacity } + : { river: record.river, output: record.output }), + lat: round(record.lat, 5), + lon: round(record.lon, 5), + distance_km: round(distanceKm(center, record), 1), + })) + .sort((a, b) => a.distance_km - b.distance_km); + return { + summary: `${countNoun(rows.length, `mapped ${noun}`)} in ${area.label}.`, + data: { + ...capRows(rows, args.limit), + attribution: ATTRIBUTION[args.kind], + }, + }; + }, +}); diff --git a/src/tools/queries/infrastructure.test.mjs b/src/tools/queries/infrastructure.test.mjs new file mode 100644 index 000000000..d2f209793 --- /dev/null +++ b/src/tools/queries/infrastructure.test.mjs @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; +import { createApiFetch } from '../../../server/mcp/services.js'; +import { + createInfrastructureSource, + featurePoint, +} from '../../sources/infrastructureData.js'; + +test('features are placed at their point or average outline vertex', () => { + assert.deepEqual( + featurePoint({ geometry: { type: 'Point', coordinates: [10, 20] } }), + { lon: 10, lat: 20 }, + ); + assert.deepEqual( + featurePoint({ + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + ], + }, + }), + { lon: 1, lat: 1 }, + ); + assert.deepEqual( + featurePoint({ + geometry: { + type: 'MultiPolygon', + coordinates: [ + [ + [ + [4, 4], + [6, 6], + ], + ], + ], + }, + }), + { lon: 5, lat: 5 }, + ); + assert.equal(featurePoint({ geometry: null }), null); +}); + +test('datacenters and dams are listed nearest first with their own fields', async () => { + const records = { + 'local-datacenters': [ + { + id: 'a', + name: 'Equinix DC2', + lat: 38.9, + lon: -77.45, + operator: 'Equinix', + capacity: '20 MW', + river: null, + output: null, + }, + { + id: 'b', + name: 'Far DC', + lat: 48.8, + lon: 2.35, + operator: null, + capacity: null, + river: null, + output: null, + }, + ], + 'local-dams': [ + { + id: 'c', + name: 'Hoover Dam', + lat: 36.016, + lon: -114.737, + operator: 'USBR', + capacity: null, + river: 'Colorado River', + output: '2080 MW', + }, + ], + }; + const infrastructure = { getRecords: async (layerId) => records[layerId] }; + const catalog = composeCatalog({ + tools: coreTools, + services: { infrastructure }, + }); + const dc = await catalog.call('find_infrastructure', { + kind: 'datacenters', + area: { lat: 38.95, lon: -77.45, radius_km: 20 }, + }); + assert.equal( + dc.summary, + '1 mapped datacenter in 20 km around 38.950, -77.450.', + ); + assert.deepEqual(dc.data.rows[0], { + name: 'Equinix DC2', + operator: 'Equinix', + capacity: '20 MW', + lat: 38.9, + lon: -77.45, + distance_km: 5.6, + }); + assert.match(dc.data.attribution, /OpenStreetMap contributors \(ODbL 1\.0\)/); + const dams = await catalog.call('find_infrastructure', { + kind: 'dams', + area: { lat: 36, lon: -114.7, radius_km: 20 }, + }); + assert.deepEqual(dams.data.rows[0], { + name: 'Hoover Dam', + operator: 'USBR', + river: 'Colorado River', + output: '2080 MW', + lat: 36.016, + lon: -114.737, + distance_km: 3.8, + }); +}); + +test('the bundled datasets load through the local fetch', async () => { + const fetchImpl = createApiFetch({ + apiBase: 'http://127.0.0.1:1', + fetchImpl: async () => assert.fail('no network'), + }); + const source = createInfrastructureSource({ fetchImpl }); + const dams = await source.getRecords('local-dams'); + const datacenters = await source.getRecords('local-datacenters'); + assert.ok(dams.length > 500 && datacenters.length > 3000); + assert.ok( + dams.every( + (record) => Number.isFinite(record.lat) && Number.isFinite(record.lon), + ), + ); + assert.equal(await source.getRecords('local-dams'), dams); + await assert.rejects( + source.getRecords('local-other'), + /Unknown infrastructure layer/, + ); +}); diff --git a/src/tools/services.js b/src/tools/services.js index a77494fc7..b18baca77 100644 --- a/src/tools/services.js +++ b/src/tools/services.js @@ -26,6 +26,7 @@ import { createTransitSource } from '../layers/transit/source.js'; import { createWeatherSource } from '../layers/weather/source.js'; import { createWindSource } from '../layers/wind/source.js'; import { createApplicationRequestServices } from '../services/requests.js'; +import { createInfrastructureSource } from '../sources/infrastructureData.js'; import { createAdsbLolSource, createAisStreamSource, @@ -70,6 +71,7 @@ export function createToolServices({ fetchImpl, appUrl }) { imagery: createImageryService({ fetchImpl }), cables: createBundledCableSource({ fetchImpl }), alpr: createAlprTileSource({ tileFetchImpl: fetchImpl }), + infrastructure: createInfrastructureSource({ fetchImpl }), regional: requests.regional, terrain: requests.terrain, summary: requests.summary, From a747b8a4e02e6bb1c781c1a470bc35400a2203b7 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:41:30 -0700 Subject: [PATCH 026/111] feat: add a military awareness tool --- docs/CURRENT-STATE.md | 2 +- docs/TOOLS.md | 3 +- src/tools/index.js | 7 +- src/tools/queries/brief.js | 126 +++++++++++++++++++++---- src/tools/queries/environment.test.mjs | 78 +++++++++++++++ 5 files changed, 197 insertions(+), 19 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index df6793080..d6673919e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -17,7 +17,7 @@ stations, transit vehicles, road traffic flow, weather, weather map images (radar, satellite, lightning), wind, the most recent satellite image of an area, submarine cables, datacenters and dams, regional briefs, tropical cyclones, fire perimeters, terrain height, military installations and map features, plus a -combined situation brief, the app's heads-up display caption, and a link that +combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. The app does not import the tools, so its runtime diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 811a47022..8186f11ed 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -53,7 +53,7 @@ relative `/api/...` paths through an injected `fetchImpl`, so the same tool code runs wherever an application routes those paths. The `weather`, `regional`, `terrain`, `summary` and `features` services are the application request services from `gods-eye-view/application/requests`. -`situation_brief` runs each section whose services are supplied and marks the +`situation_brief` and `military_awareness` run each section whose services are supplied and mark the others unavailable. `app` is `{ baseUrl }`, the address links open. The `bikeshare` service is `{ systems, getStations }`: the system registry and the GBFS source. `createGeocodePlaceService` resolves place names through `/api/geocode`; `createPlaceSearchService` searches `/api/google/*` and reports when no search key @@ -140,5 +140,6 @@ public feeds the sources already use. | `find_military_installations` | `installations` | OpenStreetMap military sites in an area of at most 10° per side | | `get_map_features` | `features` | Administrative areas, named places or monuments at a location (needs Overpass) | | `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 looking straight down on an area, with chosen layers on | diff --git a/src/tools/index.js b/src/tools/index.js index 1c35bdcc6..bedf234de 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -11,7 +11,11 @@ import { } from './queries/aviation.js'; import { getWeatherMap, getWind } from './queries/atmosphere.js'; import { findSubmarineCables } from './queries/cables.js'; -import { getHudCaption, situationBrief } from './queries/brief.js'; +import { + getHudCaption, + militaryAwareness, + situationBrief, +} from './queries/brief.js'; import { findMilitaryInstallations, getCyclones, @@ -106,6 +110,7 @@ export const coreTools = Object.freeze([ findMilitaryInstallations, getMapFeatures, situationBrief, + militaryAwareness, getHudCaption, openInGodsEyeView, ]); diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index 9c51f36a3..44dcbaf38 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -1,13 +1,25 @@ /** Composite queries that combine other tools' answers for one area. */ import { defineTool, ToolError } from '../catalog.js'; -import { AREA_SCHEMA, areaCenter, resolveArea } from '../area.js'; +import { + AREA_SCHEMA, + POINT_SCHEMA, + areaCenter, + resolveArea, + resolvePoint, +} from '../area.js'; import { aircraftInArea } from './aviation.js'; -import { getCyclones, getWeather } from './environment.js'; +import { + findMilitaryInstallations, + getCyclones, + getWeather, +} from './environment.js'; import { getActiveFires, getEarthquakes } from './hazards.js'; import { vesselsInArea } from './maritime.js'; const SECTION_LIMIT = 5; +const AWARENESS_LIMIT = 10; +const AWARENESS_RADIUS_KM = 250; /** Brief sections: the tool each one reuses and the services it needs. */ const SECTIONS = [ @@ -45,14 +57,37 @@ const SECTIONS = [ }, ]; -/** - * Run every section whose services are supplied. A failing section is - * reported as unavailable instead of failing the brief. - */ -async function buildBrief(args, { services, signal }) { - const resolved = await resolveArea(args.area, { services, signal }); - // Sections receive the resolved box so a place name is looked up once. - const area = { +/** Military contacts around a point, as the app's military awareness panel. */ +const AWARENESS_SECTIONS = [ + { + key: 'military_aircraft', + tool: aircraftInArea, + args: (area) => ({ + area: area.argument, + military: true, + limit: AWARENESS_LIMIT, + }), + }, + { + key: 'aircraft', + tool: aircraftInArea, + args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }), + }, + { + key: 'vessels', + tool: vesselsInArea, + args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }), + }, + { + key: 'installations', + tool: findMilitaryInstallations, + args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }), + }, +]; + +/** The resolved area, plus the argument sections receive in its place. */ +function sectionArea(resolved) { + return { ...resolved, argument: resolved.center ? { @@ -64,8 +99,15 @@ async function buildBrief(args, { services, signal }) { bbox: [resolved.west, resolved.south, resolved.east, resolved.north], }, }; - const center = areaCenter(resolved); - const sections = SECTIONS.filter(({ tool }) => +} + +/** + * Run every section whose services are supplied. A failing section is + * reported as unavailable instead of failing the whole answer. + */ +async function runSections(all, area, { services, signal }) { + const center = areaCenter(area); + const sections = all.filter(({ tool }) => tool.requires.every((key) => services[key] != null), ); const results = await Promise.allSettled( @@ -74,22 +116,32 @@ async function buildBrief(args, { services, signal }) { ), ); signal?.throwIfAborted(); - const brief = {}; + const answers = {}; const lines = []; sections.forEach(({ key }, index) => { const result = results[index]; if (result.status === 'fulfilled') { - brief[key] = { summary: result.value.summary, data: result.value.data }; + answers[key] = { summary: result.value.summary, data: result.value.data }; lines.push(result.value.summary); } else { const known = result.reason instanceof ToolError; - brief[key] = { + answers[key] = { unavailable: true, reason: known ? result.reason.message : 'unavailable right now', }; } }); - return { area: resolved, center, brief, lines }; + return { center, answers, lines }; +} + +async function buildBrief(args, { services, signal }) { + // Sections receive the resolved box so a place name is looked up once. + const area = sectionArea(await resolveArea(args.area, { services, signal })); + const { center, answers, lines } = await runSections(SECTIONS, area, { + services, + signal, + }); + return { area, center, brief: answers, lines }; } export const situationBrief = defineTool({ @@ -143,3 +195,45 @@ export const getHudCaption = defineTool({ }; }, }); + +export const militaryAwareness = defineTool({ + name: 'military_awareness', + title: 'Military awareness', + description: + 'Military contacts around a location, as the military awareness panel ' + + 'shows them: military aircraft, other aircraft, ships and mapped ' + + `military installations within ${AWARENESS_RADIUS_KM} km by default, ` + + 'each nearest first. Sections that are unavailable are marked as such.', + inputSchema: { + type: 'object', + properties: { + location: POINT_SCHEMA, + radius_km: { type: 'number', minimum: 1, maximum: AWARENESS_RADIUS_KM }, + }, + required: ['location'], + additionalProperties: false, + }, + requires: ['military'], + async run(args, { services, signal }) { + const point = await resolvePoint(args.location, { services, signal }); + const radiusKm = args.radius_km ?? AWARENESS_RADIUS_KM; + const area = sectionArea( + await resolveArea( + { lat: point.lat, lon: point.lon, radius_km: radiusKm }, + { services, signal }, + ), + ); + const { answers, lines } = await runSections(AWARENESS_SECTIONS, area, { + services, + signal, + }); + return { + summary: `Military awareness within ${radiusKm} km of ${point.label}: ${lines.join(' ')}`, + data: { + location: point, + radius_km: radiusKm, + sections: answers, + }, + }; + }, +}); diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index 4c9337a96..bd4f48bd3 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -453,3 +453,81 @@ test('the HUD caption summarizes the brief through the summary service', async ( (error) => error instanceof ToolError && error.code === 'unavailable', ); }); + +test('military awareness gathers contacts around a point and marks failures', async () => { + const contact = (id, latitude, longitude) => ({ + id, + reference: id, + latitude, + longitude, + callsign: 'RCH123', + baroAltitudeM: 9000, + onGround: false, + positionTimeMs: Date.UTC(2026, 0, 1), + }); + const military = { + getSnapshot: async () => ({ + records: [contact('ae1234', 32.8, -117.1), contact('ae9999', 40, -100)], + complete: true, + source: 'Military feed', + freshness: 'current', + }), + }; + const aircraft = { + getSnapshot: async () => { + throw new Error('upstream down'); + }, + }; + const installations = { + getMappedSites: async () => ({ + source: 'OpenStreetMap', + records: [ + { + id: 'osm:1', + kind: 'installation', + class: 'base', + name: 'Naval Base Point Loma', + latitude: 32.6941, + longitude: -117.2494, + }, + ], + }), + }; + const catalog = catalogWith({ military, aircraft, installations }); + const result = await catalog.call('military_awareness', { + location: { lat: 32.7, lon: -117.2 }, + radius_km: 100, + }); + assert.equal( + result.summary, + 'Military awareness within 100 km of 32.7000, -117.2000: ' + + '1 military aircraft in 100 km around 32.700, -117.200. ' + + '1 mapped military installation in 100 km around 32.700, -117.200.', + ); + assert.deepEqual(Object.keys(result.data.sections), [ + 'military_aircraft', + 'aircraft', + 'installations', + ]); + assert.deepEqual( + result.data.sections.military_aircraft.data.rows.map((row) => row.id), + ['ae1234'], + ); + assert.deepEqual(result.data.sections.aircraft, { + unavailable: true, + reason: 'unavailable right now', + }); + assert.equal(result.data.radius_km, 100); + assert.equal( + (await catalog.call('military_awareness', { location: { lat: 0, lon: 0 } })) + .data.radius_km, + 250, + ); + await assert.rejects( + catalog.call('military_awareness', { + location: { lat: 0, lon: 0 }, + radius_km: 500, + }), + /radius_km/, + ); +}); From 0953ebb0269d87852e37f7cdc238a9c632ed527e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:44:41 -0700 Subject: [PATCH 027/111] feat: add a Bhote Koshi flood event tool --- docs/CURRENT-STATE.md | 2 +- docs/TOOLS.md | 1 + public/events/bhote-koshi-2026/README.md | 3 +- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 7 +- src/sources/eventPacks.js | 24 +++++ src/tools/index.js | 2 + src/tools/queries/events.js | 130 +++++++++++++++++++++++ src/tools/queries/events.test.mjs | 74 +++++++++++++ src/tools/services.js | 2 + 10 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 src/sources/eventPacks.js create mode 100644 src/tools/queries/events.js create mode 100644 src/tools/queries/events.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d6673919e..54e410f44 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -15,7 +15,7 @@ pass over a point, and those overhead now), public cameras (including a camera's current image), license plate reader cameras, radio stations, place search, routing, bike-share stations, transit vehicles, road traffic flow, weather, weather map images (radar, satellite, lightning), wind, the most recent satellite image of an -area, submarine cables, datacenters and dams, regional briefs, tropical cyclones, fire +area, submarine cables, datacenters and dams, the Bhote Koshi flood event pack, regional briefs, tropical cyclones, fire perimeters, terrain height, military installations and map features, plus a combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 8186f11ed..d8da75bb0 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -133,6 +133,7 @@ public feeds the sources already use. | `get_recent_imagery` | `imagery` | The most recent clear Landsat/Sentinel-2 image of an area (VIIRS fallback) | | `find_submarine_cables` | `cables` | TeleGeography cables and landing points by area or name (CC BY-NC-SA 3.0) | | `find_infrastructure` | `infrastructure` | OpenStreetMap datacenters or dams in an area, nearest first (ODbL) | +| `get_bhote_koshi_flood` | `events` | The 2026 Bhote Koshi flood: evidence trail in story order, flood path and imagery dates (CC BY-NC 4.0) | | `get_regional_brief` | `regional` | What and where a location is, its weather and recent headlines | | `get_cyclones` | `cyclones` | Active NHC/CPHC tropical cyclones, optionally in an area | | `get_fire_perimeters` | `perimeters` | Mapped WFIGS wildfire perimeters in an area, largest first | diff --git a/public/events/bhote-koshi-2026/README.md b/public/events/bhote-koshi-2026/README.md index f8d3657e7..a0160eab9 100644 --- a/public/events/bhote-koshi-2026/README.md +++ b/public/events/bhote-koshi-2026/README.md @@ -18,7 +18,8 @@ For commercial use, obtain separate permission or exclude both this pack and the derived coordinate dataset in `src/data/bhoteKoshiFloodPath.js` from the source and build. Deleting this directory alone does not remove the compiled river data. Excluding the Nepal scene also requires removing its registrations and -imports before building. +imports before building, including the `get_bhote_koshi_flood` tool in +`src/tools/queries/events.js`. Sources: diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 75bb30a7c..e02a2e4f9 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -884,5 +884,6 @@ "src/tools/queries/imagery.test.mjs", "src/tools/queries/cables.test.mjs", "src/tools/queries/surveillance.test.mjs", - "src/tools/queries/infrastructure.test.mjs" + "src/tools/queries/infrastructure.test.mjs", + "src/tools/queries/events.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f33058197..fa7885c2c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3117,7 +3117,9 @@ "src/tools/queries/imagery.js", "src/tools/queries/cables.js", "src/tools/queries/surveillance.js", - "src/tools/queries/infrastructure.js" + "src/tools/queries/infrastructure.js", + "src/tools/queries/events.js", + "src/data/bhoteKoshiFloodPath.js" ], "external": ["satellite.js"] }, @@ -3193,7 +3195,8 @@ "src/layers/alpr/policy.js", "src/layers/alpr/records.js", "src/layers/alpr/tileRecords.js", - "src/sources/infrastructureData.js" + "src/sources/infrastructureData.js", + "src/sources/eventPacks.js" ], "external": ["@mapbox/vector-tile", "pbf"] } diff --git a/src/sources/eventPacks.js b/src/sources/eventPacks.js new file mode 100644 index 000000000..53a2c3776 --- /dev/null +++ b/src/sources/eventPacks.js @@ -0,0 +1,24 @@ +/** Event packs the app serves under `/events//`, read once per id. */ + +const EVENT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export function createEventPackSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + const cache = new Map(); + return { + async getEvent(id, { signal } = {}) { + if (!EVENT_ID.test(id)) throw new TypeError(`Invalid event id: ${id}`); + if (!cache.has(id)) { + signal?.throwIfAborted(); + const response = await fetchImpl(`/events/${id}/event.json`, { + signal, + }); + if (!response.ok) + throw new Error(`Event pack ${id} unavailable (${response.status})`); + cache.set(id, await response.json()); + } + return cache.get(id); + }, + }; +} diff --git a/src/tools/index.js b/src/tools/index.js index bedf234de..ebe5b0b7e 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -25,6 +25,7 @@ import { getTerrainHeight, getWeather, } from './queries/environment.js'; +import { getBhoteKoshiFlood } from './queries/events.js'; import { getActiveFires, getEarthquakes } from './queries/hazards.js'; import { getRecentImagery } from './queries/imagery.js'; import { findInfrastructure } from './queries/infrastructure.js'; @@ -103,6 +104,7 @@ export const coreTools = Object.freeze([ getRecentImagery, findSubmarineCables, findInfrastructure, + getBhoteKoshiFlood, getRegionalBrief, getCyclones, getFirePerimeters, diff --git a/src/tools/queries/events.js b/src/tools/queries/events.js new file mode 100644 index 000000000..9b530b40b --- /dev/null +++ b/src/tools/queries/events.js @@ -0,0 +1,130 @@ +/** The bundled Bhote Koshi 2026 outburst flood event pack. */ + +import { BHOTE_KOSHI_FLOOD_PATH } from '../../data/bhoteKoshiFloodPath.js'; +import { defineTool, ToolError } from '../catalog.js'; +import { distanceKm } from '../area.js'; + +const EVENT_ID = 'bhote-koshi-2026'; +const ATTRIBUTION = + 'Imagery: Vantor Open Data; reconstruction: GeoPera (CC BY-NC 4.0). ' + + 'Linked witness posts and the GeoGeorgeShadrach geolocation map keep ' + + "their owners' terms."; + +const round = (value, digits) => + Number.isFinite(value) ? Number(value.toFixed(digits)) : null; + +/** Length of a `[lon, lat]` line in kilometers. */ +function pathLengthKm(path) { + let total = 0; + for (let index = 1; index < path.length; index += 1) + total += distanceKm( + { lat: path[index - 1][1], lon: path[index - 1][0] }, + { lat: path[index][1], lon: path[index][0] }, + ); + return total; +} + +const pathPoint = ([lon, lat]) => ({ lat, lon }); + +function evidenceRow(record) { + return { + sequence: record.sequence ?? null, + id: record.id, + title: record.title ?? null, + summary: record.summary ?? null, + phase: record.phase ?? null, + lat: round(record.lat, 5), + lon: round(record.lon, 5), + location_confidence: record.confidence?.location ?? null, + time_confidence: record.confidence?.time ?? null, + captured_at: record.timing?.capturedAt ?? null, + in_imagery: record.imageryCoverage === 'within', + source_url: record.media?.sourceUrl ?? null, + platform: record.media?.platform ?? null, + via: record.provenance?.via ?? null, + }; +} + +export const getBhoteKoshiFlood = defineTool({ + name: 'get_bhote_koshi_flood', + title: 'Bhote Koshi flood', + description: + "The 26 August 2026 Bhote Koshi outburst flood in Nepal as God's Eye " + + 'View maps it: the evidence trail of geolocated public witness posts ' + + 'from the collapse to Devighat in story order, the mapped flood path, ' + + 'and the before and after satellite imagery. A visualization, not an ' + + 'official hazard model; capture times are unverified.', + inputSchema: { + type: 'object', + properties: { + phase: { + type: 'string', + minLength: 1, + maxLength: 40, + description: 'Only evidence in a phase, such as "downstream passage".', + }, + }, + additionalProperties: false, + }, + requires: ['events'], + async run(args, { services, signal }) { + const event = await services.events.getEvent(EVENT_ID, { signal }); + if (!Array.isArray(event?.evidenceSpine)) + throw new ToolError('malformed', 'The event pack has no evidence'); + const wanted = args.phase?.toLowerCase(); + const evidence = event.evidenceSpine + .filter((record) => !wanted || record.phase?.toLowerCase() === wanted) + .sort((a, b) => a.sequence - b.sequence) + .map(evidenceRow); + const phases = [ + ...new Set(event.evidenceSpine.map((record) => record.phase)), + ]; + const lengthKm = round(pathLengthKm(BHOTE_KOSHI_FLOOD_PATH), 1); + const { imagery = {} } = event; + return { + summary: + `${event.title}, observed ${event.observedDate}: ${evidence.length} ` + + `evidence record${evidence.length === 1 ? '' : 's'}` + + (args.phase ? ` in the ${args.phase} phase` : '') + + ` along a ${lengthKm} km mapped flood path.`, + data: { + id: event.id, + title: event.title, + observed_date: event.observedDate, + caveat: event.reconstruction?.caveat ?? null, + phases, + evidence, + witnesses: (event.witnessAnchors || []).map((anchor) => ({ + title: anchor.title ?? null, + lat: round(anchor.lat, 6), + lon: round(anchor.lon, 6), + source_url: anchor.sourceUrl ?? null, + provenance: anchor.provenance ?? null, + })), + flood_path: { + start: pathPoint(BHOTE_KOSHI_FLOOD_PATH[0]), + end: pathPoint(BHOTE_KOSHI_FLOOD_PATH.at(-1)), + length_km: lengthKm, + description: + 'Simplified river centerline from the debris-dammed lake to Trishuli Bazaar.', + }, + imagery: { + box: imagery.rectangle ?? null, + before: imagery.before + ? { + observed_at: imagery.before.observedAt, + source: imagery.before.source, + } + : null, + after: imagery.after + ? { + observed_at: imagery.after.observedAt, + source: imagery.after.source, + } + : null, + }, + attribution: ATTRIBUTION, + }, + }; + }, +}); diff --git a/src/tools/queries/events.test.mjs b/src/tools/queries/events.test.mjs new file mode 100644 index 000000000..1daaff486 --- /dev/null +++ b/src/tools/queries/events.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; +import { createEventPackSource } from '../../sources/eventPacks.js'; + +const EVENT_URL = new URL( + '../../../public/events/bhote-koshi-2026/event.json', + import.meta.url, +); +const bundled = JSON.parse(await readFile(EVENT_URL, 'utf8')); + +test('the event pack source reads each event once and checks the id', async () => { + const requested = []; + const source = createEventPackSource({ + fetchImpl: async (url) => { + requested.push(url); + return Response.json(bundled); + }, + }); + const event = await source.getEvent('bhote-koshi-2026'); + assert.deepEqual(event, bundled); + assert.equal(await source.getEvent('bhote-koshi-2026'), event); + assert.deepEqual(requested, ['/events/bhote-koshi-2026/event.json']); + await assert.rejects(source.getEvent('../secret'), /Invalid event id/); + const missing = createEventPackSource({ + fetchImpl: async () => new Response(null, { status: 404 }), + }); + await assert.rejects(missing.getEvent('other'), /unavailable \(404\)/); +}); + +test('the flood tool lists the bundled evidence in story order', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { events: { getEvent: async () => bundled } }, + }); + const all = await catalog.call('get_bhote_koshi_flood', {}); + assert.match( + all.summary, + /^Bhote Koshi Outburst Flood, observed 2026-08-26: 16 evidence records along a \d+(\.\d)? km mapped flood path\.$/, + ); + assert.deepEqual( + all.data.evidence.map((row) => row.sequence), + Array.from({ length: 16 }, (_, index) => index + 1), + ); + assert.deepEqual(all.data.evidence[3], { + sequence: 4, + id: 'gyirong-border-gate', + title: 'Gyirong border gate', + summary: 'Public footage geolocated at the Nepal-China border crossing.', + phase: 'border arrival', + lat: 28.28083, + lon: 85.37802, + location_confidence: 'geolocated', + time_confidence: 'unverified', + captured_at: null, + in_imagery: true, + source_url: 'https://www.youtube.com/watch?v=qlE51Eu0thk', + platform: 'YouTube', + via: 'GeoGeorgeShadrach public geolocation map; independently consistent with the linked GeoConfirmed witness', + }); + assert.deepEqual(all.data.flood_path.start, { lat: 28.33255, lon: 85.48476 }); + assert.ok(all.data.flood_path.length_km > 50); + assert.equal(all.data.imagery.after.observed_at, '2026-08-27'); + assert.match(all.data.caveat, /not an official hazard model/); + assert.match(all.data.attribution, /CC BY-NC 4\.0/); + const downstream = await catalog.call('get_bhote_koshi_flood', { + phase: 'Downstream passage', + }); + assert.ok( + downstream.data.evidence.every((row) => row.phase === 'downstream passage'), + ); + assert.match(downstream.summary, /in the Downstream passage phase/); +}); diff --git a/src/tools/services.js b/src/tools/services.js index b18baca77..422207629 100644 --- a/src/tools/services.js +++ b/src/tools/services.js @@ -26,6 +26,7 @@ import { createTransitSource } from '../layers/transit/source.js'; import { createWeatherSource } from '../layers/weather/source.js'; import { createWindSource } from '../layers/wind/source.js'; import { createApplicationRequestServices } from '../services/requests.js'; +import { createEventPackSource } from '../sources/eventPacks.js'; import { createInfrastructureSource } from '../sources/infrastructureData.js'; import { createAdsbLolSource, @@ -72,6 +73,7 @@ export function createToolServices({ fetchImpl, appUrl }) { cables: createBundledCableSource({ fetchImpl }), alpr: createAlprTileSource({ tileFetchImpl: fetchImpl }), infrastructure: createInfrastructureSource({ fetchImpl }), + events: createEventPackSource({ fetchImpl }), regional: requests.regional, terrain: requests.terrain, summary: requests.summary, From 1a03e7483dd04ddb574b07c1b7c8c8782a9e11ff Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:52:36 -0700 Subject: [PATCH 028/111] feat: share the tool catalog with voice --- CHANGELOG.md | 6 ++- docs/CURRENT-STATE.md | 5 ++- docs/TOOLS.md | 31 +++++++++++++-- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 7 +++- server/providers/local.js | 9 +++-- server/providers/openai/instructions.js | 1 + server/providers/openai/realtime.js | 3 +- server/providers/openai/tools.js | 12 ++++++ server/standalone/vite.config.js | 7 +++- server/standalone/voiceTools.js | 16 ++++++++ src/standalone/toolCatalog.js | 22 +++++++++++ src/standalone/tools.js | 2 + src/tooling/localServices.test.mjs | 33 ++++++++++++++++ src/tools/functions.js | 28 ++++++++++++++ src/tools/functions.test.mjs | 50 +++++++++++++++++++++++++ src/tools/index.js | 1 + src/voice/gevRealtime.js | 26 ++++++++++++- src/voice/gevRealtime.test.mjs | 41 ++++++++++++++++++++ 19 files changed, 288 insertions(+), 15 deletions(-) create mode 100644 server/standalone/voiceTools.js create mode 100644 src/standalone/toolCatalog.js create mode 100644 src/tools/functions.js create mode 100644 src/tools/functions.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b32c2471..c670b5fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,11 @@ stdio to clients such as Claude Code, reading from a running app. Tools are defined once in `gods-eye-view/tools`, reuse the layers' source factories, and are - exposed through the protocol adapter in `gods-eye-view/tools/mcp`. + exposed through the protocol adapter in `gods-eye-view/tools/mcp`. The + catalog also covers weather maps, wind, recent imagery, submarine cables, + license plate reader cameras, datacenters and dams, the Bhote Koshi flood, + military awareness and links that open the app over an area with layers on. + Voice offers the same queries next to its app actions. - Public Overpass instances are no longer used by default. Street Traffic roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 54e410f44..7ed18026a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -20,8 +20,9 @@ perimeters, terrain height, military installations and map features, plus a combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap -lists at 25 rows by default. The app does not import the tools, so its runtime -behavior is unchanged. See [tools and the MCP server](TOOLS.md). +lists at 25 rows by default. Voice offers the same queries next to its app +actions: the session lists them, and the browser runs them through the same +catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). ## Cyber HUD — September 23, 2026 diff --git a/docs/TOOLS.md b/docs/TOOLS.md index d8da75bb0..3484a1c11 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -1,8 +1,8 @@ # Tools and the MCP server Tools answer questions from God's Eye View data for language-model clients. -They are defined once and exposed through adapters; the Model Context Protocol -(MCP) is the first. +They are defined once and exposed through adapters: the Model Context Protocol +(MCP) and function calling, which voice uses. ## Layers @@ -11,13 +11,17 @@ They are defined once and exposed through adapters; the Model Context Protocol | `src/tools/` | Tool definitions, catalog composition, argument validation, shared `area` and result helpers | | `src/tools/queries/` | Queries, one file per domain, reading only portable source contracts | | `src/tools/mcp/` | MCP protocol (JSON-RPC) and a stateless HTTP transport; knows the catalog interface, not queries | +| `src/tools/functions.js` | Function-calling adapter: tool records and results for function-calling clients | | `src/tools/services.js` | The default services: the layers' source factories and request services, given a resolving fetch | | `server/mcp/` | Node composition: points the sources at a running app's `/api` routes; serves stdio and `/mcp` | +| `server/standalone/voiceTools.js`, `src/standalone/toolCatalog.js` | Standalone voice composition: the session's tool list and the browser catalog | Dependencies point downward only. `gods-eye-view/tools` and `gods-eye-view/tools/mcp` are portable exports: they reach no application, rendering, Node, Cesium or browser-global code, which -`npm run check:boundaries` enforces. Nothing in the application imports them. +`npm run check:boundaries` enforces. In the application, only voice reaches +them: `withToolCatalog` in `src/voice/gevRealtime.js` imports the +function-calling adapter, and the standalone entry supplies the catalog. ## Definitions and composition @@ -77,6 +81,27 @@ returns a `Request`-to-`Response` handler for stateless Streamable HTTP: one JSON-RPC message per POST, answered with JSON. The host owns routing and any access control in front of it. +## Voice + +Voice offers the catalog's queries next to its app actions. +`toFunctionTools(tools, { exclude })` turns tools into +`{ type: 'function', name, description, parameters }` records, and +`toFunctionOutput(name, result)` turns a result into +`{ ok, tool, summary, data }`, counting images in `images_omitted` instead of +sending them. + +The voice session token endpoint takes its tool list as `realtime.tools`. +`realtimeSessionTools(additional)` appends function tools to the app actions, +skipping names an action already uses, so `next_satellite_pass` stays the +action. The standalone server supplies every core query except +`open_in_gods_eye_view`, since voice runs inside the app. + +In the browser, `initGevVoiceCommands({ toolCatalog })` takes a function that +resolves a catalog. App action names go to the action runner; other names the +catalog has go to `catalog.call` with the call's abort signal. The standalone +entry composes the catalog with `createToolServices` over the page's fetch and +loads it the first time voice calls a query. + ## Running locally Start the app (`npm run dev` or `npm run preview`), then register the stdio diff --git a/scripts/format-scope.json b/scripts/format-scope.json index e02a2e4f9..69312e248 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -885,5 +885,6 @@ "src/tools/queries/cables.test.mjs", "src/tools/queries/surveillance.test.mjs", "src/tools/queries/infrastructure.test.mjs", - "src/tools/queries/events.test.mjs" + "src/tools/queries/events.test.mjs", + "src/tools/functions.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index fa7885c2c..9b14f6392 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2036,7 +2036,9 @@ "src/data/local_data/osm_military_names/names.json", "src/layers/installations/namedMarkers.js", "src/data/infrastructureOverlayEntry.js", - "src/sources/infrastructureData.js" + "src/sources/infrastructureData.js", + "src/tools/functions.js", + "src/voice/actionSchemas.js" ], "external": [ "hls.js", @@ -3119,7 +3121,8 @@ "src/tools/queries/surveillance.js", "src/tools/queries/infrastructure.js", "src/tools/queries/events.js", - "src/data/bhoteKoshiFloodPath.js" + "src/data/bhoteKoshiFloodPath.js", + "src/tools/functions.js" ], "external": ["satellite.js"] }, diff --git a/server/providers/local.js b/server/providers/local.js index 398519d92..fe6ee2e5a 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -26,8 +26,11 @@ import { firePerimetersProxy } from './firePerimeters.js'; import { cycloneProxy } from './cyclones.js'; import { windProxy } from './wind.js'; -/** Construct the local provider plugins in their established order. */ -function localProviderPlugins() { +/** + * Construct the local provider plugins in their established order. + * `realtime` configures the voice session token endpoint. + */ +function localProviderPlugins({ realtime } = {}) { return [ openSkyProxy(), celestrakProxy(), @@ -49,7 +52,7 @@ function localProviderPlugins() { adsbLolProxy(), aisLiveProxy(), trackBackfillProxies(), - openAiRealtimeProxy(), + openAiRealtimeProxy({ realtime }), googlePlacesContextProxy(), windProxy(), weatherProxy(), diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index fa957ac7c..6633dab10 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -19,6 +19,7 @@ function realtimeInstructions( 'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.', 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.', 'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, earthquakes, satellites, datacenters, or dams ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.', + 'WORLD QUERIES — the query tools (get_weather, aircraft_in_area, vessels_in_area, get_earthquakes, search_places, plan_route, situation_brief, military_awareness and the other get_*, find_* and *_in_area tools) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "any ships near Singapore", "how do I drive from Austin to Dallas"). Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.', 'FEED STATE CONTRACT — analyst_query and get_current_view_state carry feedProvenance from the same layerFeedState the Data Layers chips use (nominal/loading/degraded/stale/fallback/unavailable, or off). NEVER narrate a count as live or current when feedProvenance.overall is not nominal. Say the feedState in the same breath as the count ("12 flights in view, STALE"). If unavailable, do not invent a count; say the feed is unavailable. Fallback is valid data — name the fallback source, never call it an enable failure. Confirm only what the tool result returned.', 'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near " means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.', 'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.', diff --git a/server/providers/openai/realtime.js b/server/providers/openai/realtime.js index b4dfb4b5a..85539400f 100644 --- a/server/providers/openai/realtime.js +++ b/server/providers/openai/realtime.js @@ -20,6 +20,7 @@ function createRealtimeTokenHandler({ fetchImpl = (...args) => fetch(...args), resolveApiKey = () => process.env.OPENAI_API_KEY, models = {}, + tools = GEV_REALTIME_TOOLS, } = {}) { return async (req, res) => { res.setHeader('Cache-Control', 'no-store'); @@ -113,7 +114,7 @@ function createRealtimeTokenHandler({ output: { voice }, }, instructions: realtimeInstructions(annotationGuidance), - tools: GEV_REALTIME_TOOLS, + tools, tool_choice: 'auto', }, }; diff --git a/server/providers/openai/tools.js b/server/providers/openai/tools.js index a1bbc4745..fdbb14cd8 100644 --- a/server/providers/openai/tools.js +++ b/server/providers/openai/tools.js @@ -2,3 +2,15 @@ import { createActionTools } from '../../../src/voice/actionSchemas.js'; import { ACTION_DESCRIPTIONS } from './toolDescriptions.js'; export const GEV_REALTIME_TOOLS = createActionTools(ACTION_DESCRIPTIONS); + +/** + * The session's tools: the app actions, then additional function tools + * whose names no action already uses. + */ +export function realtimeSessionTools(additional = []) { + const names = new Set(GEV_REALTIME_TOOLS.map((tool) => tool.name)); + return [ + ...GEV_REALTIME_TOOLS, + ...additional.filter((tool) => !names.has(tool.name)), + ]; +} diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index fd315bea8..8cb3679ef 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -4,6 +4,7 @@ import { createBrowserViteConfig } from '../../build/vite.js'; import { localProviderPlugins } from '../providers/local.js'; import { localMcpPlugin } from '../mcp/plugin.js'; import { apiNotFoundPlugin } from './api-not-found.js'; +import { standaloneVoiceTools } from './voiceTools.js'; const root = fileURLToPath(new URL('../../', import.meta.url)); @@ -14,7 +15,11 @@ export default defineConfig(({ command, mode }) => { if (process.env[key] === undefined) process.env[key] = value; } return createBrowserViteConfig({ - plugins: [...localProviderPlugins(), localMcpPlugin(), apiNotFoundPlugin()], + plugins: [ + ...localProviderPlugins({ realtime: { tools: standaloneVoiceTools() } }), + localMcpPlugin(), + apiNotFoundPlugin(), + ], googleApiKey: process.env.GOOGLE_MAPS_API_KEY, cesiumToken: process.env.CESIUM_ION_TOKEN, host: process.env.HOST, diff --git a/server/standalone/voiceTools.js b/server/standalone/voiceTools.js new file mode 100644 index 000000000..9e7c26dce --- /dev/null +++ b/server/standalone/voiceTools.js @@ -0,0 +1,16 @@ +/** + * The voice session's tools for the standalone server: the app actions plus + * Core's catalog queries, which the browser runs through the same catalog. + * Links to the app are left out, since voice runs inside it. + */ + +import { coreTools, toFunctionTools } from '../../src/tools/index.js'; +import { realtimeSessionTools } from '../providers/openai/tools.js'; + +export const VOICE_EXCLUDED_QUERIES = Object.freeze(['open_in_gods_eye_view']); + +export function standaloneVoiceTools() { + return realtimeSessionTools( + toFunctionTools(coreTools, { exclude: VOICE_EXCLUDED_QUERIES }), + ); +} diff --git a/src/standalone/toolCatalog.js b/src/standalone/toolCatalog.js new file mode 100644 index 000000000..b8c550427 --- /dev/null +++ b/src/standalone/toolCatalog.js @@ -0,0 +1,22 @@ +/** The standalone tool catalog for voice, loaded the first time voice needs it. */ + +let pending = null; + +export function loadToolCatalog() { + pending ??= Promise.all([ + import('../tools/index.js'), + import('../tools/services.js'), + ]).then(([{ composeCatalog, coreTools }, { createToolServices }]) => + composeCatalog({ + tools: coreTools, + services: createToolServices({ + fetchImpl: (...args) => globalThis.fetch(...args), + appUrl: globalThis.location.origin, + }), + }), + ); + pending.catch(() => { + pending = null; + }); + return pending; +} diff --git a/src/standalone/tools.js b/src/standalone/tools.js index 36c273cd9..047434924 100644 --- a/src/standalone/tools.js +++ b/src/standalone/tools.js @@ -1,6 +1,7 @@ import { createAssetDirectorySource } from '../director/packs/source.js'; import { createApplicationTools } from '../app/tools.js'; import { startStandaloneChrome } from './startupChrome.js'; +import { loadToolCatalog } from './toolCatalog.js'; export function createStandaloneTools(options) { return createApplicationTools({ startChrome: startStandaloneChrome, @@ -12,5 +13,6 @@ export function createStandaloneTools(options) { }, }, ...options, + voice: { toolCatalog: loadToolCatalog, ...options?.voice }, }); } diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index 26eebfa08..e8ff998a0 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -21,6 +21,7 @@ import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai'; import { keySetupEndpoint } from 'gods-eye-view/server/standalone/key-setup'; import { realtimeInstructions } from '../../server/providers/openai/instructions.js'; import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js'; +import { standaloneVoiceTools } from '../../server/standalone/voiceTools.js'; function install(plugin, preview = false) { const routes = new Map(); @@ -195,6 +196,38 @@ test('Realtime handler preserves tools and default instructions, isolates suppli assert.equal(sent[0].session.instructions, sent[2].session.instructions); }); +test('Realtime sessions carry supplied tools, and the standalone voice adds the catalog queries', async (t) => { + env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret'); + env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined); + const sent = []; + t.mock.method(globalThis, 'fetch', async (url, options) => { + sent.push(JSON.parse(options.body)); + return Response.json({ value: 'fixture-ephemeral' }); + }); + const tools = standaloneVoiceTools(); + const response = await request( + install(openAiRealtimeProxy({ realtime: { tools } })).get( + '/api/realtime/token', + ), + ); + assert.equal(response.status, 200); + assert.deepEqual(sent.at(-1).session.tools, tools); + const names = tools.map((tool) => tool.name); + assert.equal(new Set(names).size, names.length); + assert.deepEqual( + tools.slice(0, GEV_REALTIME_TOOLS.length), + GEV_REALTIME_TOOLS, + ); + assert.ok(names.includes('get_weather')); + assert.ok(names.includes('military_awareness')); + assert.ok(!names.includes('open_in_gods_eye_view')); + // The action of the same name answers satellite passes. + assert.equal( + tools.findLast((tool) => tool.name === 'next_satellite_pass'), + GEV_REALTIME_TOOLS.find((tool) => tool.name === 'next_satellite_pass'), + ); +}); + test('debug logging resolves each supplied application directory independently', async (t) => { const first = root(t), second = root(t); diff --git a/src/tools/functions.js b/src/tools/functions.js new file mode 100644 index 000000000..ef5cdbf0e --- /dev/null +++ b/src/tools/functions.js @@ -0,0 +1,28 @@ +/** Function-calling adapter for a tool catalog: tool records and results. */ + +/** Tools as `{ type: 'function', name, description, parameters }` records. */ +export function toFunctionTools(tools, { exclude = [] } = {}) { + const skipped = new Set(exclude); + return tools + .filter((tool) => !skipped.has(tool.name)) + .map((tool) => ({ + type: 'function', + name: tool.name, + description: tool.description, + parameters: structuredClone(tool.inputSchema), + })); +} + +/** + * A tool result as one JSON function output. Images are counted rather than + * sent, since function outputs carry text. + */ +export function toFunctionOutput(name, result) { + return { + ok: true, + tool: name, + summary: result.summary, + data: result.data, + ...(result.images?.length ? { images_omitted: result.images.length } : {}), + }; +} diff --git a/src/tools/functions.test.mjs b/src/tools/functions.test.mjs new file mode 100644 index 000000000..0965233a3 --- /dev/null +++ b/src/tools/functions.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { coreTools, toFunctionOutput, toFunctionTools } from './index.js'; + +test('catalog tools become independent function records', () => { + const records = toFunctionTools(coreTools, { + exclude: ['open_in_gods_eye_view'], + }); + assert.equal(records.length, coreTools.length - 1); + assert.ok(!records.some((tool) => tool.name === 'open_in_gods_eye_view')); + const weather = records.find((tool) => tool.name === 'get_weather'); + const source = coreTools.find((tool) => tool.name === 'get_weather'); + assert.deepEqual(Object.keys(weather), [ + 'type', + 'name', + 'description', + 'parameters', + ]); + assert.equal(weather.type, 'function'); + assert.equal(weather.description, source.description); + assert.deepEqual(weather.parameters, source.inputSchema); + assert.notEqual(weather.parameters, source.inputSchema); + weather.parameters.properties.location.description = 'changed'; + assert.notEqual( + source.inputSchema.properties.location.description, + 'changed', + ); + assert.ok(records.every((tool) => tool.parameters.type === 'object')); +}); + +test('results become function outputs without image data', () => { + assert.deepEqual( + toFunctionOutput('get_wind', { summary: 'Calm.', data: { calm: true } }), + { ok: true, tool: 'get_wind', summary: 'Calm.', data: { calm: true } }, + ); + assert.deepEqual( + toFunctionOutput('get_weather_map', { + summary: 'Radar.', + data: {}, + images: [{ mimeType: 'image/png', data: 'AAAA' }], + }), + { + ok: true, + tool: 'get_weather_map', + summary: 'Radar.', + data: {}, + images_omitted: 1, + }, + ); +}); diff --git a/src/tools/index.js b/src/tools/index.js index ebe5b0b7e..0ba01004b 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -67,6 +67,7 @@ export { distanceKm, } from './area.js'; export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js'; +export { toFunctionOutput, toFunctionTools } from './functions.js'; export { createGeocodePlaceService, createPlaceSearchService, diff --git a/src/voice/gevRealtime.js b/src/voice/gevRealtime.js index 63286951c..837684668 100644 --- a/src/voice/gevRealtime.js +++ b/src/voice/gevRealtime.js @@ -1,11 +1,35 @@ +import { toFunctionOutput } from '../tools/functions.js'; +import { GEV_ACTION_SCHEMAS } from './actionSchemas.js'; import { createGevActionRunner } from './gevActions.js'; import { createVoiceCommands } from './commands.js'; export * from './realtimeController.js'; +const ACTION_NAMES = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name)); + +/** + * Run app actions through `runner` and every other tool the catalog has + * through the catalog. `loadCatalog` resolves the catalog when first needed. + */ +export function withToolCatalog(runner, loadCatalog) { + if (typeof loadCatalog !== 'function') return runner; + return async function runGevTool(name, args, options = {}) { + if (ACTION_NAMES.has(name)) return runner(name, args, options); + const catalog = await loadCatalog(); + if (!catalog?.get(name)) return runner(name, args, options); + const result = await catalog.call(name, args ?? {}, { + signal: options.signal, + }); + return toFunctionOutput(name, result); + }; +} + /** Compose the standalone action runner with the voice controls. */ export function initGevVoiceCommands(options) { return createVoiceCommands({ ...options, - runner: createGevActionRunner(options), + runner: withToolCatalog( + createGevActionRunner(options), + options.toolCatalog, + ), }); } diff --git a/src/voice/gevRealtime.test.mjs b/src/voice/gevRealtime.test.mjs index d7056a0e3..8c0f5df6f 100644 --- a/src/voice/gevRealtime.test.mjs +++ b/src/voice/gevRealtime.test.mjs @@ -31,6 +31,7 @@ import { readStoredVoiceLimits, writeStoredVoiceTier, writeStoredVoiceLimits, + withToolCatalog, } from './gevRealtime.js'; import { createVoiceCostTracker } from './voiceCost.js'; @@ -3681,3 +3682,43 @@ test('a genuinely different refused call still gets its own output', async () => const testPlaceSearch = () => createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ }); function createGevActionRunner(options) { return createActionRunner({ placeSearch: testPlaceSearch(), ...options }); } function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { placeSearch: testPlaceSearch(), ...options }); } + +test('voice runs app actions itself and other tools through the catalog', async () => { + const actions = []; + const runner = async (name, args) => { + actions.push([name, args]); + return { ok: true, action: name }; + }; + const calls = []; + let loads = 0; + const catalog = { + get: (name) => (name === 'get_wind' ? { name } : undefined), + async call(name, args, options) { + calls.push([name, args, options.signal]); + return { summary: 'Calm.', data: { calm: true } }; + }, + }; + const run = withToolCatalog(runner, async () => { + loads += 1; + return catalog; + }); + const signal = new AbortController().signal; + assert.deepEqual(await run('zoom_to_globe', {}), { + ok: true, + action: 'zoom_to_globe', + }); + assert.equal(loads, 0); + assert.deepEqual( + await run('get_wind', { location: { place: 'Oslo' } }, { signal }), + { ok: true, tool: 'get_wind', summary: 'Calm.', data: { calm: true } }, + ); + assert.deepEqual(calls, [ + ['get_wind', { location: { place: 'Oslo' } }, signal], + ]); + await run('not_a_tool', {}); + assert.deepEqual( + actions.map(([name]) => name), + ['zoom_to_globe', 'not_a_tool'], + ); + assert.equal(withToolCatalog(runner, undefined), runner); +}); From ec1adbdc199af39f3e79414132f5fedd5e507c35 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:59:17 -0700 Subject: [PATCH 029/111] fix: tighten tool answers found in local testing Unknown aircraft tracks read as empty instead of a server error, aircraft info names the aircraft and flight, the HUD caption sends the app's caption context, and satellite pass, satellites overhead and nearby places take a place name or point like the other tools. --- docs/TOOLS.md | 6 +-- src/tools/queries/aviation.js | 14 ++++--- src/tools/queries/aviation.test.mjs | 26 ++++++++++++- src/tools/queries/brief.js | 31 ++++++++++++++- src/tools/queries/environment.test.mjs | 3 ++ src/tools/queries/places.js | 10 ++--- src/tools/queries/places.test.mjs | 5 +-- src/tools/queries/space.js | 52 +++++++++++++------------- src/tools/queries/space.test.mjs | 36 +++++++++--------- 9 files changed, 119 insertions(+), 64 deletions(-) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 3484a1c11..45a4f22e8 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -140,14 +140,14 @@ public feeds the sources already use. | `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type | | `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name | | `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points | -| `next_satellite_pass` | `satellites` | Next pass over a point (default the ISS), with naked-eye visibility | -| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a point now, highest first | +| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility | +| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a place or point now, highest first | | `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first | | `get_cctv_snapshot` | `cctv` | The current image from one camera, returned as image content | | `find_alpr_cameras` | `alpr` | OpenStreetMap-mapped license plate readers in a US/Canadian area up to 3° | | `find_radio_stations` | `radio` | Radio Browser stations by area and/or search terms, with stream URLs | | `search_places` | `placeSearch` | Points of interest matching a query within an area (Google Places) | -| `places_nearby` | `placeSearch` | Notable places around a point (Google Places) | +| `places_nearby` | `placeSearch` | Notable places around a place or point (Google Places) | | `plan_route` | `routing` | Walking, driving or cycling route over OpenStreetMap, with a simplified path | | `get_bike_share` | `bikeshare` | Live GBFS stations in an area, with bikes and docks available | | `get_transit_vehicles` | `transit` | Live GTFS-Realtime vehicle positions in an area, optionally one route | diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 110024841..f5bc416ba 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -200,9 +200,13 @@ export const getAircraftTrack = defineTool({ requires: ['aircraft'], async run(args, { services, signal }) { const icao24 = args.icao24.toLowerCase(); - const track = await feedFor(services, args.military).getTrack(icao24, { - signal, - }); + // A track server answers 404 for an aircraft it has no positions for. + const track = await feedFor(services, args.military) + .getTrack(icao24, { signal }) + .catch((error) => { + if (error?.status === 404) return { records: [] }; + throw error; + }); const points = [...track.records].sort( (a, b) => a.observedAtMs - b.observedAtMs, ); @@ -284,13 +288,13 @@ export const getAircraftInfo = defineTool({ if (icao24) parts.push( aircraft - ? `${icao24} is ${aircraft.type_name || aircraft.type_code || 'an aircraft of unknown type'}${aircraft.registration ? ` (${aircraft.registration})` : ''}` + ? `aircraft ${icao24} is ${aircraft.type_name || aircraft.type_code || 'an aircraft of unknown type'}${aircraft.registration ? ` (${aircraft.registration})` : ''}` : `no type is known for ${icao24}`, ); if (callsign) parts.push( flight - ? `${callsign} flies ${flight.origin?.code || flight.origin?.name} to ${flight.destination?.code || flight.destination?.name}${flight.airline ? ` (${flight.airline})` : ''}` + ? `flight ${callsign} flies ${flight.origin?.code || flight.origin?.name} to ${flight.destination?.code || flight.destination?.name}${flight.airline ? ` (${flight.airline})` : ''}` : `no route is known for ${callsign}`, ); const summary = parts.join('; '); diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 671226e0d..811a066c4 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -215,6 +215,30 @@ test('tracks are ordered and thinned to 200 points', async () => { (await empty.call('get_aircraft_track', { icao24: 'aaa111' })).summary, 'No recent track is available for aaa111.', ); + const failing = (status) => ({ + getSnapshot: async () => snapshot([]), + getTrack: async () => { + throw new LiveSourceError('unavailable', `OpenSky HTTP ${status}`, { + status, + }); + }, + }); + const unknown = composeCatalog({ + tools: coreTools, + services: { aircraft: failing(404) }, + }); + assert.equal( + (await unknown.call('get_aircraft_track', { icao24: 'abcdef' })).summary, + 'No recent track is available for abcdef.', + ); + const down = composeCatalog({ + tools: coreTools, + services: { aircraft: failing(502) }, + }); + await assert.rejects( + down.call('get_aircraft_track', { icao24: 'abcdef' }), + (error) => error.code === 'unavailable', + ); }); test('aircraft info combines type and route lookups', async () => { @@ -241,7 +265,7 @@ test('aircraft info combines type and route lookups', async () => { }); assert.equal( both.summary, - 'Aaa111 is Boeing 737-800 (N12345); UAL123 flies SFO to EWR (United Airlines).', + 'Aircraft aaa111 is Boeing 737-800 (N12345); flight UAL123 flies SFO to EWR (United Airlines).', ); assert.equal(both.data.route.destination.name, 'Newark'); const unknown = await catalog.call('get_aircraft_info', { callsign: 'XYZ9' }); diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index 44dcbaf38..917f8d15b 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -25,6 +25,7 @@ const AWARENESS_RADIUS_KM = 250; const SECTIONS = [ { key: 'weather', + label: 'Weather', tool: getWeather, args: (area, center) => ({ location: { lat: center.lat, lon: center.lon }, @@ -32,26 +33,31 @@ const SECTIONS = [ }, { key: 'earthquakes', + label: 'Earthquakes', tool: getEarthquakes, args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'fires', + label: 'Active fires', tool: getActiveFires, args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'aircraft', + label: 'Aircraft', tool: aircraftInArea, args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'vessels', + label: 'Ships', tool: vesselsInArea, args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'cyclones', + label: 'Tropical cyclones', tool: getCyclones, args: (area) => ({ area: area.argument }), }, @@ -181,9 +187,30 @@ export const getHudCaption = defineTool({ }, requires: ['weather', 'summary'], async run(args, context) { - const { area, center, lines } = await buildBrief(args, context); + const { area, center, brief, lines } = await buildBrief(args, context); + // The app's caption context: place labels and the layers that answered, + // with their feed state. + const answered = SECTIONS.filter( + ({ key }) => brief[key] && !brief[key].unavailable, + ); + const layers = answered.map(({ key, label }) => ({ + id: key, + name: label, + feedState: 'nominal', + })); const response = await context.services.summary.summarize( - { location: area.label, center, observations: lines }, + { + placeLabels: [area.label], + location: area.label, + center, + observations: lines, + enabledLayerLabels: layers.map((layer) => layer.name), + enabledLayers: layers, + feedProvenance: { + overall: layers.length ? 'nominal' : null, + layers, + }, + }, { signal: context.signal }, ); const caption = response?.data?.summary; diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index bd4f48bd3..3635ff207 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -438,6 +438,9 @@ test('the HUD caption summarizes the brief through the summary service', async ( ); assert.equal(result.summary, 'Overcast skies over Austin'); assert.equal(sent[0].location, 'Austin, Texas'); + assert.deepEqual(sent[0].placeLabels, ['Austin, Texas']); + assert.deepEqual(sent[0].enabledLayerLabels, ['Weather']); + assert.equal(sent[0].feedProvenance.overall, 'nominal'); assert.match(sent[0].observations[0], /^Weather at /); const failing = { summarize: async () => ({ diff --git a/src/tools/queries/places.js b/src/tools/queries/places.js index 1e82237e1..c6532832b 100644 --- a/src/tools/queries/places.js +++ b/src/tools/queries/places.js @@ -82,25 +82,25 @@ export const placesNearby = defineTool({ inputSchema: { type: 'object', properties: { - lat: { type: 'number', minimum: -90, maximum: 90 }, - lon: { type: 'number', minimum: -180, maximum: 180 }, + location: POINT_SCHEMA, radius_m: { type: 'number', minimum: 10, maximum: 5000 }, limit: LIMIT_SCHEMA, }, - required: ['lat', 'lon'], + required: ['location'], additionalProperties: false, }, requires: ['placeSearch'], async run(args, { services, signal }) { + const point = await resolvePoint(args.location, { services, signal }); const radiusM = Math.round(args.radius_m ?? 250); const result = await services.placeSearch.nearby( - { latitude: args.lat, longitude: args.lon, radiusM }, + { latitude: point.lat, longitude: point.lon, radiusM }, { signal }, ); if (!result.configured) throw new ToolError('unavailable', NOT_CONFIGURED); const rows = result.places.map(placeRow); return { - summary: `${countNoun(rows.length, 'place')} within ${radiusM} m.`, + summary: `${countNoun(rows.length, 'place')} within ${radiusM} m of ${point.label}.`, data: capRows(rows, args.limit), }; }, diff --git a/src/tools/queries/places.test.mjs b/src/tools/queries/places.test.mjs index 29755d517..ab4a0a0e2 100644 --- a/src/tools/queries/places.test.mjs +++ b/src/tools/queries/places.test.mjs @@ -93,11 +93,10 @@ test('nearby places use the given radius', async () => { services: { placeSearch: search }, }); const result = await catalog.call('places_nearby', { - lat: 30.27, - lon: -97.74, + location: { lat: 30.27, lon: -97.74 }, radius_m: 400, }); - assert.equal(result.summary, '1 place within 400 m.'); + assert.equal(result.summary, '1 place within 400 m of 30.2700, -97.7400.'); assert.deepEqual(search.calls[0], { latitude: 30.27, longitude: -97.74, diff --git a/src/tools/queries/space.js b/src/tools/queries/space.js index 298ec104a..d02796429 100644 --- a/src/tools/queries/space.js +++ b/src/tools/queries/space.js @@ -6,6 +6,7 @@ import { lookAnglesAt, } from '../../data/satellitePass.js'; import { parseTleText, tleCatalogNumber } from '../../sources/tle.js'; +import { POINT_SCHEMA, resolvePoint } from '../area.js'; import { defineTool, ToolError } from '../catalog.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; @@ -83,20 +84,6 @@ const COMPASS = [ 'NW', 'NNW', ]; -const OBSERVER = { - lat: { - type: 'number', - minimum: -90, - maximum: 90, - description: 'Observer latitude.', - }, - lon: { - type: 'number', - minimum: -180, - maximum: 180, - description: 'Observer longitude.', - }, -}; const GROUP_SCHEMA = { type: 'string', enum: GROUPS, @@ -154,7 +141,7 @@ export const nextSatellitePass = defineTool({ inputSchema: { type: 'object', properties: { - ...OBSERVER, + location: POINT_SCHEMA, satellite: { type: 'string', minLength: 1, @@ -170,11 +157,12 @@ export const nextSatellitePass = defineTool({ }, hours: { type: 'number', minimum: 1, maximum: 72 }, }, - required: ['lat', 'lon'], + required: ['location'], additionalProperties: false, }, requires: ['satellites'], async run(args, { services, signal }) { + const point = await resolvePoint(args.location, { services, signal }); const group = args.group ?? 'stations'; const entries = await readCatalog(services, group, signal); const satellite = matchSatellite(entries, args.satellite); @@ -186,8 +174,8 @@ export const nextSatellitePass = defineTool({ const hours = args.hours ?? 24; const pass = findNextSatellitePass({ satrec: satellite.satrec, - latDeg: args.lat, - lonDeg: args.lon, + latDeg: point.lat, + lonDeg: point.lon, fromMs: nowMs(services), minElevDeg: args.min_elevation_deg ?? 10, horizonHours: hours, @@ -196,15 +184,21 @@ export const nextSatellitePass = defineTool({ const name = satellite.name; if (!pass) return { - summary: `${name} has no ${args.visible_only ? 'visible ' : ''}pass over this location in the next ${hours} hours.`, - data: { satellite: name, norad: satellite.norad, pass: null }, + summary: `${name} has no ${args.visible_only ? 'visible ' : ''}pass over ${point.label} in the next ${hours} hours.`, + data: { + location: point, + satellite: name, + norad: satellite.norad, + pass: null, + }, }; return { summary: - `${name} next rises ${isoTime(pass.riseMs)} in the ${compass(pass.riseAzDeg)}, ` + + `${name} next rises over ${point.label} at ${isoTime(pass.riseMs)} in the ${compass(pass.riseAzDeg)}, ` + `peaking at ${Math.round(pass.maxElevDeg)}°` + `${pass.visible ? '; visible to the naked eye' : ''}.`, data: { + location: point, satellite: name, norad: satellite.norad, pass: { @@ -230,22 +224,23 @@ export const satellitesOverhead = defineTool({ inputSchema: { type: 'object', properties: { - ...OBSERVER, + location: POINT_SCHEMA, group: GROUP_SCHEMA, min_elevation_deg: { type: 'number', minimum: 0, maximum: 90 }, limit: LIMIT_SCHEMA, }, - required: ['lat', 'lon'], + required: ['location'], additionalProperties: false, }, requires: ['satellites'], async run(args, { services, signal }) { + const point = await resolvePoint(args.location, { services, signal }); const group = args.group ?? 'stations'; const minimum = args.min_elevation_deg ?? 10; const at = nowMs(services); const rows = (await readCatalog(services, group, signal)) .flatMap((entry) => { - const look = lookAnglesAt(entry.satrec, at, args.lat, args.lon); + const look = lookAnglesAt(entry.satrec, at, point.lat, point.lon); return look && look.elevDeg >= minimum ? [ { @@ -260,8 +255,13 @@ export const satellitesOverhead = defineTool({ }) .sort((a, b) => b.elevation_deg - a.elevation_deg); return { - summary: `${countNoun(rows.length, 'satellite')} from the ${group} group ${rows.length === 1 ? 'is' : 'are'} at least ${minimum}° above this location at ${isoTime(at)}.`, - data: { ...capRows(rows, args.limit), group, at: isoTime(at) }, + summary: `${countNoun(rows.length, 'satellite')} from the ${group} group ${rows.length === 1 ? 'is' : 'are'} at least ${minimum}° above ${point.label} at ${isoTime(at)}.`, + data: { + ...capRows(rows, args.limit), + location: point, + group, + at: isoTime(at), + }, }; }, }); diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 460da8319..8f77fb041 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -36,8 +36,7 @@ test('the next pass defaults to the ISS and reports times, peak and direction', services: { satellites: satellites(), clock: CLOCK }, }); const result = await catalog.call('next_satellite_pass', { - lat: 40.7, - lon: -74, + location: { lat: 40.7, lon: -74 }, }); const { pass } = result.data; assert.equal(result.data.satellite, 'ISS (ZARYA)'); @@ -50,7 +49,7 @@ test('the next pass defaults to the ISS and reports times, peak and direction', assert.match( result.summary, new RegExp( - `^ISS \\(ZARYA\\) next rises ${pass.rise} in the ${pass.rise_direction}, peaking at \\d+°`, + `^ISS \\(ZARYA\\) next rises over 40.7000, -74.0000 at ${pass.rise} in the ${pass.rise_direction}, peaking at \\d+°`, ), ); }); @@ -62,14 +61,12 @@ test('satellites are matched by name or catalog number within the group', async services: { satellites: source, clock: CLOCK }, }); const byName = await catalog.call('next_satellite_pass', { - lat: 0, - lon: 0, + location: { lat: 0, lon: 0 }, satellite: 'tianhe', }); assert.equal(byName.data.norad, 48274); const byNumber = await catalog.call('next_satellite_pass', { - lat: 0, - lon: 0, + location: { lat: 0, lon: 0 }, satellite: '48274', group: 'visual', }); @@ -77,14 +74,16 @@ test('satellites are matched by name or catalog number within the group', async assert.deepEqual(source.calls, ['stations', 'visual']); await assert.rejects( catalog.call('next_satellite_pass', { - lat: 0, - lon: 0, + location: { lat: 0, lon: 0 }, satellite: 'Hubble', }), /No satellite matching "Hubble" is in the stations group/, ); await assert.rejects( - catalog.call('next_satellite_pass', { lat: 0, lon: 0, group: 'weather' }), + catalog.call('next_satellite_pass', { + location: { lat: 0, lon: 0 }, + group: 'weather', + }), /must be one of/, ); }); @@ -96,21 +95,20 @@ test('a pass that never comes is a normal answer, and catalog failures are unava }); // The ISS orbit is inclined 51.6°, so it never rises 10° above the pole. const none = await catalog.call('next_satellite_pass', { - lat: 89.9, - lon: 0, + location: { lat: 89.9, lon: 0 }, hours: 6, }); assert.equal(none.data.pass, null); assert.equal( none.summary, - 'ISS (ZARYA) has no pass over this location in the next 6 hours.', + 'ISS (ZARYA) has no pass over 89.9000, 0.0000 in the next 6 hours.', ); const down = composeCatalog({ tools: coreTools, services: { satellites: satellites('', false), clock: CLOCK }, }); await assert.rejects( - down.call('next_satellite_pass', { lat: 0, lon: 0 }), + down.call('next_satellite_pass', { location: { lat: 0, lon: 0 } }), (error) => error.code === 'unavailable' && /HTTP 503/.test(error.message), ); }); @@ -120,7 +118,9 @@ test('satellites overhead are those above the elevation, highest first', async ( const services = { satellites: source, clock: CLOCK }; const catalog = composeCatalog({ tools: coreTools, services }); const pass = ( - await catalog.call('next_satellite_pass', { lat: 40.7, lon: -74 }) + await catalog.call('next_satellite_pass', { + location: { lat: 40.7, lon: -74 }, + }) ).data.pass; const atPeak = { satellites: source, @@ -130,8 +130,7 @@ test('satellites overhead are those above the elevation, highest first', async ( tools: coreTools, services: atPeak, }).call('satellites_overhead', { - lat: 40.7, - lon: -74, + location: { lat: 40.7, lon: -74 }, }); assert.equal(overhead.data.rows[0].name, 'ISS (ZARYA)'); assert.ok( @@ -142,8 +141,7 @@ test('satellites overhead are those above the elevation, highest first', async ( tools: coreTools, services: atPeak, }).call('satellites_overhead', { - lat: 40.7, - lon: -74, + location: { lat: 40.7, lon: -74 }, min_elevation_deg: 90, }); assert.equal(strict.data.total, 0); From b4067f98595ab7eb4edd6e181396fab251b9e708 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 14:23:44 -0700 Subject: [PATCH 030/111] fix: time-limit OpenSky snapshot requests and retry once A stalled global snapshot held the request for over a minute. Each attempt now gets 10 seconds, a timed-out or failed attempt is retried once, and a second failure is answered from the stale cache or the regional fallback. The OAuth token request gets the same limit. --- server/providers/aircraft/opensky.js | 42 +++++++++++++++++----- src/tooling/liveProviders.test.mjs | 54 ++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 612d01e11..db4920668 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -25,6 +25,36 @@ let _openskyCacheMeta = null; let _openskyCacheSourceEpochMs = null; /** TTL for the OpenSky response cache (ms). */ const OPENSKY_CACHE_MS = 9000; +/** Per-attempt limit for the global snapshot; it normally arrives in ~1.5 s. */ +const OPENSKY_ATTEMPT_TIMEOUT_MS = 10_000; +const OPENSKY_STATES_URL = + 'https://opensky-network.org/api/states/all?extended=1'; + +/** + * Fetch and read the global snapshot, giving each attempt + * OPENSKY_ATTEMPT_TIMEOUT_MS and retrying once when an attempt times out or + * the connection fails. A second failure throws, which the proxy answers + * from its stale cache or the regional fallback. + * @param {Record} headers Request headers. + * @returns {Promise<{upstream: Response, body: string}>} + */ +async function fetchOpenSkyStates(headers) { + for (let attempt = 1; ; attempt++) { + try { + const upstream = await fetch(OPENSKY_STATES_URL, { + headers, + signal: AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS), + }); + return { upstream, body: await upstream.text() }; + } catch (error) { + if (attempt >= 2) throw error; + console.warn( + '[OpenSky] global snapshot attempt failed, retrying:', + error?.message || error, + ); + } + } +} // --- OpenSky credit governor (field-test fix 2026-07-06) ------------------- // The global /states/all this proxy fetches costs 4 CREDITS per call against // OpenSky's ~4000/day authenticated budget — a day with the app open burned @@ -107,6 +137,7 @@ export async function getOpenSkyToken() { { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + signal: AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS), body: `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`, }, ); @@ -470,10 +501,7 @@ export function openSkyProxy() { } } - let upstream = await fetch( - 'https://opensky-network.org/api/states/all?extended=1', - { headers }, - ); + let { upstream, body } = await fetchOpenSkyStates(headers); // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials if ( (upstream.status === 401 || upstream.status === 403) && @@ -485,15 +513,11 @@ export function openSkyProxy() { Accept: 'application/json', Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`, }; - upstream = await fetch( - 'https://opensky-network.org/api/states/all?extended=1', - { headers: retryHeaders }, - ); + ({ upstream, body } = await fetchOpenSkyStates(retryHeaders)); usedMode = 'basic'; reason = 'oauth_rejected_fallback_basic'; } - let body = await upstream.text(); const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null; if ( upstream.ok && diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index 38953eff9..ae136b994 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -139,6 +139,60 @@ test('OpenSky state and track routes share tokens, retain cache and use regional assert.equal(JSON.parse(fallback.body).states[0][0], 'abc123'); }); +test('OpenSky global snapshot attempts are time-limited, retried once, then fall back', async (t) => { + environment(t, { + OPENSKY_CLIENT_ID: undefined, + OPENSKY_CLIENT_SECRET: undefined, + OPENSKY_AUTH_MODE: 'anon', + OPENSKY_USERNAME: undefined, + OPENSKY_PASSWORD: undefined, + }); + t.mock.method(console, 'warn', () => {}); + const timedOut = () => + Promise.reject( + new DOMException( + 'The operation was aborted due to timeout', + 'TimeoutError', + ), + ); + const load = async (respond) => { + const attempts = []; + t.mock.method(globalThis, 'fetch', async (url, options) => { + if (url.includes('/states/')) { + assert.ok(options.signal instanceof AbortSignal); + attempts.push(url); + return respond(attempts.length); + } + if (url.includes('/lat/')) + return Response.json({ + now: Date.now() / 1000, + ac: [{ hex: 'abc123', lat: 30, lon: -97, alt_baro: 10000 }], + }); + throw Error(`Unexpected URL: ${url}`); + }); + const fresh = await import( + `../../server/providers/aircraft/opensky.js?timeout=${Math.random()}` + ); + const response = await install(fresh.openSkyProxy())( + '/api/opensky', + '?lat=30&lon=-97', + ); + return { response, attempts }; + }; + const recovered = await load((attempt) => + attempt === 1 + ? timedOut() + : Response.json({ time: Math.floor(Date.now() / 1000), states: [] }), + ); + assert.equal(recovered.attempts.length, 2); + assert.equal(recovered.response.statusCode, 200); + assert.equal(recovered.response.headers['x-flight-source'], undefined); + const stalled = await load(() => timedOut()); + assert.equal(stalled.attempts.length, 2); + assert.equal(stalled.response.statusCode, 200); + assert.equal(stalled.response.headers['x-flight-source'], 'adsb.lol'); +}); + test('military aircraft route preserves fresh cache and stale response after upstream failure', async (t) => { let now = Date.now(); let calls = 0; From df0e0f32e81940235c0a2848c7dd884c0ae04f2a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 14:36:12 -0700 Subject: [PATCH 031/111] feat: choose per tool whether MCP, voice or both offer it src/tools/surfaces.js lists the tools a surface leaves out, and the MCP server, the voice session's tool list and the browser catalog voice calls all filter through it. Voice leaves out tools that answer with images, link to the app, or repeat what its app actions already answer. --- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 3 +- server/mcp/server.js | 8 +++-- server/standalone/voiceTools.js | 14 ++++---- src/standalone/toolCatalog.js | 18 ++++++---- src/tooling/localServices.test.mjs | 2 ++ src/tools/index.js | 1 + src/tools/surfaces.js | 56 ++++++++++++++++++++++++++++++ src/tools/surfaces.test.mjs | 50 ++++++++++++++++++++++++++ 9 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 src/tools/surfaces.js create mode 100644 src/tools/surfaces.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 69312e248..9fd4c4a4c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -886,5 +886,6 @@ "src/tools/queries/surveillance.test.mjs", "src/tools/queries/infrastructure.test.mjs", "src/tools/queries/events.test.mjs", - "src/tools/functions.test.mjs" + "src/tools/functions.test.mjs", + "src/tools/surfaces.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9b14f6392..03a648a71 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3122,7 +3122,8 @@ "src/tools/queries/infrastructure.js", "src/tools/queries/events.js", "src/data/bhoteKoshiFloodPath.js", - "src/tools/functions.js" + "src/tools/functions.js", + "src/tools/surfaces.js" ], "external": ["satellite.js"] }, diff --git a/server/mcp/server.js b/server/mcp/server.js index daacd3d3c..3cf75ee0f 100644 --- a/server/mcp/server.js +++ b/server/mcp/server.js @@ -1,7 +1,11 @@ /** The local MCP server: Core's tools over services backed by a running app. */ import { readFileSync } from 'node:fs'; -import { composeCatalog, coreTools } from '../../src/tools/index.js'; +import { + composeCatalog, + coreTools, + toolsForSurface, +} from '../../src/tools/index.js'; import { createMcpServer } from '../../src/tools/mcp/index.js'; import { DEFAULT_API_BASE, createLocalToolServices } from './services.js'; @@ -20,7 +24,7 @@ export function createLocalMcpServer({ ); return createMcpServer({ catalog: composeCatalog({ - tools: coreTools, + tools: toolsForSurface(coreTools, 'mcp'), services: createLocalToolServices({ apiBase, fetchImpl }), }), name: 'gods-eye-view', diff --git a/server/standalone/voiceTools.js b/server/standalone/voiceTools.js index 9e7c26dce..35dc47440 100644 --- a/server/standalone/voiceTools.js +++ b/server/standalone/voiceTools.js @@ -1,16 +1,18 @@ /** * The voice session's tools for the standalone server: the app actions plus - * Core's catalog queries, which the browser runs through the same catalog. - * Links to the app are left out, since voice runs inside it. + * the catalog tools voice offers (see src/tools/surfaces.js), which the + * browser runs through the same catalog. */ -import { coreTools, toFunctionTools } from '../../src/tools/index.js'; +import { + coreTools, + toFunctionTools, + toolsForSurface, +} from '../../src/tools/index.js'; import { realtimeSessionTools } from '../providers/openai/tools.js'; -export const VOICE_EXCLUDED_QUERIES = Object.freeze(['open_in_gods_eye_view']); - export function standaloneVoiceTools() { return realtimeSessionTools( - toFunctionTools(coreTools, { exclude: VOICE_EXCLUDED_QUERIES }), + toFunctionTools(toolsForSurface(coreTools, 'voice')), ); } diff --git a/src/standalone/toolCatalog.js b/src/standalone/toolCatalog.js index b8c550427..ef1aa709a 100644 --- a/src/standalone/toolCatalog.js +++ b/src/standalone/toolCatalog.js @@ -6,14 +6,18 @@ export function loadToolCatalog() { pending ??= Promise.all([ import('../tools/index.js'), import('../tools/services.js'), - ]).then(([{ composeCatalog, coreTools }, { createToolServices }]) => - composeCatalog({ - tools: coreTools, - services: createToolServices({ - fetchImpl: (...args) => globalThis.fetch(...args), - appUrl: globalThis.location.origin, + ]).then( + ([ + { composeCatalog, coreTools, toolsForSurface }, + { createToolServices }, + ]) => + composeCatalog({ + tools: toolsForSurface(coreTools, 'voice'), + services: createToolServices({ + fetchImpl: (...args) => globalThis.fetch(...args), + appUrl: globalThis.location.origin, + }), }), - }), ); pending.catch(() => { pending = null; diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index e8ff998a0..d6d6d4325 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -221,6 +221,8 @@ 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('aircraft_in_area')); + assert.ok(!names.includes('get_weather_map')); // The action of the same name answers satellite passes. assert.equal( tools.findLast((tool) => tool.name === 'next_satellite_pass'), diff --git a/src/tools/index.js b/src/tools/index.js index 0ba01004b..66aa90bac 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -68,6 +68,7 @@ export { } from './area.js'; export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js'; export { toFunctionOutput, toFunctionTools } from './functions.js'; +export { SURFACES, TOOL_SURFACES, toolsForSurface } from './surfaces.js'; export { createGeocodePlaceService, createPlaceSearchService, diff --git a/src/tools/surfaces.js b/src/tools/surfaces.js new file mode 100644 index 000000000..45c18f015 --- /dev/null +++ b/src/tools/surfaces.js @@ -0,0 +1,56 @@ +/** + * Which surfaces offer each tool. A tool is on every surface unless this + * table turns it off; edit an entry to turn a tool on or off for MCP, voice, + * or both. Applications can pass overrides when they compose a surface. + */ + +export const SURFACES = Object.freeze(['mcp', 'voice']); + +const voiceOff = Object.freeze({ voice: 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, + // 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. + get_weather_map: voiceOff, + get_recent_imagery: voiceOff, + get_cctv_snapshot: voiceOff, + // Voice shows the HUD caption in the app itself. + get_hud_caption: voiceOff, + // Voice answers these from the loaded layers with analyst_query. + aircraft_in_area: voiceOff, + vessels_in_area: voiceOff, + find_aircraft: voiceOff, + find_vessel: voiceOff, + get_earthquakes: voiceOff, + get_active_fires: voiceOff, + find_infrastructure: voiceOff, + satellites_overhead: voiceOff, +}); + +/** + * The tools a surface offers, in their original order. `overrides` uses the + * table's shape and wins over it, such as `{ get_weather_map: { voice: true } }`. + * Unknown tool names or surfaces throw, so a typo cannot silently do nothing. + */ +export function toolsForSurface(tools, surface, overrides = {}) { + if (!SURFACES.includes(surface)) + throw new TypeError(`Unknown tool surface: ${surface}`); + const names = new Set(tools.map((tool) => tool.name)); + for (const table of [TOOL_SURFACES, overrides]) { + for (const [name, entry] of Object.entries(table)) { + if (!names.has(name)) throw new TypeError(`Unknown tool: ${name}`); + for (const key of Object.keys(entry)) { + if (!SURFACES.includes(key)) + throw new TypeError(`Unknown tool surface for ${name}: ${key}`); + } + } + } + return tools.filter( + (tool) => + (overrides[tool.name]?.[surface] ?? + TOOL_SURFACES[tool.name]?.[surface]) !== false, + ); +} diff --git a/src/tools/surfaces.test.mjs b/src/tools/surfaces.test.mjs new file mode 100644 index 000000000..4305783f9 --- /dev/null +++ b/src/tools/surfaces.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { TOOL_SURFACES, coreTools, toolsForSurface } from './index.js'; + +const names = (tools) => tools.map((tool) => tool.name); + +test('MCP offers every tool and voice leaves out the table entries', () => { + assert.deepEqual(names(toolsForSurface(coreTools, 'mcp')), names(coreTools)); + const voice = names(toolsForSurface(coreTools, 'voice')); + assert.equal( + voice.length, + coreTools.length - Object.keys(TOOL_SURFACES).length, + ); + for (const name of Object.keys(TOOL_SURFACES)) + assert.ok(!voice.includes(name), name); + assert.ok(voice.includes('get_weather')); + assert.ok(voice.includes('military_awareness')); +}); + +test('overrides turn a tool on or off for one surface', () => { + const voice = names( + toolsForSurface(coreTools, 'voice', { + get_weather_map: { voice: true }, + get_weather: { voice: false }, + }), + ); + assert.ok(voice.includes('get_weather_map')); + assert.ok(!voice.includes('get_weather')); + assert.ok( + !names( + toolsForSurface(coreTools, 'mcp', { get_weather: { mcp: false } }), + ).includes('get_weather'), + ); +}); + +test('unknown surfaces and tool names are rejected', () => { + assert.throws( + () => toolsForSurface(coreTools, 'email'), + /Unknown tool surface/, + ); + assert.throws( + () => toolsForSurface(coreTools, 'voice', { get_wether: { voice: false } }), + /Unknown tool: get_wether/, + ); + assert.throws( + () => + toolsForSurface(coreTools, 'voice', { get_weather: { voise: false } }), + /Unknown tool surface for get_weather: voise/, + ); +}); From 2e071b62d324e1a0e4d242812131989e4206b650 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 14:38:10 -0700 Subject: [PATCH 032/111] fix: repeat MCP tool data as JSON text for clients without structured content --- docs/TOOLS.md | 3 ++- src/tools/mcp/mcp.test.mjs | 5 ++++- src/tools/mcp/protocol.js | 3 +++ src/tools/queries/media.test.mjs | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 45a4f22e8..ed9f8c280 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -32,7 +32,8 @@ checks completely; unsupported keywords are rejected at definition time. `run(args, { services, signal })` resolves to `{ summary, data }`: one sentence for people and a structured object for programs. A tool may also return `images`, each `{ mimeType, data }` with base64 data; the MCP adapter sends them as image -content. +content. MCP results carry the summary and the data as JSON text, plus the data +as `structuredContent`, for clients that read only one of them. `composeCatalog({ tools, services, replace, interceptors })` builds a catalog: diff --git a/src/tools/mcp/mcp.test.mjs b/src/tools/mcp/mcp.test.mjs index 61a2ff63f..54269ab2e 100644 --- a/src/tools/mcp/mcp.test.mjs +++ b/src/tools/mcp/mcp.test.mjs @@ -125,7 +125,10 @@ test('tool results carry text and structured content; failures stay generic', as (await request('tools/call', { name: 'count', arguments: { n: 3 } })) .result, { - content: [{ type: 'text', text: '3 things.' }], + content: [ + { type: 'text', text: '3 things.' }, + { type: 'text', text: '{"n":3}' }, + ], structuredContent: { n: 3 }, isError: false, }, diff --git a/src/tools/mcp/protocol.js b/src/tools/mcp/protocol.js index d86a0c6b8..44e3e778c 100644 --- a/src/tools/mcp/protocol.js +++ b/src/tools/mcp/protocol.js @@ -80,8 +80,11 @@ export function createMcpServer({ signal, }); return { + // The data is repeated as JSON text for clients that do not read + // structuredContent. content: [ { type: 'text', text: result.summary }, + { type: 'text', text: JSON.stringify(result.data) }, ...(result.images || []).map((item) => ({ type: 'image', data: item.data, diff --git a/src/tools/queries/media.test.mjs b/src/tools/queries/media.test.mjs index b99e9e781..a1916c111 100644 --- a/src/tools/queries/media.test.mjs +++ b/src/tools/queries/media.test.mjs @@ -97,7 +97,7 @@ test('a snapshot returns the frame as an image with its credit', async () => { method: 'tools/call', params: { name: 'get_cctv_snapshot', arguments: { camera_id: 'austin-1' } }, }); - assert.deepEqual(response.result.content[1], { + assert.deepEqual(response.result.content[2], { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png', From 7ddee426e7265f4de6dd052a0c3df67f572eaaa8 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 14:39:57 -0700 Subject: [PATCH 033/111] fix: count every mapped installation in tool answers The installation source keeps one named site per display cell for the map. Queries now ask for every site and the exact box, and report when the source returned only part of the mapped sites. --- src/data/militaryNames.js | 14 +++++++++++--- src/data/militaryNames.test.mjs | 10 ++++++++++ src/layers/installations/source.js | 22 ++++++++++++++-------- src/tools/queries/environment.js | 14 ++++++++++++-- src/tools/queries/environment.test.mjs | 17 ++++++++++++++++- 5 files changed, 63 insertions(+), 14 deletions(-) diff --git a/src/data/militaryNames.js b/src/data/militaryNames.js index 8e9e9a5c2..3f0371bd9 100644 --- a/src/data/militaryNames.js +++ b/src/data/militaryNames.js @@ -93,8 +93,16 @@ export function nameMilitaryFragment(fragment, names) { }; } -/** Area-ranked spatial thinning of visible label points, including dateline views. */ -export function militaryNamesInView(names, box, cap = MILITARY_POINT_CAP) { +/** + * Area-ranked spatial thinning of visible label points, including dateline + * views. With `thinned` false every point in the box is kept, up to `cap`. + */ +export function militaryNamesInView( + names, + box, + cap = MILITARY_POINT_CAP, + thinned = true, +) { const width = box.east >= box.west ? box.east - box.west : 360 + box.east - box.west; const height = box.north - box.south; @@ -107,7 +115,7 @@ export function militaryNamesInView(names, box, cap = MILITARY_POINT_CAP) { continue; count++; const cell = `${Math.min(31, Math.floor((x / width) * 32))}:${Math.min(15, Math.floor(((record.latitude - box.south) / height) * 16))}`; - if (cells.has(cell) || records.length >= cap) continue; + if ((thinned && cells.has(cell)) || records.length >= cap) continue; cells.add(cell); records.push(record); } diff --git a/src/data/militaryNames.test.mjs b/src/data/militaryNames.test.mjs index bdc4449c8..7e52b2c60 100644 --- a/src/data/militaryNames.test.mjs +++ b/src/data/militaryNames.test.mjs @@ -217,3 +217,13 @@ test('a close view keeps the parent name inside its polygon when the usual label hole, ); }); + +test('queries can take every named site in a box instead of one per display cell', async () => { + const names = await loadMilitaryNames(); + const box = { west: -2, south: 50, east: 2, north: 54 }; + const thinned = militaryNamesInView(names, box); + const all = militaryNamesInView(names, box, Infinity, false); + assert.equal(all.count, thinned.count); + assert.equal(all.records.length, all.count); + assert.ok(all.records.length > thinned.records.length); +}); diff --git a/src/layers/installations/source.js b/src/layers/installations/source.js index 7cad3fca0..fed73e87a 100644 --- a/src/layers/installations/source.js +++ b/src/layers/installations/source.js @@ -101,7 +101,7 @@ export function createInstallationSource({ } = {}) { let overpassUnavailable = false; const installationIds = new Map(); - async function getNamedSites(box, signal) { + async function getNamedSites(box, signal, thinned = true) { const names = await waitForSignal(loadNames(), signal).catch((cause) => { signal?.throwIfAborted(); throw Object.assign( @@ -112,7 +112,13 @@ export function createInstallationSource({ ); }); signal?.throwIfAborted(); - const { records, count } = militaryNamesInView(names, box); + // The map keeps one site per display cell; queries can ask for all. + const { records, count } = militaryNamesInView( + names, + box, + thinned ? undefined : Infinity, + thinned, + ); return { records, namedInView: count, @@ -124,9 +130,9 @@ export function createInstallationSource({ source: 'OpenStreetMap', }; } - async function getTileSites(box, signal) { + async function getTileSites(box, signal, thinned) { const zoom = installationTileZoom(box); - if (zoom === null) return getNamedSites(box, signal); + if (zoom === null) return getNamedSites(box, signal, thinned); let names = null; const namesJob = waitForSignal(loadNames(), signal) .then((loaded) => { @@ -181,7 +187,7 @@ export function createInstallationSource({ mapTiles.clear(); installationIds.clear(); }, - async getMappedSites(box, { exact = false, signal } = {}) { + async getMappedSites(box, { exact = false, thinned = true, signal } = {}) { const { south, west, north, east } = box || {}; if ( ![south, west, north, east].every(Number.isFinite) || @@ -197,8 +203,8 @@ export function createInstallationSource({ throw new TypeError('A bounded installation viewport is required'); signal?.throwIfAborted(); if (east < west || north - south > 10 || east - west > 10) - return getNamedSites(box, signal); - if (overpassUnavailable) return getTileSites(box, signal); + return getNamedSites(box, signal, thinned); + if (overpassUnavailable) return getTileSites(box, signal, thinned); const query = new URLSearchParams( Object.entries({ south, west, north, east }).map(([key, value]) => [ key, @@ -213,7 +219,7 @@ export function createInstallationSource({ signal?.throwIfAborted(); if (isUnavailableCapability(body)) { overpassUnavailable = true; - return getTileSites(box, signal); + return getTileSites(box, signal, thinned); } if (!response.ok) throw Object.assign( diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index 8e053f2e4..7def1f87d 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -355,8 +355,12 @@ export const findMilitaryInstallations = defineTool({ ); const center = areaCenter(area); const result = await services.installations.getMappedSites(area, { + exact: true, + thinned: false, signal, }); + // A saturated source returned only part of the mapped sites. + const complete = !result.saturated; const rows = (result.records || []) .map((site) => ({ site, @@ -374,8 +378,14 @@ export const findMilitaryInstallations = defineTool({ })) .sort((a, b) => a.distance_km - b.distance_km); return { - summary: `${countNoun(rows.length, 'mapped military installation')} in ${area.label}.`, - data: { ...capRows(rows, args.limit), source: result.source ?? null }, + summary: + `${countNoun(rows.length, 'mapped military installation')} in ${area.label}` + + (complete ? '.' : ' (partial: the source returned only some sites).'), + data: { + ...capRows(rows, args.limit), + complete, + source: result.source ?? null, + }, }; }, }); diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index 3635ff207..40fdd4793 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -263,10 +263,14 @@ test('terrain heights are returned in request order', async () => { test('military installations need a bounded area and are nearest first', async () => { const requested = []; + const options = []; + let saturated = false; const installations = { - async getMappedSites(box) { + async getMappedSites(box, { signal, ...rest } = {}) { requested.push(box); + options.push(rest); return { + saturated, source: 'OpenStreetMap', records: [ { @@ -302,7 +306,18 @@ test('military installations need a bounded area and are nearest first', async ( ['osm:2', 'osm:1'], ); assert.equal(result.data.source, 'OpenStreetMap'); + assert.equal(result.data.complete, true); assert.ok(requested[0].north - requested[0].south < 1); + assert.deepEqual(options[0], { exact: true, thinned: false }); + saturated = true; + const partial = await catalog.call('find_military_installations', { + area: { lat: 32.7, lon: -117.2, radius_km: 20 }, + }); + assert.equal(partial.data.complete, false); + assert.match( + partial.summary, + /\(partial: the source returned only some sites\)\.$/, + ); await assert.rejects( catalog.call('find_military_installations', { area: { bbox: [-130, 20, -100, 50] }, From 963c791a5cfa504d8794e64c8879d0eb4052103c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 14:45:50 -0700 Subject: [PATCH 034/111] fix: run composite tool sections through the catalog situation_brief, military_awareness and the HUD caption called other tools directly, so replaced tools and interceptors did not apply to their sections. Tools now reach each other through the catalog, and interceptors see these calls with the calling tool as parent. Surfaces become a view over the composed catalog, so a composite offered on voice keeps sections that voice does not list. --- docs/TOOLS.md | 15 +++++++ server/mcp/server.js | 13 ++++--- src/standalone/toolCatalog.js | 17 ++++---- src/tools/catalog.js | 36 +++++++++++------ src/tools/index.js | 7 +++- src/tools/queries/brief.js | 51 ++++++++++-------------- src/tools/queries/environment.test.mjs | 54 +++++++++++++++++++++++++- src/tools/surfaces.js | 42 ++++++++++++++++---- src/tools/surfaces.test.mjs | 49 ++++++++++++++++++++++- 9 files changed, 219 insertions(+), 65 deletions(-) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index ed9f8c280..5797cbad5 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -43,6 +43,21 @@ as `structuredContent`, for clients that read only one of them. whose services are not supplied are left out. - **Interceptors**: `(call, next) => next(call)` functions wrap every call, outermost first. They can observe, reject or change a call. +- **Composite tools**: `run` receives `tools`, with `has(name)` and + `call(name, args)`, to call other tools through the same catalog, so + replaced tools and interceptors apply. Interceptors see such calls with + `parent`, the calling tool's name. + +### Surfaces + +`src/tools/surfaces.js` lists which tools MCP and voice offer. A tool is on +both unless `TOOL_SURFACES` turns it off; edit an entry to turn a tool on or +off for one surface. `catalogForSurface(catalog, surface, overrides)` is the +view a surface exposes: it lists and calls only the tools it offers, while +composite tools still reach the whole catalog. `toolsForSurface` gives the +same selection as a list of definitions, such as for the voice session's tool +list. Voice leaves out tools that answer with images, link to the app, or +repeat what its app actions answer. Expected failures throw `ToolError` with one of `invalid_arguments`, `unavailable`, `unsupported`, `malformed` or `retry_later`. Other errors are diff --git a/server/mcp/server.js b/server/mcp/server.js index 3cf75ee0f..0ddb79795 100644 --- a/server/mcp/server.js +++ b/server/mcp/server.js @@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'; import { composeCatalog, coreTools, - toolsForSurface, + catalogForSurface, } from '../../src/tools/index.js'; import { createMcpServer } from '../../src/tools/mcp/index.js'; import { DEFAULT_API_BASE, createLocalToolServices } from './services.js'; @@ -23,10 +23,13 @@ export function createLocalMcpServer({ readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), ); return createMcpServer({ - catalog: composeCatalog({ - tools: toolsForSurface(coreTools, 'mcp'), - services: createLocalToolServices({ apiBase, fetchImpl }), - }), + catalog: catalogForSurface( + composeCatalog({ + tools: coreTools, + services: createLocalToolServices({ apiBase, fetchImpl }), + }), + 'mcp', + ), name: 'gods-eye-view', version, instructions: INSTRUCTIONS, diff --git a/src/standalone/toolCatalog.js b/src/standalone/toolCatalog.js index ef1aa709a..53a1f6a7b 100644 --- a/src/standalone/toolCatalog.js +++ b/src/standalone/toolCatalog.js @@ -8,16 +8,19 @@ export function loadToolCatalog() { import('../tools/services.js'), ]).then( ([ - { composeCatalog, coreTools, toolsForSurface }, + { composeCatalog, coreTools, catalogForSurface }, { createToolServices }, ]) => - composeCatalog({ - tools: toolsForSurface(coreTools, 'voice'), - services: createToolServices({ - fetchImpl: (...args) => globalThis.fetch(...args), - appUrl: globalThis.location.origin, + catalogForSurface( + composeCatalog({ + tools: coreTools, + services: createToolServices({ + fetchImpl: (...args) => globalThis.fetch(...args), + appUrl: globalThis.location.origin, + }), }), - }), + 'voice', + ), ); pending.catch(() => { pending = null; diff --git a/src/tools/catalog.js b/src/tools/catalog.js index aa993e765..e525bbce0 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -60,9 +60,11 @@ export function fromSourceError(error) { /** * Validate and freeze a tool definition. * - * `run(args, { services, signal })` resolves to `{ summary, data }`: a short - * sentence for people and a structured object for programs. It may add - * `images`, each `{ mimeType, data }` with base64 data. + * `run(args, { services, signal, tools })` resolves to `{ summary, data }`: a + * short sentence for people and a structured object for programs. It may add + * `images`, each `{ mimeType, data }` with base64 data. `tools` reaches other + * tools through the same catalog: `tools.has(name)` and + * `tools.call(name, args)`. */ export function defineTool({ name, @@ -111,7 +113,8 @@ export function defineTool({ * - Duplicate names fail unless the later tool is listed in `replace`. * - Tools whose required services are missing are left out. * - Interceptors wrap every call in order, outermost first, as - * `(call, next) => next(call)` where `call` is `{ tool, args, signal }`. + * `(call, next) => next(call)` where `call` is `{ tool, args, signal }`, + * plus `parent`, the calling tool's name, when one tool calls another. */ export function composeCatalog({ tools, @@ -135,13 +138,28 @@ export function composeCatalog({ ); const index = new Map(available.map((tool) => [tool.name, tool])); + const invoke = (name, args, signal, parent) => { + const tool = index.get(name); + if (!tool) + return Promise.reject( + new ToolError('unsupported', `No tool named ${name} is available`), + ); + return chain({ tool, args, signal, ...(parent ? { parent } : {}) }); + }; const execute = async ({ tool, args, signal }) => { const problems = validateValue(tool.inputSchema, args); if (problems.length) throw new ToolError('invalid_arguments', problems.join('; ')); let result; try { - result = await tool.run(args, { services, signal }); + result = await tool.run(args, { + services, + signal, + tools: { + has: (name) => index.has(name), + call: (name, nested = {}) => invoke(name, nested, signal, tool.name), + }, + }); } catch (error) { throw fromSourceError(error); } @@ -171,13 +189,7 @@ export function composeCatalog({ get: (name) => index.get(name), /** Validate arguments and run a tool through the interceptors. */ async call(name, args = {}, { signal } = {}) { - const tool = index.get(name); - if (!tool) - throw new ToolError( - 'unsupported', - `No tool named ${name} is available`, - ); - return chain({ tool, args, signal }); + return invoke(name, args, signal); }, }); } diff --git a/src/tools/index.js b/src/tools/index.js index 66aa90bac..6f0fd8dfd 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -68,7 +68,12 @@ export { } from './area.js'; export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js'; export { toFunctionOutput, toFunctionTools } from './functions.js'; -export { SURFACES, TOOL_SURFACES, toolsForSurface } from './surfaces.js'; +export { + SURFACES, + TOOL_SURFACES, + catalogForSurface, + toolsForSurface, +} from './surfaces.js'; export { createGeocodePlaceService, createPlaceSearchService, diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index 917f8d15b..4997dd02d 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -8,25 +8,17 @@ import { resolveArea, resolvePoint, } from '../area.js'; -import { aircraftInArea } from './aviation.js'; -import { - findMilitaryInstallations, - getCyclones, - getWeather, -} from './environment.js'; -import { getActiveFires, getEarthquakes } from './hazards.js'; -import { vesselsInArea } from './maritime.js'; const SECTION_LIMIT = 5; const AWARENESS_LIMIT = 10; const AWARENESS_RADIUS_KM = 250; -/** Brief sections: the tool each one reuses and the services it needs. */ +/** Brief sections: the tool each one calls and the arguments it passes. */ const SECTIONS = [ { key: 'weather', label: 'Weather', - tool: getWeather, + tool: 'get_weather', args: (area, center) => ({ location: { lat: center.lat, lon: center.lon }, }), @@ -34,31 +26,31 @@ const SECTIONS = [ { key: 'earthquakes', label: 'Earthquakes', - tool: getEarthquakes, + tool: 'get_earthquakes', args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'fires', label: 'Active fires', - tool: getActiveFires, + tool: 'get_active_fires', args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'aircraft', label: 'Aircraft', - tool: aircraftInArea, + tool: 'aircraft_in_area', args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'vessels', label: 'Ships', - tool: vesselsInArea, + tool: 'vessels_in_area', args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'cyclones', label: 'Tropical cyclones', - tool: getCyclones, + tool: 'get_cyclones', args: (area) => ({ area: area.argument }), }, ]; @@ -67,7 +59,7 @@ const SECTIONS = [ const AWARENESS_SECTIONS = [ { key: 'military_aircraft', - tool: aircraftInArea, + tool: 'aircraft_in_area', args: (area) => ({ area: area.argument, military: true, @@ -76,17 +68,17 @@ const AWARENESS_SECTIONS = [ }, { key: 'aircraft', - tool: aircraftInArea, + tool: 'aircraft_in_area', args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }), }, { key: 'vessels', - tool: vesselsInArea, + tool: 'vessels_in_area', args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }), }, { key: 'installations', - tool: findMilitaryInstallations, + tool: 'find_military_installations', args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }), }, ]; @@ -108,17 +100,16 @@ function sectionArea(resolved) { } /** - * Run every section whose services are supplied. A failing section is - * reported as unavailable instead of failing the whole answer. + * Run every section whose tool the catalog offers, through the catalog so + * replaced tools and interceptors apply. A failing section is reported as + * unavailable instead of failing the whole answer. */ -async function runSections(all, area, { services, signal }) { +async function runSections(all, area, { tools, signal }) { const center = areaCenter(area); - const sections = all.filter(({ tool }) => - tool.requires.every((key) => services[key] != null), - ); + const sections = all.filter(({ tool }) => tools.has(tool)); const results = await Promise.allSettled( sections.map(({ tool, args: build }) => - tool.run(build(area, center), { services, signal }), + tools.call(tool, build(area, center)), ), ); signal?.throwIfAborted(); @@ -140,11 +131,11 @@ async function runSections(all, area, { services, signal }) { return { center, answers, lines }; } -async function buildBrief(args, { services, signal }) { +async function buildBrief(args, { services, signal, tools }) { // Sections receive the resolved box so a place name is looked up once. const area = sectionArea(await resolveArea(args.area, { services, signal })); const { center, answers, lines } = await runSections(SECTIONS, area, { - services, + tools, signal, }); return { area, center, brief: answers, lines }; @@ -241,7 +232,7 @@ export const militaryAwareness = defineTool({ additionalProperties: false, }, requires: ['military'], - async run(args, { services, signal }) { + async run(args, { services, signal, tools }) { const point = await resolvePoint(args.location, { services, signal }); const radiusKm = args.radius_km ?? AWARENESS_RADIUS_KM; const area = sectionArea( @@ -251,7 +242,7 @@ export const militaryAwareness = defineTool({ ), ); const { answers, lines } = await runSections(AWARENESS_SECTIONS, area, { - services, + tools, signal, }); return { diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index 40fdd4793..d1df866c9 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -1,6 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { composeCatalog, coreTools, ToolError } from '../index.js'; +import { + catalogForSurface, + composeCatalog, + coreTools, + defineTool, + ToolError, +} from '../index.js'; const conditions = { observedAt: '2026-10-01T19:15:00.000Z', @@ -536,6 +542,19 @@ test('military awareness gathers contacts around a point and marks failures', as reason: 'unavailable right now', }); assert.equal(result.data.radius_km, 100); + const voice = catalogForSurface(catalog, 'voice'); + assert.equal(voice.get('aircraft_in_area'), undefined); + assert.deepEqual( + Object.keys( + ( + await voice.call('military_awareness', { + location: { lat: 32.7, lon: -117.2 }, + radius_km: 100, + }) + ).data.sections, + ), + ['military_aircraft', 'aircraft', 'installations'], + ); assert.equal( (await catalog.call('military_awareness', { location: { lat: 0, lon: 0 } })) .data.radius_km, @@ -549,3 +568,36 @@ test('military awareness gathers contacts around a point and marks failures', as /radius_km/, ); }); + +test('composites reach their sections through the catalog', async () => { + const replacement = defineTool({ + name: 'get_weather', + title: 'Weather', + description: 'Replaced weather.', + inputSchema: { + type: 'object', + properties: { location: { type: 'object' } }, + }, + run: async () => ({ summary: 'Replaced weather.', data: {} }), + }); + const seen = []; + const catalog = composeCatalog({ + tools: [...coreTools, replacement], + replace: ['get_weather'], + services: { weather, places }, + interceptors: [ + (call, next) => { + seen.push([call.tool.name, call.parent ?? null]); + return next(call); + }, + ], + }); + const result = await catalog.call('situation_brief', { + area: { place: 'Austin' }, + }); + assert.equal(result.data.sections.weather.summary, 'Replaced weather.'); + assert.deepEqual(seen, [ + ['situation_brief', null], + ['get_weather', 'situation_brief'], + ]); +}); diff --git a/src/tools/surfaces.js b/src/tools/surfaces.js index 45c18f015..73a45e499 100644 --- a/src/tools/surfaces.js +++ b/src/tools/surfaces.js @@ -4,6 +4,8 @@ * or both. Applications can pass overrides when they compose a surface. */ +import { ToolError } from './catalog.js'; + export const SURFACES = Object.freeze(['mcp', 'voice']); const voiceOff = Object.freeze({ voice: false }); @@ -33,19 +35,18 @@ export const TOOL_SURFACES = Object.freeze({ /** * The tools a surface offers, in their original order. `overrides` uses the * table's shape and wins over it, such as `{ get_weather_map: { voice: true } }`. - * Unknown tool names or surfaces throw, so a typo cannot silently do nothing. + * An override naming an unknown tool or surface throws, so a typo cannot + * silently do nothing. */ export function toolsForSurface(tools, surface, overrides = {}) { if (!SURFACES.includes(surface)) throw new TypeError(`Unknown tool surface: ${surface}`); const names = new Set(tools.map((tool) => tool.name)); - for (const table of [TOOL_SURFACES, overrides]) { - for (const [name, entry] of Object.entries(table)) { - if (!names.has(name)) throw new TypeError(`Unknown tool: ${name}`); - for (const key of Object.keys(entry)) { - if (!SURFACES.includes(key)) - throw new TypeError(`Unknown tool surface for ${name}: ${key}`); - } + for (const [name, entry] of Object.entries(overrides)) { + if (!names.has(name)) throw new TypeError(`Unknown tool: ${name}`); + for (const key of Object.keys(entry)) { + if (!SURFACES.includes(key)) + throw new TypeError(`Unknown tool surface for ${name}: ${key}`); } } return tools.filter( @@ -54,3 +55,28 @@ export function toolsForSurface(tools, surface, overrides = {}) { TOOL_SURFACES[tool.name]?.[surface]) !== false, ); } + +/** + * A view of a composed catalog that lists and calls only the tools a surface + * offers. Tools that combine others still reach the whole catalog, so a + * composite such as military_awareness keeps its sections on every surface. + */ +export function catalogForSurface(catalog, surface, overrides = {}) { + const offered = new Set( + toolsForSurface(catalog.list(), surface, overrides).map( + (tool) => tool.name, + ), + ); + return Object.freeze({ + list: () => catalog.list().filter((tool) => offered.has(tool.name)), + get: (name) => (offered.has(name) ? catalog.get(name) : undefined), + async call(name, args, options) { + if (!offered.has(name)) + throw new ToolError( + 'unsupported', + `No tool named ${name} is available`, + ); + return catalog.call(name, args, options); + }, + }); +} diff --git a/src/tools/surfaces.test.mjs b/src/tools/surfaces.test.mjs index 4305783f9..5fc7424da 100644 --- a/src/tools/surfaces.test.mjs +++ b/src/tools/surfaces.test.mjs @@ -1,6 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { TOOL_SURFACES, coreTools, toolsForSurface } from './index.js'; +import { + TOOL_SURFACES, + catalogForSurface, + composeCatalog, + coreTools, + defineTool, + toolsForSurface, +} from './index.js'; const names = (tools) => tools.map((tool) => tool.name); @@ -33,6 +40,46 @@ test('overrides turn a tool on or off for one surface', () => { ); }); +test('every table entry names a core tool and a known surface', () => { + const names = new Set(coreTools.map((tool) => tool.name)); + for (const [name, entry] of Object.entries(TOOL_SURFACES)) { + assert.ok(names.has(name), name); + assert.deepEqual(Object.keys(entry), ['voice']); + } +}); + +test('a surface view hides tools from clients but not from composites', async () => { + const leaf = defineTool({ + name: 'leaf', + title: 'Leaf', + description: 'A hidden building block.', + inputSchema: { type: 'object', properties: {} }, + run: async () => ({ summary: 'leaf', data: {} }), + }); + const outer = defineTool({ + name: 'outer', + title: 'Outer', + description: 'Calls leaf.', + inputSchema: { type: 'object', properties: {} }, + run: async (args, { tools }) => tools.call('leaf', {}), + }); + const view = catalogForSurface( + composeCatalog({ tools: [leaf, outer] }), + 'voice', + { leaf: { voice: false } }, + ); + assert.deepEqual( + view.list().map((tool) => tool.name), + ['outer'], + ); + assert.equal(view.get('leaf'), undefined); + await assert.rejects( + view.call('leaf', {}), + (error) => error.code === 'unsupported', + ); + assert.equal((await view.call('outer', {})).summary, 'leaf'); +}); + test('unknown surfaces and tool names are rejected', () => { assert.throws( () => toolsForSurface(coreTools, 'email'), From 61974848fe6b361afc33eb791588d85ad6e30fc2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 14:47:48 -0700 Subject: [PATCH 035/111] fix: keep aircraft search results when one feed is down find_aircraft failed whenever either feed failed. It now answers from the feeds that responded, names the ones that did not, and searches only the military feed for registrations, the only feed that reports them. --- src/tools/queries/aviation.js | 40 ++++++++++++++++++++++------- src/tools/queries/aviation.test.mjs | 39 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index f5bc416ba..0bf520d0d 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -161,13 +161,31 @@ export const findAircraft = defineTool({ icao24: 'id', registration: 'registration', }[key]; - const feeds = [services.aircraft, services.military].filter(Boolean); - const snapshots = await Promise.all( - feeds.map((feed) => feed.getSnapshot({}, { signal })), + // Only the military feed reports registrations. + const feeds = [ + ...(key === 'registration' + ? [] + : [{ name: 'civil', feed: services.aircraft }]), + { name: 'military', feed: services.military }, + ].filter(({ feed }) => feed); + if (!feeds.length) + throw new ToolError( + 'unavailable', + 'The military aircraft feed is not available here', + ); + const settled = await Promise.allSettled( + feeds.map(({ feed }) => feed.getSnapshot({}, { signal })), ); + signal?.throwIfAborted(); + // A search succeeds when any feed answers; missing feeds are named. + const unavailable = feeds + .filter((_, index) => settled[index].status === 'rejected') + .map(({ name }) => name); + if (unavailable.length === feeds.length) throw settled[0].reason; const seen = new Set(); - const rows = snapshots - .flatMap((snapshot) => snapshot.records) + const rows = settled + .filter((result) => result.status === 'fulfilled') + .flatMap((result) => result.value.records) .filter( (record) => String(record[field] || '') @@ -177,10 +195,14 @@ export const findAircraft = defineTool({ .filter((record) => !seen.has(record.id) && seen.add(record.id)) .map((record) => aircraftRow(record)); return { - summary: rows.length - ? `Found ${countNoun(rows.length, 'aircraft', 'aircraft')} with ${key} ${wanted}.` - : `No aircraft with ${key} ${wanted} is currently reported.`, - data: capRows(rows, args.limit), + summary: + (rows.length + ? `Found ${countNoun(rows.length, 'aircraft', 'aircraft')} with ${key} ${wanted}.` + : `No aircraft with ${key} ${wanted} is currently reported.`) + + (unavailable.length + ? ` The ${unavailable.join(' and ')} feed did not answer.` + : ''), + data: { ...capRows(rows, args.limit), unavailable_feeds: unavailable }, }; }, }); diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 811a066c4..7594029ce 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -298,3 +298,42 @@ test('live source failures become tool errors with retry guidance', async () => error.message === 'OpenSky rate limited', ); }); + +test('aircraft searches keep the feeds that answered and name the others', async () => { + const down = { + getSnapshot: async () => { + throw new LiveSourceError('unavailable', 'OpenSky HTTP 503'); + }, + }; + const military = feed([ + record('ae1234', 38, -77, { callsign: 'RCH123', registration: '05-5140' }), + ]); + const catalog = composeCatalog({ + tools: coreTools, + services: { aircraft: down, military }, + }); + const byCallsign = await catalog.call('find_aircraft', { + callsign: 'rch123', + }); + assert.equal( + byCallsign.summary, + 'Found 1 aircraft with callsign RCH123. The civil feed did not answer.', + ); + assert.deepEqual(byCallsign.data.unavailable_feeds, ['civil']); + const civil = feed([]); + const byRegistration = await composeCatalog({ + tools: coreTools, + services: { aircraft: civil, military }, + }).call('find_aircraft', { registration: '05-5140' }); + assert.equal(byRegistration.data.rows[0].id, 'ae1234'); + assert.deepEqual(byRegistration.data.unavailable_feeds, []); + assert.equal(civil.calls.length, 0); + await assert.rejects( + composeCatalog({ + tools: coreTools, + services: { aircraft: down, military: down }, + }).call('find_aircraft', { callsign: 'RCH123' }), + (error) => + error.code === 'unavailable' && error.message === 'OpenSky HTTP 503', + ); +}); From 853c60583fa66e5b1bdeeffde3cf2fb47c4af63f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 14:51:31 -0700 Subject: [PATCH 036/111] fix: report stale transit feeds and drop expired positions get_transit_vehicles returned positions from a feed the proxy served from its error cache, and hour-old fixes, as current. It now applies the transit layer's rules: a feed is stale when the proxy says so or the operator has not answered recently, and fixes older than the vehicle limit are dropped and counted. --- scripts/package-boundaries.json | 6 ++- src/tools/queries/mobility.js | 60 +++++++++++++++++++++++++++-- src/tools/queries/mobility.test.mjs | 36 ++++++++++++++++- 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 03a648a71..9c768aa65 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3123,7 +3123,11 @@ "src/tools/queries/events.js", "src/data/bhoteKoshiFloodPath.js", "src/tools/functions.js", - "src/tools/surfaces.js" + "src/tools/surfaces.js", + "src/layers/transit/policy.js", + "src/data/transitFeeds.js", + "src/layers/transit/movement.js", + "src/data/contactPlayback.js" ], "external": ["satellite.js"] }, diff --git a/src/tools/queries/mobility.js b/src/tools/queries/mobility.js index 0c9e3a7fe..055889a7c 100644 --- a/src/tools/queries/mobility.js +++ b/src/tools/queries/mobility.js @@ -4,6 +4,10 @@ import { parseStationInformation, parseStationStatus, } from '../../sources/gbfsStations.js'; +import { + FEED_STALE_AFTER_MS, + isStaleVehicleFix, +} from '../../layers/transit/policy.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, @@ -157,6 +161,7 @@ export const getTransitVehicles = defineTool({ data: { ...capRows([], args.limit), feeds: [] }, }; const center = areaCenter(area); + const now = services.clock?.now() ?? Date.now(); const results = await Promise.allSettled( feeds.map(async (feed) => { const response = await services.transit.requestSnapshot(feed.id, { @@ -164,7 +169,31 @@ export const getTransitVehicles = defineTool({ }); if (!response.ok) throw new Error(`Transit HTTP ${response.status}`); const snapshot = await response.json(); - return (snapshot.vehicles || []).map((vehicle) => ({ feed, vehicle })); + // The app's freshness rules: the operator's fetch time ages the + // snapshot, and fixes older than the vehicle limit are not shown. + const fetchedAt = Number.isFinite(snapshot.fetchedAt) + ? Math.min(snapshot.fetchedAt, now) + : now; + const contacted = Number.parseInt( + response.headers?.get?.('x-transit-contact') || '', + 10, + ); + const answeredAt = Math.max( + fetchedAt, + Number.isFinite(contacted) ? Math.min(contacted, now) : fetchedAt, + ); + const stale = + response.headers?.get?.('x-gev-cache') === 'STALE-ERROR' || + now - answeredAt > FEED_STALE_AFTER_MS; + const vehicles = snapshot.vehicles || []; + const current = vehicles.filter( + (vehicle) => !isStaleVehicleFix(vehicle, now, fetchedAt), + ); + return { + stale, + expired: vehicles.length - current.length, + vehicles: current.map((vehicle) => ({ feed, vehicle })), + }; }), ); signal?.throwIfAborted(); @@ -175,7 +204,9 @@ export const getTransitVehicles = defineTool({ ); const wanted = args.route?.trim().toLowerCase(); const rows = results - .flatMap((result) => (result.status === 'fulfilled' ? result.value : [])) + .flatMap((result) => + result.status === 'fulfilled' ? result.value.vehicles : [], + ) .filter(({ vehicle }) => areaContains(area, vehicle)) .filter( ({ vehicle }) => @@ -205,15 +236,36 @@ export const getTransitVehicles = defineTool({ const failed = results.filter( (result) => result.status === 'rejected', ).length; + const answered = results + .filter((result) => result.status === 'fulfilled') + .map((result) => result.value); + const staleFeeds = answered.filter((feed) => feed.stale).length; + const expired = answered.reduce((total, feed) => total + feed.expired, 0); + const notes = [ + ...(failed ? [`${countNoun(failed, 'feed')} unavailable`] : []), + ...(staleFeeds + ? [ + `${countNoun(staleFeeds, 'feed')} stale; positions may be out of date`, + ] + : []), + ]; const what = args.route ? ` on route ${args.route}` : ''; return { summary: `${countNoun(rows.length, 'transit vehicle')}${what} in ${area.label}` + - (failed ? ` (${countNoun(failed, 'feed')} unavailable).` : '.'), + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { ...capRows(rows, args.limit), - feeds: feeds.map((feed) => ({ + stale: staleFeeds > 0, + expired_positions_dropped: expired, + feeds: feeds.map((feed, index) => ({ name: feed.name, + status: + results[index].status === 'rejected' + ? 'unavailable' + : results[index].value.stale + ? 'stale' + : 'current', operator: feed.operator ?? null, attribution: feed.attribution ?? null, license: feed.license ?? null, diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index cbf48b99d..11510f01a 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -177,6 +177,8 @@ const vehicles = [ }, { id: 'v3', lat: 30.5, lon: -97.6, routeId: '1' }, ]; +// One minute after v1's fix. +const clock = { now: () => Date.UTC(2026, 0, 1, 0, 1) }; const transit = ( snapshot = { ok: true, status: 200, json: async () => ({ vehicles }) }, ) => ({ @@ -197,7 +199,7 @@ test('transit vehicles come from nearby feeds and can be limited to a route', as const source = transit(); const catalog = composeCatalog({ tools: coreTools, - services: { transit: source }, + services: { transit: source, clock }, }); const all = await catalog.call('get_transit_vehicles', { area: downtown }); assert.equal( @@ -223,6 +225,7 @@ test('transit vehicles come from nearby feeds and can be limited to a route', as assert.deepEqual(all.data.feeds, [ { name: 'CapMetro', + status: 'current', operator: 'Capital Metro', attribution: 'Capital Metro', license: 'Open data', @@ -370,3 +373,34 @@ test('traffic reports a missing key, empty areas and areas that are too wide', a /too large for traffic detail/, ); }); + +test('transit marks stale feeds and drops expired positions', async () => { + const headers = new Headers({ 'x-gev-cache': 'STALE-ERROR' }); + const snapshot = (extra) => ({ + ok: true, + status: 200, + headers, + json: async () => ({ + vehicles: [ + vehicles[0], + { ...vehicles[0], id: 'old', timestamp: 1767225600 - 3600 }, + ], + ...extra, + }), + }); + const result = await composeCatalog({ + tools: coreTools, + services: { transit: transit(snapshot()), clock }, + }).call('get_transit_vehicles', { area: downtown }); + assert.deepEqual( + result.data.rows.map((row) => row.id), + ['v1'], + ); + assert.equal(result.data.expired_positions_dropped, 1); + assert.equal(result.data.stale, true); + assert.equal(result.data.feeds[0].status, 'stale'); + assert.equal( + result.summary, + '1 transit vehicle in 2 km around 30.267, -97.743 (1 feed stale; positions may be out of date).', + ); +}); From cac5ba3b722460e48df7dd08d35e5831f1554667 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 14:54:02 -0700 Subject: [PATCH 037/111] fix: derive HUD caption feed states from section results The caption request declared every answered section nominal, so a stale aircraft snapshot reached the caption as live. Each section now reports stale or unavailable from its own result, and the provenance envelope is built with the app's feedProvenanceEnvelope. --- scripts/package-boundaries.json | 6 +++- src/tools/queries/brief.js | 39 +++++++++++++++--------- src/tools/queries/environment.test.mjs | 41 ++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9c768aa65..7da7e6c08 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3127,7 +3127,11 @@ "src/layers/transit/policy.js", "src/data/transitFeeds.js", "src/layers/transit/movement.js", - "src/data/contactPlayback.js" + "src/data/contactPlayback.js", + "src/data/layerSnapshot.js", + "src/data/feedState.js", + "src/loadingFeedback.js", + "src/data/installationFeedback.js" ], "external": ["satellite.js"] }, diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index 4997dd02d..750cd1dfb 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -1,5 +1,6 @@ /** Composite queries that combine other tools' answers for one area. */ +import { feedProvenanceEnvelope } from '../../data/layerSnapshot.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, @@ -83,6 +84,15 @@ const AWARENESS_SECTIONS = [ }, ]; +/** A section's feed state for the caption, from what its result reported. */ +function sectionFeedState(section) { + if (section.unavailable) return 'unavailable'; + const data = section.data || {}; + return data.freshness === 'stale' || data.stale === true + ? 'stale' + : 'nominal'; +} + /** The resolved area, plus the argument sections receive in its place. */ function sectionArea(resolved) { return { @@ -179,16 +189,16 @@ export const getHudCaption = defineTool({ requires: ['weather', 'summary'], async run(args, context) { const { area, center, brief, lines } = await buildBrief(args, context); - // The app's caption context: place labels and the layers that answered, - // with their feed state. - const answered = SECTIONS.filter( - ({ key }) => brief[key] && !brief[key].unavailable, + // The app's caption context: place labels and each section as a layer + // with the feed state its result reported, failures included. + const layers = SECTIONS.filter(({ key }) => brief[key]).map( + ({ key, label }) => ({ + id: key, + name: label, + enabled: true, + feedState: sectionFeedState(brief[key]), + }), ); - const layers = answered.map(({ key, label }) => ({ - id: key, - name: label, - feedState: 'nominal', - })); const response = await context.services.summary.summarize( { placeLabels: [area.label], @@ -196,11 +206,12 @@ export const getHudCaption = defineTool({ center, observations: lines, enabledLayerLabels: layers.map((layer) => layer.name), - enabledLayers: layers, - feedProvenance: { - overall: layers.length ? 'nominal' : null, - layers, - }, + enabledLayers: layers.map(({ id, name, feedState }) => ({ + id, + name, + feedState, + })), + feedProvenance: feedProvenanceEnvelope(layers), }, { signal: context.signal }, ); diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index d1df866c9..b79f578d3 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -601,3 +601,44 @@ test('composites reach their sections through the catalog', async () => { ['get_weather', 'situation_brief'], ]); }); + +test('the HUD caption context carries each section feed state', async () => { + const sent = []; + const summary = { + async summarize(context) { + sent.push(context); + return { + ok: true, + status: 200, + data: { summary: 'Austin aircraft STALE' }, + }; + }, + }; + const aircraft = { + getSnapshot: async () => ({ + records: [], + complete: true, + source: 'Test feed', + freshness: 'stale', + }), + }; + const cyclones = { + getSnapshot: async () => { + throw new Error('upstream down'); + }, + }; + await catalogWith({ weather, summary, places, aircraft, cyclones }).call( + 'get_hud_caption', + { area: { place: 'Austin' } }, + ); + assert.deepEqual( + sent[0].enabledLayers.map((layer) => [layer.id, layer.feedState]), + [ + ['weather', 'nominal'], + ['aircraft', 'stale'], + ['cyclones', 'unavailable'], + ], + ); + assert.equal(sent[0].feedProvenance.overall, 'unavailable'); + assert.match(sent[0].feedProvenance.note, /Aircraft is STALE/); +}); From 5b73b70d19ac30be19e1f2450111336a7820db73 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 14:55:43 -0700 Subject: [PATCH 038/111] fix: bound radius areas by the exact spherical cap The longitude span divided the angular radius by the center's cosine, which is too narrow for large radii and near the poles. Areas now use the exact tangent bound and span every longitude when the circle reaches a pole. --- src/tools/area.js | 14 +++++++++++--- src/tools/area.test.mjs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/tools/area.js b/src/tools/area.js index e494c6782..41b417482 100644 --- a/src/tools/area.js +++ b/src/tools/area.js @@ -82,10 +82,18 @@ export async function resolveArea(area, { services, signal } = {}) { }; } +/** + * The bounding box of a spherical cap. When the cap reaches a pole it spans + * every longitude; otherwise the longitude span is the exact tangent bound, + * asin(sin(d) / cos(lat)) for angular radius d. + */ function circleArea(lat, lon, radiusKm) { - const latSpan = (radiusKm / EARTH_RADIUS_KM) * (180 / Math.PI); - const cosLat = Math.cos((lat * Math.PI) / 180); - const lonSpan = cosLat < 1e-6 ? 180 : Math.min(180, latSpan / cosLat); + const angular = radiusKm / EARTH_RADIUS_KM; + const latSpan = angular * (180 / Math.PI); + const reachesPole = lat + latSpan >= 90 || lat - latSpan <= -90; + const ratio = Math.sin(angular) / Math.cos((lat * Math.PI) / 180); + const lonSpan = + reachesPole || ratio >= 1 ? 180 : Math.asin(ratio) * (180 / Math.PI); const wrap = (value) => ((((value + 180) % 360) + 360) % 360) - 180; return { label: `${radiusKm} km around ${lat.toFixed(3)}, ${lon.toFixed(3)}`, diff --git a/src/tools/area.test.mjs b/src/tools/area.test.mjs index 99df85684..d22611282 100644 --- a/src/tools/area.test.mjs +++ b/src/tools/area.test.mjs @@ -117,3 +117,33 @@ test('a named place centers on its own point, not the middle of its bounds', asy const box = await resolveArea({ bbox: [0, 0, 10, 10] }); assert.deepEqual(areaCenter(box), { lat: 5, lon: 5 }); }); + +test('radius areas include every point within the radius, near the poles too', async () => { + const polar = await resolveArea({ lat: 80, lon: 0, radius_km: 1500 }); + const across = { lat: 89, lon: 180 }; + assert.ok(distanceKm({ lat: 80, lon: 0 }, across) < 1500); + assert.ok(areaContains(polar, across)); + assert.equal(polar.west, -180); + assert.equal(polar.east, 180); + assert.equal(polar.north, 90); + // Away from the poles, sample the circle's edge and check each point is kept. + const center = { lat: 60, lon: 20 }; + const wide = await resolveArea({ ...center, radius_km: 2000 }); + assert.ok(wide.east - wide.west < 360); + for (let bearing = 0; bearing < 360; bearing += 5) { + const d = 1999 / 6371.0088; + const b = (bearing * Math.PI) / 180; + const lat1 = (center.lat * Math.PI) / 180; + const lat2 = Math.asin( + Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(b), + ); + const lon2 = + (center.lon * Math.PI) / 180 + + Math.atan2( + Math.sin(b) * Math.sin(d) * Math.cos(lat1), + Math.cos(d) - Math.sin(lat1) * Math.sin(lat2), + ); + const point = { lat: (lat2 * 180) / Math.PI, lon: (lon2 * 180) / Math.PI }; + assert.ok(areaContains(wide, point), `bearing ${bearing}`); + } +}); From 6f56c84b0bf61e69b1099dfec387613435f16de2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 14:57:55 -0700 Subject: [PATCH 039/111] fix: find cables that cross an area between vertices Cable searches only tested vertices, so a cable crossing an area with no vertex inside it was missed. Lines now test each segment against the area's bounds, across the antimeridian too, and against a radius area's great-circle distance. --- src/tools/area.js | 101 ++++++++++++++++++++++++++++++ src/tools/area.test.mjs | 79 ++++++++++++++++++++++- src/tools/queries/cables.js | 11 ++-- src/tools/queries/cables.test.mjs | 15 +++++ 4 files changed, 201 insertions(+), 5 deletions(-) diff --git a/src/tools/area.js b/src/tools/area.js index 41b417482..b8eef612b 100644 --- a/src/tools/area.js +++ b/src/tools/area.js @@ -195,3 +195,104 @@ export function areaContains(area, point) { ? distanceKm(area.center, point) <= area.center.radiusKm : true; } + +/** Whether a straight lon/lat segment meets a plain box (Liang–Barsky). */ +function segmentMeetsBox(a, b, west, south, east, north) { + const dx = b.lon - a.lon; + const dy = b.lat - a.lat; + let low = 0; + let high = 1; + for (const [p, q] of [ + [-dx, a.lon - west], + [dx, east - a.lon], + [-dy, a.lat - south], + [dy, north - a.lat], + ]) { + if (p === 0) { + if (q < 0) return false; + } else { + const t = q / p; + if (p < 0) low = Math.max(low, t); + else high = Math.min(high, t); + if (low > high) return false; + } + } + return true; +} + +/** Whether a segment meets an area's bounding box, across the antimeridian too. */ +function segmentMeetsBounds(a, b, area) { + // A segment longer than half the globe in longitude crosses the antimeridian. + const end = + Math.abs(b.lon - a.lon) > 180 + ? { lat: b.lat, lon: b.lon + (b.lon < a.lon ? 360 : -360) } + : b; + const boxes = + area.west <= area.east + ? [[area.west, area.east]] + : [ + [area.west, 180], + [-180, area.east], + ]; + return boxes.some(([west, east]) => + [-360, 0, 360].some((shift) => + segmentMeetsBox( + a, + end, + west + shift, + area.south, + east + shift, + area.north, + ), + ), + ); +} + +/** Closest great-circle distance in km from `point` to the segment a–b. */ +function segmentDistanceKm(point, a, b) { + const toRad = Math.PI / 180; + const bearing = (from, to) => { + const lat1 = from.lat * toRad; + const lat2 = to.lat * toRad; + const dLon = (to.lon - from.lon) * toRad; + return Math.atan2( + Math.sin(dLon) * Math.cos(lat2), + Math.cos(lat1) * Math.sin(lat2) - + Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon), + ); + }; + const toPoint = distanceKm(a, point) / EARTH_RADIUS_KM; + const length = distanceKm(a, b) / EARTH_RADIUS_KM; + const angle = bearing(a, point) - bearing(a, b); + if (length === 0 || Math.cos(angle) <= 0) return distanceKm(a, point); + const cross = Math.asin(Math.sin(toPoint) * Math.sin(angle)); + const along = Math.acos( + Math.min(1, Math.cos(toPoint) / Math.max(1e-12, Math.cos(cross))), + ); + if (along >= length) return distanceKm(b, point); + return Math.abs(cross) * EARTH_RADIUS_KM; +} + +/** + * Whether a line of `[lon, lat]` vertices passes through an area, including + * segments that cross it with no vertex inside. + */ +export function lineTouchesArea(coords, area) { + const points = coords + .map(([lon, lat]) => ({ lat, lon })) + .filter( + (point) => Number.isFinite(point.lat) && Number.isFinite(point.lon), + ); + if (points.some((point) => areaContains(area, point))) return true; + for (let index = 1; index < points.length; index += 1) { + const a = points[index - 1]; + const b = points[index]; + if (!segmentMeetsBounds(a, b, area)) continue; + if ( + !area.center || + segmentDistanceKm(area.center, a, b) <= area.center.radiusKm + ) + return true; + } + return false; +} diff --git a/src/tools/area.test.mjs b/src/tools/area.test.mjs index d22611282..dee34f402 100644 --- a/src/tools/area.test.mjs +++ b/src/tools/area.test.mjs @@ -1,6 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { areaCenter, areaContains, distanceKm, resolveArea } from './area.js'; +import { + areaCenter, + areaContains, + distanceKm, + lineTouchesArea, + resolveArea, +} from './area.js'; import { placeFromGeocodeResult } from './places.js'; test('an area needs exactly one form', async () => { @@ -147,3 +153,74 @@ test('radius areas include every point within the radius, near the poles too', a assert.ok(areaContains(wide, point), `bearing ${bearing}`); } }); + +test('lines touch an area when a segment crosses it with no vertex inside', async () => { + const box = await resolveArea({ bbox: [-1, -1, 1, 1] }); + assert.ok( + lineTouchesArea( + [ + [-2, 0], + [2, 0], + ], + box, + ), + ); + assert.ok( + !lineTouchesArea( + [ + [-2, 2], + [2, 2], + ], + box, + ), + ); + const circle = await resolveArea({ lat: 0, lon: 0, radius_km: 50 }); + assert.ok( + lineTouchesArea( + [ + [-2, 0.3], + [2, 0.3], + ], + circle, + ), + ); + // Inside the circle's box but beyond its radius. + assert.ok( + !lineTouchesArea( + [ + [0.3, 0.42], + [0.6, 0.42], + ], + circle, + ), + ); + const dateline = await resolveArea({ bbox: [179, -1, -179, 1] }); + assert.ok( + lineTouchesArea( + [ + [178, 0], + [-178, 0], + ], + dateline, + ), + ); + const nearDateline = await resolveArea({ bbox: [179.5, -1, 179.9, 1] }); + assert.ok( + lineTouchesArea( + [ + [179, 0], + [-179, 0], + ], + nearDateline, + ), + ); + assert.ok( + !lineTouchesArea( + [ + [170, 0], + [175, 0], + ], + nearDateline, + ), + ); +}); diff --git a/src/tools/queries/cables.js b/src/tools/queries/cables.js index e7f5ec6f7..ddf69f33d 100644 --- a/src/tools/queries/cables.js +++ b/src/tools/queries/cables.js @@ -1,7 +1,12 @@ /** Submarine cables and their landing points. */ import { defineTool, ToolError } from '../catalog.js'; -import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; +import { + AREA_SCHEMA, + areaContains, + lineTouchesArea, + resolveArea, +} from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js'; const ATTRIBUTION = '© TeleGeography — submarinecablemap.com (CC BY-NC-SA 3.0)'; @@ -58,9 +63,7 @@ export const findSubmarineCables = defineTool({ return false; return ( !area || - cableLines(feature).some((line) => - line.some(([lon, lat]) => areaContains(area, { lat, lon })), - ) + cableLines(feature).some((line) => lineTouchesArea(line, area)) ); }) .map((feature) => ({ diff --git a/src/tools/queries/cables.test.mjs b/src/tools/queries/cables.test.mjs index 754032b6e..f74457087 100644 --- a/src/tools/queries/cables.test.mjs +++ b/src/tools/queries/cables.test.mjs @@ -72,6 +72,21 @@ test('cables are found by area and name, with landing points and attribution', a catalog.call('find_submarine_cables', {}), /area, a name or both/, ); + // Areas a cable crosses between vertices, including at the antimeridian. + const atlantic = await catalog.call('find_submarine_cables', { + area: { bbox: [-30, 40, -20, 43] }, + }); + assert.deepEqual( + atlantic.data.rows.map((row) => row.id), + ['marea'], + ); + const pacific = await catalog.call('find_submarine_cables', { + area: { bbox: [179, 34, -179, 35.5] }, + }); + assert.deepEqual( + pacific.data.rows.map((row) => row.id), + ['unity'], + ); }); test('the local fetch serves bundled data files and nothing outside them', async () => { From 1b3aa8de8bb58030f6ad63a68f1c4f1f2110fee0 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 14:59:49 -0700 Subject: [PATCH 040/111] fix: stop reading MCP request bodies past the size limit The HTTP handler read the whole body before checking its size, so a body without Content-Length could be buffered without bound. It now reads the stream and stops as soon as the body passes the limit. --- src/tools/mcp/http.js | 34 +++++++++++++++++++++++++++++++--- src/tools/mcp/mcp.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/tools/mcp/http.js b/src/tools/mcp/http.js index fc8549e19..f16562a93 100644 --- a/src/tools/mcp/http.js +++ b/src/tools/mcp/http.js @@ -25,9 +25,8 @@ export function createMcpHttpHandler(server) { const declared = Number(request.headers.get('content-length')); if (declared > MAX_BODY_BYTES) return json(413, { error: 'Request too large' }); - const text = await request.text(); - if (new TextEncoder().encode(text).length > MAX_BODY_BYTES) - return json(413, { error: 'Request too large' }); + const text = await readCapped(request, MAX_BODY_BYTES); + if (text === null) return json(413, { error: 'Request too large' }); let message; try { message = JSON.parse(text); @@ -39,6 +38,35 @@ export function createMcpHttpHandler(server) { }; } +/** + * Read a request body as text, stopping as soon as it passes `limit` bytes, + * so a body without a Content-Length cannot be buffered without bound. + * Returns null when the body is too large. + */ +async function readCapped(request, limit) { + if (!request.body) return ''; + const reader = request.body.getReader(); + const chunks = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { + await reader.cancel().catch(() => {}); + return null; + } + chunks.push(value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(bytes); +} + function json(status, body) { return new Response(JSON.stringify(body), { status, diff --git a/src/tools/mcp/mcp.test.mjs b/src/tools/mcp/mcp.test.mjs index 54269ab2e..e82f4ee16 100644 --- a/src/tools/mcp/mcp.test.mjs +++ b/src/tools/mcp/mcp.test.mjs @@ -176,6 +176,26 @@ test('the HTTP transport accepts one JSON message per POST', async () => { ); assert.equal((await post({}, { 'Content-Type': 'text/plain' })).status, 415); assert.equal((await post('x'.repeat(1024 * 1024 + 1))).status, 413); + // A streamed body without Content-Length stops being read past the limit. + let produced = 0; + const chunk = new Uint8Array(64 * 1024).fill(32); + const body = new ReadableStream({ + pull(controller) { + if (produced >= 8 * 1024 * 1024) return controller.close(); + produced += chunk.byteLength; + controller.enqueue(chunk); + }, + }); + const streamed = await handle( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + duplex: 'half', + }), + ); + assert.equal(streamed.status, 413); + assert.ok(produced <= 1024 * 1024 + 4 * chunk.byteLength, `read ${produced}`); const get = await handle(new Request('http://localhost/mcp')); assert.equal(get.status, 405); assert.equal(get.headers.get('allow'), 'POST'); From 4af15aa6a9907f2a655a08e1b363af4cd159bfff Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 15:17:07 -0700 Subject: [PATCH 041/111] fix: keep a named place's own point in situation briefs Brief sections received a named place as its bounding box, so they centered on the box's middle: Tokyo's aircraft were sorted from a point in the Pacific. Sections now receive the place name and resolve it like a direct query, and place lookups are cached briefly so a brief resolves it once. --- scripts/format-scope.json | 3 +- src/tools/places.js | 47 +++++++++++++++++++++++--- src/tools/places.test.mjs | 39 +++++++++++++++++++++ src/tools/queries/brief.js | 42 ++++++++++++++++------- src/tools/queries/environment.test.mjs | 27 +++++++++++++-- 5 files changed, 137 insertions(+), 21 deletions(-) create mode 100644 src/tools/places.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 9fd4c4a4c..580988971 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -887,5 +887,6 @@ "src/tools/queries/infrastructure.test.mjs", "src/tools/queries/events.test.mjs", "src/tools/functions.test.mjs", - "src/tools/surfaces.test.mjs" + "src/tools/surfaces.test.mjs", + "src/tools/places.test.mjs" ] diff --git a/src/tools/places.js b/src/tools/places.js index e7f4f8084..97b4ce8f1 100644 --- a/src/tools/places.js +++ b/src/tools/places.js @@ -20,18 +20,55 @@ const POINT_RADIUS_DEGREES = 0.25; */ export function createGeocodePlaceService({ fetchImpl = (...args) => globalThis.fetch(...args), + cacheSize = 200, + cacheMs = 10 * 60_000, + now = () => Date.now(), } = {}) { + // Recent answers by name, so tools that combine others resolve a place to + // the same point once instead of once per section. + const cache = new Map(); + async function lookup(name) { + const query = new URLSearchParams({ q: name }); + const response = await fetchImpl(`/api/geocode?${query}`); + if (!response.ok) throw new Error(`Geocode HTTP ${response.status}`); + const payload = await response.json(); + return placeFromGeocodeResult(payload?.results?.[0]); + } return { async resolve(name, { signal } = {}) { - const query = new URLSearchParams({ q: name }); - const response = await fetchImpl(`/api/geocode?${query}`, { signal }); - if (!response.ok) throw new Error(`Geocode HTTP ${response.status}`); - const payload = await response.json(); - return placeFromGeocodeResult(payload?.results?.[0]); + signal?.throwIfAborted(); + const key = name.trim().toLowerCase(); + const hit = cache.get(key); + if (hit && now() - hit.at < cacheMs) { + cache.delete(key); + cache.set(key, hit); + return waitFor(hit.place, signal); + } + const place = lookup(name); + const entry = { at: now(), place }; + cache.set(key, entry); + while (cache.size > cacheSize) cache.delete(cache.keys().next().value); + place.catch(() => { + if (cache.get(key) === entry) cache.delete(key); + }); + return waitFor(place, signal); }, }; } +/** Settle with `promise`, or reject when `signal` aborts first. */ +function waitFor(promise, signal) { + if (!signal) return promise; + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason); + signal.addEventListener('abort', abort, { once: true }); + promise + .then(resolve, reject) + .finally(() => signal.removeEventListener('abort', abort)); + }); +} + /** Convert one geocode result to a place with bounds, or null. */ export function placeFromGeocodeResult(result) { const location = result?.geometry?.location; diff --git a/src/tools/places.test.mjs b/src/tools/places.test.mjs new file mode 100644 index 000000000..63fdeb28a --- /dev/null +++ b/src/tools/places.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGeocodePlaceService } from './places.js'; + +const answer = { + results: [ + { + formatted_address: 'Tokyo, Japan', + geometry: { location: { lat: 35.68, lng: 139.76 } }, + }, + ], +}; + +test('place lookups are cached by name for a while, and failures are not', async () => { + let clock = 0; + const requests = []; + let fail = true; + const places = createGeocodePlaceService({ + now: () => clock, + cacheMs: 1000, + fetchImpl: async (url) => { + requests.push(url); + if (fail) return new Response(null, { status: 503 }); + return Response.json(answer); + }, + }); + await assert.rejects(places.resolve('Tokyo'), /Geocode HTTP 503/); + fail = false; + const first = await places.resolve('Tokyo'); + assert.deepEqual(first.point, { lat: 35.68, lon: 139.76 }); + assert.equal(await places.resolve(' tokyo '), first); + assert.equal(requests.length, 2); + clock = 2000; + await places.resolve('Tokyo'); + assert.equal(requests.length, 3); + const aborted = new AbortController(); + aborted.abort(); + await assert.rejects(places.resolve('Tokyo', { signal: aborted.signal })); +}); diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index 750cd1dfb..cf6161693 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -21,7 +21,9 @@ const SECTIONS = [ label: 'Weather', tool: 'get_weather', args: (area, center) => ({ - location: { lat: center.lat, lon: center.lon }, + location: area.argument.place + ? { place: area.argument.place } + : { lat: center.lat, lon: center.lon }, }), }, { @@ -93,19 +95,30 @@ function sectionFeedState(section) { : 'nominal'; } -/** The resolved area, plus the argument sections receive in its place. */ -function sectionArea(resolved) { +/** + * The resolved area, plus the argument sections receive. A named place is + * passed by name, so sections resolve it to the same point and label as a + * direct query does (place lookups are cached). + */ +function sectionArea(resolved, original) { return { ...resolved, - argument: resolved.center - ? { - lat: resolved.center.lat, - lon: resolved.center.lon, - radius_km: resolved.center.radiusKm, - } - : { - bbox: [resolved.west, resolved.south, resolved.east, resolved.north], - }, + argument: original?.place + ? { place: original.place } + : resolved.center + ? { + lat: resolved.center.lat, + lon: resolved.center.lon, + radius_km: resolved.center.radiusKm, + } + : { + bbox: [ + resolved.west, + resolved.south, + resolved.east, + resolved.north, + ], + }, }; } @@ -143,7 +156,10 @@ async function runSections(all, area, { tools, signal }) { async function buildBrief(args, { services, signal, tools }) { // Sections receive the resolved box so a place name is looked up once. - const area = sectionArea(await resolveArea(args.area, { services, signal })); + const area = sectionArea( + await resolveArea(args.area, { services, signal }), + args.area, + ); const { center, answers, lines } = await runSections(SECTIONS, area, { tools, signal, diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index b79f578d3..2a7a82982 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -421,8 +421,8 @@ test('the situation brief combines available sections and marks failures', async }); assert.equal( result.summary, - 'Situation in Austin, Texas: Weather at 30.3100, -97.7500: overcast, 26°C, wind 5 km/h. ' + - '1 earthquake of M2.5+ in the last 24 hours in the requested box; strongest M3.1 near Austin.', + 'Situation in Austin, Texas: Weather at Austin, Texas: overcast, 26°C, wind 5 km/h. ' + + '1 earthquake of M2.5+ in the last 24 hours in Austin, Texas; strongest M3.1 near Austin.', ); assert.deepEqual(Object.keys(result.data.sections), [ 'weather', @@ -642,3 +642,26 @@ test('the HUD caption context carries each section feed state', async () => { assert.equal(sent[0].feedProvenance.overall, 'unavailable'); assert.match(sent[0].feedProvenance.note, /Aircraft is STALE/); }); + +test('brief sections use a named place at its own point', async () => { + const tokyo = { + resolve: async () => ({ + name: 'Tokyo, Japan', + point: { lat: 35.6769, lon: 139.7639 }, + bounds: { west: 135, south: 20, east: 155, north: 36 }, + }), + }; + const queries = []; + const aircraft = { + getSnapshot: async (query) => { + queries.push(query); + return { records: [], complete: true, source: 'Test feed' }; + }, + }; + const result = await catalogWith({ weather, places: tokyo, aircraft }).call( + 'situation_brief', + { area: { place: 'Tokyo' } }, + ); + assert.deepEqual(queries[0], { latitude: 35.6769, longitude: 139.7639 }); + assert.match(result.data.sections.aircraft.summary, /in Tokyo, Japan\.$/); +}); From dd8ba562d29906306eaac3cce7cfa92bceaafff9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 15:18:55 -0700 Subject: [PATCH 042/111] fix: find fire perimeters that overlap an area Fire perimeters were placed at their average vertex, so a fire enclosing the whole requested area was missed. Polygons now count when a vertex is inside the area, an edge crosses it, or the polygon contains it outside any hole. --- src/tools/area.js | 35 ++++++++++++++++++++++++++ src/tools/area.test.mjs | 21 ++++++++++++++++ src/tools/queries/environment.js | 4 ++- src/tools/queries/environment.test.mjs | 29 +++++++++++++++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/tools/area.js b/src/tools/area.js index b8eef612b..dbd3aabb6 100644 --- a/src/tools/area.js +++ b/src/tools/area.js @@ -296,3 +296,38 @@ export function lineTouchesArea(coords, area) { } return false; } + +/** Whether a point lies inside a ring of `[lon, lat]` vertices (ray casting). */ +function ringContains(ring, point) { + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) { + const [xi, yi] = ring[i]; + const [xj, yj] = ring[j]; + if ( + yi > point.lat !== yj > point.lat && + point.lon < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi + ) + inside = !inside; + } + return inside; +} + +/** + * Whether polygons (each a list of `[lon, lat]` rings, outer ring first) + * overlap an area: a vertex inside it, an edge crossing it, or the area's + * center inside a polygon and outside its holes. + */ +export function polygonsTouchArea(polygons, area) { + const center = areaCenter(area); + return (polygons || []).some((rings) => { + if (!Array.isArray(rings) || !rings.length) return false; + const closed = rings.map((ring) => + ring.length && ring[0] !== ring.at(-1) ? [...ring, ring[0]] : ring, + ); + if (closed.some((ring) => lineTouchesArea(ring, area))) return true; + return ( + ringContains(rings[0], center) && + !rings.slice(1).some((hole) => ringContains(hole, center)) + ); + }); +} diff --git a/src/tools/area.test.mjs b/src/tools/area.test.mjs index dee34f402..d901813b5 100644 --- a/src/tools/area.test.mjs +++ b/src/tools/area.test.mjs @@ -5,6 +5,7 @@ import { areaContains, distanceKm, lineTouchesArea, + polygonsTouchArea, resolveArea, } from './area.js'; import { placeFromGeocodeResult } from './places.js'; @@ -224,3 +225,23 @@ test('lines touch an area when a segment crosses it with no vertex inside', asyn ), ); }); + +test('polygons touch an area by vertex, crossing edge or enclosing it', async () => { + const box = await resolveArea({ bbox: [-1, -1, 1, 1] }); + const square = (half, x = 0) => [ + [ + [x - half, -half], + [x + half, -half], + [x + half, half], + [x - half, half], + ], + ]; + assert.ok(polygonsTouchArea([square(5)], box), 'encloses the area'); + assert.ok(polygonsTouchArea([square(0.5)], box), 'inside the area'); + assert.ok(polygonsTouchArea([square(1, 1.5)], box), 'overlaps an edge'); + assert.ok(!polygonsTouchArea([square(0.5, 5)], box), 'elsewhere'); + const ring = [...square(5), ...square(3)]; + assert.ok(!polygonsTouchArea([ring], box), 'the area sits in a hole'); + const circle = await resolveArea({ lat: 0, lon: 0, radius_km: 20 }); + assert.ok(polygonsTouchArea([square(5)], circle)); +}); diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index 7def1f87d..141a8e70c 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -10,6 +10,7 @@ import { areaCenter, areaContains, distanceKm, + polygonsTouchArea, resolveArea, resolvePoint, } from '../area.js'; @@ -237,8 +238,9 @@ export const getFirePerimeters = defineTool({ async run(args, { services, signal }) { const area = await resolveArea(args.area, { services, signal }); const rows = (await services.perimeters.getSnapshot({ signal })) + .filter((fire) => polygonsTouchArea(fire.polygons, area)) .map((fire) => ({ fire, point: polygonCenter(fire.polygons) })) - .filter(({ point }) => point && areaContains(area, point)) + .filter(({ point }) => point) .map(({ fire, point }) => ({ id: fire.stableId, name: fire.name, diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index 2a7a82982..b26c615bf 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -665,3 +665,32 @@ test('brief sections use a named place at its own point', async () => { assert.deepEqual(queries[0], { latitude: 35.6769, longitude: 139.7639 }); assert.match(result.data.sections.aircraft.summary, /in Tokyo, Japan\.$/); }); + +test('a fire perimeter enclosing the whole area is found', async () => { + const perimeters = { + getSnapshot: async () => [ + { + stableId: 'big', + name: 'Big Fire', + acres: 250000, + polygons: [ + [ + [ + [-122, 38], + [-120, 38], + [-120, 40], + [-122, 40], + ], + ], + ], + }, + ], + }; + const result = await catalogWith({ perimeters }).call('get_fire_perimeters', { + area: { bbox: [-121.2, 38.9, -120.8, 39.1] }, + }); + assert.deepEqual( + result.data.rows.map((row) => row.id), + ['big'], + ); +}); From 56f0d2e2030a0cc32ffbb207b0d2a8a0459366ef Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 15:21:00 -0700 Subject: [PATCH 043/111] fix: carry weather and fire freshness into tool answers get_weather dropped the proxy's stale status and get_active_fires dropped FIRMS staleness and failed satellite sources, so answers and the HUD caption read them as current. Both now report them, and the caption context marks such sections stale or degraded. --- src/tools/queries/brief.js | 8 +++-- src/tools/queries/environment.js | 13 +++++-- src/tools/queries/environment.test.mjs | 48 ++++++++++++++++++++++++++ src/tools/queries/hazards.js | 17 ++++++++- 4 files changed, 80 insertions(+), 6 deletions(-) diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index cf6161693..e1900873d 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -90,9 +90,11 @@ const AWARENESS_SECTIONS = [ function sectionFeedState(section) { if (section.unavailable) return 'unavailable'; const data = section.data || {}; - return data.freshness === 'stale' || data.stale === true - ? 'stale' - : 'nominal'; + if (data.freshness === 'stale' || data.stale === true) return 'stale'; + // Answered, but some of the feeds or satellites behind it did not. + if (data.missing_sources?.length || data.unavailable_feeds?.length) + return 'degraded'; + return 'nominal'; } /** diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index 141a8e70c..fab605431 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -104,9 +104,18 @@ export const getWeather = defineTool({ signal, }); const row = weatherRow(payload?.weather); + // The proxy answers from its cache, marked stale, when a refresh fails. + const stale = payload?.status === 'stale'; return { - summary: `Weather at ${point.label}: ${describeWeather(row)}.`, - data: { location: point, weather: row }, + summary: + `Weather at ${point.label}: ${describeWeather(row)}` + + (stale ? ' (data may be stale).' : '.'), + data: { + location: point, + weather: row, + stale, + retrieved_at: payload?.retrievedAt ?? null, + }, }; }, }); diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index b26c615bf..617d156f2 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -694,3 +694,51 @@ test('a fire perimeter enclosing the whole area is found', async () => { ['big'], ); }); + +test('stale weather and partial fire data reach answers and the HUD caption', async () => { + const sent = []; + const summary = { + async summarize(context) { + sent.push(context); + return { ok: true, status: 200, data: { summary: 'Austin STALE' } }; + }, + }; + const staleWeather = { + getConditions: async () => ({ status: 'stale', weather: conditions }), + }; + const fires = { + getSnapshot: async () => ({ + stale: false, + fetchedAt: Date.UTC(2026, 0, 1), + sources: [ + { source: 'VIIRS_NOAA20_NRT', ok: true }, + { source: 'VIIRS_SNPP_NRT', ok: false }, + ], + fires: [], + }), + }; + const catalog = catalogWith({ + weather: staleWeather, + summary, + places, + fires, + }); + const weatherResult = await catalog.call('get_weather', { + location: { place: 'Austin' }, + }); + assert.equal(weatherResult.data.stale, true); + assert.match(weatherResult.summary, /\(data may be stale\)\.$/); + const fireResult = await catalog.call('get_active_fires', { + area: { place: 'Austin' }, + }); + assert.deepEqual(fireResult.data.missing_sources, ['VIIRS_SNPP_NRT']); + assert.match(fireResult.summary, /\(no data from VIIRS_SNPP_NRT\)\.$/); + await catalog.call('get_hud_caption', { area: { place: 'Austin' } }); + assert.deepEqual( + sent[0].enabledLayers.map((layer) => [layer.id, layer.feedState]), + [ + ['weather', 'stale'], + ['fires', 'degraded'], + ], + ); +}); diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index 15e80507a..c841d197b 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -78,9 +78,24 @@ export const getActiveFires = defineTool({ .filter((fire) => areaContains(area, fire)) .sort((a, b) => (b.frp ?? 0) - (a.frp ?? 0)); const result = capRows(fires, args.limit); + // The proxy serves its last good snapshot when a refresh fails, and marks + // satellites whose download failed. + const stale = snapshot.stale === true; + const missing = (snapshot.sources || []) + .filter((source) => source?.ok === false) + .map((source) => source.source); + const notes = [ + ...(stale ? ['data may be stale'] : []), + ...(missing.length ? [`no data from ${missing.join(', ')}`] : []), + ]; return { - summary: `${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours.`, + summary: + `${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours` + + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { + stale, + missing_sources: missing, + fetched_at: isoTime(snapshot.fetchedAt), ...result, rows: result.rows.map((fire) => ({ lat: fire.lat, From 302a1c1995e10393c68df1e18833d88a0940c921 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 15:24:01 -0700 Subject: [PATCH 044/111] fix: say when bike share, transit and place limits leave results out Bike share and transit read only the three nearest systems, and place search returns at most five matches within 50 km, while answers read as complete. Answers now name the systems not searched and say when a place search limit may have left places out. --- src/tools/queries/mobility.js | 33 +++++++++++++++++++----- src/tools/queries/mobility.test.mjs | 24 +++++++++++++++++ src/tools/queries/places.js | 40 +++++++++++++++++++++++++---- src/tools/queries/places.test.mjs | 26 +++++++++++++++++++ 4 files changed, 112 insertions(+), 11 deletions(-) diff --git a/src/tools/queries/mobility.js b/src/tools/queries/mobility.js index 055889a7c..b4743e81f 100644 --- a/src/tools/queries/mobility.js +++ b/src/tools/queries/mobility.js @@ -24,18 +24,32 @@ const MAX_SYSTEMS = 3; const round = (value, digits) => Number.isFinite(value) ? Number(value.toFixed(digits)) : null; -/** Systems or feeds whose coverage reaches the area, nearest first. */ +/** + * Systems or feeds whose coverage reaches the area, nearest first: the + * nearest MAX_SYSTEMS are read and the rest are returned as `skipped`. + */ function covering(area, entries, centerOf, radiusOf) { const center = areaCenter(area); const reach = areaRadiusKm(area); - return entries + const reaching = entries .map((entry) => ({ entry, distance: distanceKm(center, centerOf(entry)) })) .filter(({ entry, distance }) => distance <= reach + radiusOf(entry)) .sort((a, b) => a.distance - b.distance) - .slice(0, MAX_SYSTEMS) .map(({ entry }) => entry); + return { + selected: reaching.slice(0, MAX_SYSTEMS), + skipped: reaching.slice(MAX_SYSTEMS), + }; } +/** A note naming systems left out of an answer, or an empty list. */ +const skippedNote = (skipped, nameOf) => + skipped.length + ? [ + `${skipped.length} more not searched: ${skipped.map(nameOf).join(', ')}; use a smaller area`, + ] + : []; + export const getBikeShare = defineTool({ name: 'get_bike_share', title: 'Bike-share stations', @@ -52,7 +66,7 @@ export const getBikeShare = defineTool({ async run(args, { services, signal }) { const area = await resolveArea(args.area, { services, signal }); const { bikeshare } = services; - const systems = covering( + const { selected: systems, skipped } = covering( area, bikeshare.systems, (system) => ({ lat: system.centerLat, lon: system.centerLon }), @@ -110,14 +124,19 @@ export const getBikeShare = defineTool({ const failed = results.filter( (result) => result.status === 'rejected', ).length; + const notes = [ + ...(failed ? [`${countNoun(failed, 'system')} unavailable`] : []), + ...skippedNote(skipped, (system) => system.city), + ]; return { summary: `${countNoun(rows.length, 'bike-share station')} in ${area.label} with ` + `${countNoun(bikes, 'bike')} available` + - (failed ? ` (${countNoun(failed, 'system')} unavailable).` : '.'), + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { ...capRows(rows, args.limit), systems: systems.map((system) => system.city), + systems_not_searched: skipped.map((system) => system.city), bikes_available: bikes, }, }; @@ -149,7 +168,7 @@ export const getTransitVehicles = defineTool({ requires: ['transit'], async run(args, { services, signal }) { const area = await resolveArea(args.area, { services, signal }); - const feeds = covering( + const { selected: feeds, skipped } = covering( area, await services.transit.getFeeds({ signal }), (feed) => feed.center, @@ -248,6 +267,7 @@ export const getTransitVehicles = defineTool({ `${countNoun(staleFeeds, 'feed')} stale; positions may be out of date`, ] : []), + ...skippedNote(skipped, (feed) => feed.name), ]; const what = args.route ? ` on route ${args.route}` : ''; return { @@ -258,6 +278,7 @@ export const getTransitVehicles = defineTool({ ...capRows(rows, args.limit), stale: staleFeeds > 0, expired_positions_dropped: expired, + feeds_not_searched: skipped.map((feed) => feed.name), feeds: feeds.map((feed, index) => ({ name: feed.name, status: diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 11510f01a..7b41e7085 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { composeCatalog, coreTools } from '../index.js'; +import { GBFS_CITY_REGISTRY } from '../../layers/bikeshare/registry.js'; const system = (id, city, centerLat, centerLon) => ({ id, @@ -404,3 +405,26 @@ test('transit marks stale feeds and drops expired positions', async () => { '1 transit vehicle in 2 km around 30.267, -97.743 (1 feed stale; positions may be out of date).', ); }); + +test('bike share names the systems it did not search', async () => { + const empty = { data: { stations: [] } }; + const catalog = composeCatalog({ + tools: coreTools, + services: { + bikeshare: { + systems: GBFS_CITY_REGISTRY, + getStations: async () => empty, + }, + }, + }); + // New York, Philadelphia, Washington and Boston. + const result = await catalog.call('get_bike_share', { + area: { bbox: [-77.5, 38.5, -70.5, 42.8] }, + }); + assert.equal(result.data.systems.length, 3); + assert.ok(result.data.systems_not_searched.length >= 1); + assert.match( + result.summary, + /more not searched: .*; use a smaller area\)\.$/, + ); +}); diff --git a/src/tools/queries/places.js b/src/tools/queries/places.js index c6532832b..e6ec3a5e5 100644 --- a/src/tools/queries/places.js +++ b/src/tools/queries/places.js @@ -12,6 +12,12 @@ import { } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, thinEvenly } from '../results.js'; +// Most results the app's place search endpoints return, and the widest +// radius a text search covers. +const SEARCH_RESULTS = 5; +const NEARBY_RESULTS = 20; +const MAX_SEARCH_RADIUS_M = 50_000; + const MODES = { walk: 'foot', drive: 'car', bike: 'bike' }; const MAX_ROUTE_POINTS = 100; const NOT_CONFIGURED = @@ -52,8 +58,9 @@ export const searchPlaces = defineTool({ async run(args, { services, signal }) { const area = await resolveArea(args.area, { services, signal }); const center = areaCenter(area); + const wantedM = areaRadiusKm(area) * 1000; const radiusM = Math.round( - Math.min(50000, Math.max(500, areaRadiusKm(area) * 1000)), + Math.min(MAX_SEARCH_RADIUS_M, Math.max(500, wantedM)), ); const result = await services.placeSearch.search( args.query, @@ -66,9 +73,27 @@ export const searchPlaces = defineTool({ areaContains(area, { lat: place.latitude, lon: place.longitude }), ) .map(placeRow); + // The search returns a few matches within a capped radius; say so when + // either limit may have left places out. + const limited = result.places.length >= SEARCH_RESULTS; + const narrowed = wantedM > MAX_SEARCH_RADIUS_M; + const notes = [ + ...(limited + ? [`the search returns at most ${SEARCH_RESULTS} matches`] + : []), + ...(narrowed + ? [`searched within ${MAX_SEARCH_RADIUS_M / 1000} km of the center`] + : []), + ]; return { - summary: `${countNoun(rows.length, 'place')} matching "${args.query}" in ${area.label}.`, - data: capRows(rows, args.limit), + summary: + `${countNoun(rows.length, 'place')} matching "${args.query}" in ${area.label}` + + (notes.length ? ` (${notes.join('; ')}).` : '.'), + data: { + ...capRows(rows, args.limit), + may_have_more: limited || narrowed, + searched_radius_km: radiusM / 1000, + }, }; }, }); @@ -99,9 +124,14 @@ export const placesNearby = defineTool({ ); if (!result.configured) throw new ToolError('unavailable', NOT_CONFIGURED); const rows = result.places.map(placeRow); + const limited = result.places.length >= NEARBY_RESULTS; return { - summary: `${countNoun(rows.length, 'place')} within ${radiusM} m of ${point.label}.`, - data: capRows(rows, args.limit), + summary: + `${countNoun(rows.length, 'place')} within ${radiusM} m of ${point.label}` + + (limited + ? ` (nearby search returns at most ${NEARBY_RESULTS} places).` + : '.'), + data: { ...capRows(rows, args.limit), may_have_more: limited }, }; }, }); diff --git a/src/tools/queries/places.test.mjs b/src/tools/queries/places.test.mjs index ab4a0a0e2..89cad9ba8 100644 --- a/src/tools/queries/places.test.mjs +++ b/src/tools/queries/places.test.mjs @@ -248,3 +248,29 @@ test('the place and route services read the app routes and translate failures', (error) => error.code === 'retry_later' && error.retryAfterSeconds === 5, ); }); + +test('place answers say when the search limits may have left places out', async () => { + const five = Array.from({ length: 5 }, (_, index) => + place(`p${index}`, 30.27, -97.74), + ); + const catalog = composeCatalog({ + tools: coreTools, + services: { + placeSearch: placeSearch({ configured: true, places: five }), + places, + }, + }); + const capped = await catalog.call('search_places', { + query: 'coffee', + area: { place: 'Austin' }, + limit: 100, + }); + assert.equal(capped.data.may_have_more, true); + assert.match(capped.summary, /the search returns at most 5 matches\)\.$/); + const wide = await catalog.call('search_places', { + query: 'coffee', + area: { lat: 30.27, lon: -97.74, radius_km: 200 }, + }); + assert.equal(wide.data.searched_radius_km, 50); + assert.match(wide.summary, /searched within 50 km of the center/); +}); From 9e4cc9095b1827ff8a9dc9a78ae8afe75e5325b4 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 15:27:25 -0700 Subject: [PATCH 045/111] fix: measure traffic inside the requested circle and report missing tiles Traffic figures for a radius area counted roads anywhere in its bounding box, and a tile that failed to load silently changed them. Roads are now cut to the circle, and the flow source reports per request whether any tile failed so the answer can say its figures are partial. --- src/layers/traffic/flowSource.js | 38 ++++++++----- src/tools/queries/mobility.js | 87 ++++++++++++++++++++++++++--- src/tools/queries/mobility.test.mjs | 53 ++++++++++++++++++ 3 files changed, 155 insertions(+), 23 deletions(-) diff --git a/src/layers/traffic/flowSource.js b/src/layers/traffic/flowSource.js index 18d0b9863..49567a9ee 100644 --- a/src/layers/traffic/flowSource.js +++ b/src/layers/traffic/flowSource.js @@ -25,21 +25,31 @@ export function createFlowTileSource({ maxTiles: 16, }); let partial = false; + /** + * Flow segments for bounds, with whether any covering tile failed to load + * for this request. + */ + async function fetchFlowDetail(bounds, { signal, zoom = 12 } = {}) { + const result = await tiles.fetchBounds(bounds, { + zoom, + signal, + tiles: bounds.coverage?.coarse, + }); + partial = result.partial; + const segments = bounds.coverage + ? result.tiles.flat() + : result.tiles.flat().flatMap((segment) => + clipTileLine(segment.coords, bounds).map((coords) => ({ + ...segment, + coords, + })), + ); + return { segments, partial: result.partial === true }; + } return { - async fetchFlowForBounds(bounds, { signal, zoom = 12 } = {}) { - const result = await tiles.fetchBounds(bounds, { - zoom, - signal, - tiles: bounds.coverage?.coarse, - }); - partial = result.partial; - if (bounds.coverage) return result.tiles.flat(); - return result.tiles.flat().flatMap((segment) => - clipTileLine(segment.coords, bounds).map((coords) => ({ - ...segment, - coords, - })), - ); + fetchFlowDetail, + async fetchFlowForBounds(bounds, options) { + return (await fetchFlowDetail(bounds, options)).segments; }, getFlowSessionStats: () => ({ ...tiles.getStats(), partial }), resetFlowTileCache: () => tiles.clear(), diff --git a/src/tools/queries/mobility.js b/src/tools/queries/mobility.js index b4743e81f..2b5c735e2 100644 --- a/src/tools/queries/mobility.js +++ b/src/tools/queries/mobility.js @@ -310,6 +310,55 @@ function lineKm(coords) { return total; } +/** + * The parts of a `[lon, lat]` line inside an area. Box areas keep the whole + * line (tiles are already clipped to the box); radius areas cut each segment + * where it crosses the circle, by bisection. + */ +function insideParts(coords, area) { + if (!area.center) return [coords]; + const inside = ([lon, lat]) => areaContains(area, { lat, lon }); + // Where the segment from-to crosses the circle, on its inside, found by + // bisection. One end must be inside and the other outside. + const edge = (from, to) => { + let a = from; + let b = to; + for (let step = 0; step < 20; step += 1) { + const middle = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; + if (inside(middle) === inside(from)) a = middle; + else b = middle; + } + return inside(from) ? a : b; + }; + const parts = []; + let current = inside(coords[0]) ? [coords[0]] : null; + for (let index = 1; index < coords.length; index += 1) { + const from = coords[index - 1]; + const to = coords[index]; + const fromIn = inside(from); + const toIn = inside(to); + if (fromIn && toIn) current.push(to); + else if (fromIn) { + current.push(edge(from, to)); + parts.push(current); + current = null; + } else if (toIn) current = [edge(from, to), to]; + else { + // Both ends outside: the segment may still cut across the circle. + const middle = Array.from({ length: 15 }, (_, step) => { + const t = (step + 1) / 16; + return [ + from[0] + (to[0] - from[0]) * t, + from[1] + (to[1] - from[1]) * t, + ]; + }).find(inside); + if (middle) parts.push([edge(middle, from), edge(middle, to)]); + } + } + if (current) parts.push(current); + return parts.filter((part) => part.length > 1); +} + async function readFlow(traffic, area, signal) { const box = { west: area.west, @@ -319,7 +368,12 @@ async function readFlow(traffic, area, signal) { }; for (const zoom of FLOW_ZOOMS) { try { - return await traffic.fetchFlowForBounds(box, { zoom, signal }); + if (traffic.fetchFlowDetail) + return await traffic.fetchFlowDetail(box, { zoom, signal }); + return { + segments: await traffic.fetchFlowForBounds(box, { zoom, signal }), + partial: false, + }; } catch (error) { if (error?.code !== 'TILE_VIEW_TOO_WIDE') throw error; } @@ -357,9 +411,19 @@ export const getTrafficFlow = defineTool({ 'unavailable', 'Live traffic needs a TomTom key configured for this server', ); - const segments = (await readFlow(services.traffic, area, signal)).filter( - (segment) => Array.isArray(segment.coords) && segment.coords.length > 1, - ); + const flow = await readFlow(services.traffic, area, signal); + // Tiles cover the area's box; a radius area keeps only the parts of each + // road inside its circle. + const segments = flow.segments + .filter( + (segment) => Array.isArray(segment.coords) && segment.coords.length > 1, + ) + .flatMap((segment) => + insideParts(segment.coords, area).map((coords) => ({ + ...segment, + coords, + })), + ); const categories = new Map(); let measuredKm = 0; let weighted = 0; @@ -398,13 +462,18 @@ export const getTrafficFlow = defineTool({ })) .sort((a, b) => b.road_km - a.road_km); return { - summary: segments.length - ? `Traffic in ${area.label}: ${speedPct ?? 'unknown'}% of free-flow speed on average; ` + - `${round(congestedKm, 1)} km congested of ${round(measuredKm, 1)} km measured` + - (closedKm > 0 ? `; ${round(closedKm, 1)} km closed.` : '.') - : `No live traffic is reported in ${area.label}.`, + summary: + (segments.length + ? `Traffic in ${area.label}: ${speedPct ?? 'unknown'}% of free-flow speed on average; ` + + `${round(congestedKm, 1)} km congested of ${round(measuredKm, 1)} km measured` + + (closedKm > 0 ? `; ${round(closedKm, 1)} km closed.` : '.') + : `No live traffic is reported in ${area.label}.`) + + (flow.partial + ? ' Some map tiles did not load, so these figures are partial.' + : ''), data: { area: area.label, + partial: flow.partial, speed_pct_of_free_flow: speedPct, measured_km: round(measuredKm, 1), congested_km: round(congestedKm, 1), diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 7b41e7085..1d3212ff7 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -428,3 +428,56 @@ test('bike share names the systems it did not search', async () => { /more not searched: .*; use a smaller area\)\.$/, ); }); + +test('traffic counts only roads inside a radius area and reports missing tiles', async () => { + const flow = (segments, partial = false) => ({ + getStatus: async () => ({ hasKey: true }), + fetchFlowDetail: async () => ({ segments, partial }), + }); + const call = (traffic, area) => + composeCatalog({ tools: coreTools, services: { traffic } }).call( + 'get_traffic_flow', + { area }, + ); + const circle = { lat: 0, lon: 0, radius_km: 10 }; + // In the circle's bounding box, but about 12.6 km from its center. + const corner = [ + { + coords: [ + [0.08, 0.08], + [0.085, 0.085], + ], + closure: true, + trafficLevel: 1, + }, + ]; + const outside = await call(flow(corner), circle); + assert.equal(outside.data.closed_km, 0); + assert.equal(outside.data.measured_km, 0); + // A 20 km road through the center keeps the 20 km inside the circle. + const across = [ + { + coords: [ + [-0.2, 0], + [0.2, 0], + ], + trafficLevel: 1, + }, + ]; + const crossing = await call(flow(across), circle); + assert.ok(Math.abs(crossing.data.measured_km - 20) < 0.2); + // A road cutting the circle with both ends outside it. + const chord = [ + { + coords: [ + [-0.2, 0.05], + [0.2, 0.05], + ], + trafficLevel: 1, + }, + ]; + assert.ok((await call(flow(chord), circle)).data.measured_km > 10); + const partial = await call(flow(across, true), circle); + assert.equal(partial.data.partial, true); + assert.match(partial.summary, /Some map tiles did not load/); +}); From 13f8fa7c8e23880de9b578fb128fae7cd3367013 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 15:30:23 -0700 Subject: [PATCH 046/111] fix: wait briefly for installation names before answering Installation tile results can arrive before the name pack, and the tool answered with generic names such as "Military area" where a moment later it would say "Camp Mabry". It now waits up to five seconds for the named result and says when names are still loading. --- src/tools/queries/environment.js | 48 ++++++++++++++++++++++++-- src/tools/queries/environment.test.mjs | 38 ++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index fab605431..4c79dce5c 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -340,6 +340,33 @@ export const getTerrainHeight = defineTool({ }, }); +const NAMES_WAIT_MS = 5000; + +/** + * The value `promise` resolves to within `ms`, or null when it takes longer + * or fails. Rejects only when `signal` aborts. + */ +function settleWithin(promise, ms, signal) { + signal?.throwIfAborted(); + return new Promise((resolve, reject) => { + const done = (value) => { + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + resolve(value); + }; + const abort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + const timer = setTimeout(() => done(null), ms); + signal?.addEventListener('abort', abort, { once: true }); + Promise.resolve(promise).then( + (value) => done(value ?? null), + () => done(null), + ); + }); +} + export const findMilitaryInstallations = defineTool({ name: 'find_military_installations', title: 'Military installations', @@ -365,11 +392,23 @@ export const findMilitaryInstallations = defineTool({ `The area must be at most ${MAX_SITE_BOX_DEGREES}° on each side and not cross the antimeridian`, ); const center = areaCenter(area); - const result = await services.installations.getMappedSites(area, { + let result = await services.installations.getMappedSites(area, { exact: true, thinned: false, signal, }); + // Tile results can arrive before the name pack; wait briefly for the + // named version rather than answer with generic names. + let namesPending = false; + if (result.enrichment) { + const named = await settleWithin( + result.enrichment, + NAMES_WAIT_MS, + signal, + ); + if (named) result = named; + else namesPending = true; + } // A saturated source returned only part of the mapped sites. const complete = !result.saturated; const rows = (result.records || []) @@ -388,13 +427,18 @@ export const findMilitaryInstallations = defineTool({ distance_km: round(distanceKm(center, point), 1), })) .sort((a, b) => a.distance_km - b.distance_km); + const notes = [ + ...(complete ? [] : ['partial: the source returned only some sites']), + ...(namesPending ? ['site names are still loading'] : []), + ]; return { summary: `${countNoun(rows.length, 'mapped military installation')} in ${area.label}` + - (complete ? '.' : ' (partial: the source returned only some sites).'), + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { ...capRows(rows, args.limit), complete, + names_pending: namesPending, source: result.source ?? null, }, }; diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index 617d156f2..9dd3d0f8c 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -742,3 +742,41 @@ test('stale weather and partial fire data reach answers and the HUD caption', as ], ); }); + +test('installation names wait for the name pack, briefly', async (t) => { + const site = (name) => ({ + id: 'osm:9', + kind: 'installation', + name, + latitude: 30.31, + longitude: -97.76, + }); + const source = (enrichment) => ({ + getMappedSites: async () => ({ + source: 'OpenStreetMap tiles', + records: [site('Military area')], + enrichment, + }), + }); + const area = { lat: 30.31, lon: -97.76, radius_km: 5 }; + const named = await catalogWith({ + installations: source( + Promise.resolve({ + source: 'OpenStreetMap tiles', + records: [site('Camp Mabry')], + }), + ), + }).call('find_military_installations', { area }); + assert.equal(named.data.rows[0].name, 'Camp Mabry'); + assert.equal(named.data.names_pending, false); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const pending = catalogWith({ + installations: source(new Promise(() => {})), + }).call('find_military_installations', { area }); + await new Promise((resolve) => setImmediate(resolve)); + t.mock.timers.tick(5000); + const slow = await pending; + assert.equal(slow.data.rows[0].name, 'Military area'); + assert.equal(slow.data.names_pending, true); + assert.match(slow.summary, /site names are still loading\)\.$/); +}); From c4a4247f700eabeb2ec9e1d9fe9ed60caaf8e26e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 15:33:06 -0700 Subject: [PATCH 047/111] fix: cancel a local MCP tool call when its client disconnects The /mcp route built its Request without a signal, so a tool kept working after the client left. The request's signal now aborts when the connection closes before the answer is sent. --- server/mcp/plugin.js | 10 ++++++++ src/tools/localRoute.test.mjs | 43 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/server/mcp/plugin.js b/server/mcp/plugin.js index 4c5c4ae24..c1d3c6c94 100644 --- a/server/mcp/plugin.js +++ b/server/mcp/plugin.js @@ -64,6 +64,12 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) { ); return; } + // A client that disconnects before its answer cancels the tool call. + const disconnect = new AbortController(); + const onClose = () => { + if (!res.writableFinished) disconnect.abort(); + }; + res.on('close', onClose); try { const body = req.method === 'POST' ? await readBody(req) : undefined; const request = new Request(`http://${host}/mcp`, { @@ -72,11 +78,13 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) { value === undefined ? [] : [[name, String(value)]], ), body, + signal: disconnect.signal, }); const response = await handlerFor(`http://${host}`)(request); res.writeHead(response.status, Object.fromEntries(response.headers)); res.end(Buffer.from(await response.arrayBuffer())); } catch (error) { + if (disconnect.signal.aborted) return; if (res.headersSent) return res.destroy(); res.writeHead(error?.status === 413 ? 413 : 400, { 'Content-Type': 'application/json', @@ -87,6 +95,8 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) { error: error?.status === 413 ? 'Request too large' : 'Bad request', }), ); + } finally { + res.off('close', onClose); } }); }; diff --git a/src/tools/localRoute.test.mjs b/src/tools/localRoute.test.mjs index bdeb6a0e4..ed4129885 100644 --- a/src/tools/localRoute.test.mjs +++ b/src/tools/localRoute.test.mjs @@ -121,3 +121,46 @@ test('the /mcp route answers local MCP requests and refuses others', async (t) = 405, ); }); + +test('a client that disconnects cancels its tool call', async (t) => { + let middleware; + let seen; + const started = Promise.withResolvers(); + const aborted = Promise.withResolvers(); + const plugin = localMcpPlugin({ + createServer: () => ({ + handle: (message, { signal }) => { + seen = signal; + signal.addEventListener('abort', () => aborted.resolve(), { + once: true, + }); + started.resolve(); + return new Promise(() => {}); + }, + }), + }); + plugin.configureServer({ + middlewares: { use: (path, handler) => (middleware = handler) }, + }); + const http = createServer((req, res) => middleware(req, res)); + await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve)); + t.after(() => http.close()); + const { port } = http.address(); + const request = httpRequest({ + host: '127.0.0.1', + port, + path: '/mcp', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Host: `localhost:${port}`, + }, + }); + request.on('error', () => {}); + request.end(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call' })); + await started.promise; + assert.equal(seen.aborted, false); + request.destroy(); + await aborted.promise; + assert.equal(seen.aborted, true); +}); From 6a45a8f6a26ac1755d3aeb3ea1f95b6a2d2b659e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 15:34:53 -0700 Subject: [PATCH 048/111] fix: check only own properties when validating tool arguments Inherited names such as constructor, toString and __proto__ passed as declared properties despite additionalProperties: false, and a schema-valued additionalProperties was accepted but never enforced. Validation now uses own properties, and definitions must give additionalProperties as a boolean. --- src/tools/catalog.test.mjs | 29 +++++++++++++++++++++++++++++ src/tools/schema.js | 13 +++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/tools/catalog.test.mjs b/src/tools/catalog.test.mjs index 4e2a5d105..cdd492d16 100644 --- a/src/tools/catalog.test.mjs +++ b/src/tools/catalog.test.mjs @@ -203,3 +203,32 @@ test('the validator checks every supported keyword', () => { 'arguments.n must be a number', ]); }); + +test('inherited names are not declared properties, and schema-valued additionalProperties is refused', () => { + const schema = { + type: 'object', + properties: { name: { type: 'string' } }, + additionalProperties: false, + }; + for (const name of ['constructor', 'toString', '__proto__']) + assert.deepEqual(validateValue(schema, JSON.parse(`{"${name}": 1}`)), [ + `arguments has unknown property ${name}`, + ]); + assert.deepEqual(validateValue({ ...schema, required: ['toString'] }, {}), [ + 'arguments is missing toString', + ]); + assert.throws( + () => + defineTool({ + name: 'loose', + title: 'Loose', + description: 'Promises checks it cannot make.', + inputSchema: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + run: async () => ({ summary: '', data: {} }), + }), + /additionalProperties must be true or false/, + ); +}); diff --git a/src/tools/schema.js b/src/tools/schema.js index 942d6ff1f..7c40c3eb5 100644 --- a/src/tools/schema.js +++ b/src/tools/schema.js @@ -44,6 +44,12 @@ export function assertSupportedSchema(schema, path = 'schema') { if (!TYPES.has(schema.type)) throw new TypeError(`${path} has unsupported type ${schema.type}`); if (schema.pattern !== undefined) new RegExp(schema.pattern, 'u'); + // Only the boolean form is checked; a schema here would go unenforced. + if ( + schema.additionalProperties !== undefined && + typeof schema.additionalProperties !== 'boolean' + ) + throw new TypeError(`${path}.additionalProperties must be true or false`); for (const [name, child] of Object.entries(schema.properties || {})) assertSupportedSchema(child, `${path}.${name}`); if (schema.items) assertSupportedSchema(schema.items, `${path}[]`); @@ -63,11 +69,14 @@ export function validateValue(schema, value, path = 'arguments') { break; } for (const name of schema.required || []) { - if (value[name] === undefined) fail(`is missing ${name}`); + if (!Object.hasOwn(value, name) || value[name] === undefined) + fail(`is missing ${name}`); } + // Own properties only, so names such as constructor or __proto__ are + // not mistaken for declared ones. const properties = schema.properties || {}; for (const [name, child] of Object.entries(value)) { - if (properties[name]) + if (Object.hasOwn(properties, name)) problems.push( ...validateValue(properties[name], child, `${path}.${name}`), ); From e77943292590b9ed4bad2e64b46b80bad6c6c91b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 15:40:42 -0700 Subject: [PATCH 049/111] fix: report stale and partial data in more tool answers A sweep for the same pattern found more answers presented as current and complete: launches and satellite orbits served from a stale proxy cache, a regional aircraft fallback read as worldwide, license plate cameras trimmed or partly loaded, and a radio directory of popular stations read as every station. The launch and satellite sources now report the proxies' stale markers, and each answer says what it covers. --- src/layers/launches/source.js | 26 +++++++++----- src/layers/satellites/source.js | 5 ++- src/tools/queries/aviation.js | 9 ++++- src/tools/queries/aviation.test.mjs | 14 ++++++++ src/tools/queries/media.js | 18 ++++++++-- src/tools/queries/media.test.mjs | 28 ++++++++++++++- src/tools/queries/space.js | 35 ++++++++++++++----- src/tools/queries/space.test.mjs | 46 +++++++++++++++++++++++++ src/tools/queries/surveillance.js | 11 +++++- src/tools/queries/surveillance.test.mjs | 18 ++++++++++ 10 files changed, 185 insertions(+), 25 deletions(-) diff --git a/src/layers/launches/source.js b/src/layers/launches/source.js index 39a49e36c..2779e7d81 100644 --- a/src/layers/launches/source.js +++ b/src/layers/launches/source.js @@ -2,16 +2,24 @@ export function createLaunchSource({ fetchImpl = (...args) => globalThis.fetch(...args), } = {}) { + /** The launch payload, and whether the proxy served it from a stale cache. */ + async function getLaunchSnapshot({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/launches', { signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const payload = await response.json(); + signal?.throwIfAborted(); + if (!Array.isArray(payload) && !Array.isArray(payload?.results)) + throw new Error('Malformed launch snapshot'); + return { + payload, + stale: response.headers?.get?.('x-gev-cache') === 'STALE-ERROR', + }; + } return { - async getLaunches({ signal } = {}) { - signal?.throwIfAborted(); - const response = await fetchImpl('/api/launches', { signal }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const payload = await response.json(); - signal?.throwIfAborted(); - if (!Array.isArray(payload) && !Array.isArray(payload?.results)) - throw new Error('Malformed launch snapshot'); - return payload; + getLaunchSnapshot, + async getLaunches(options) { + return (await getLaunchSnapshot(options)).payload; }, async getActiveTle({ signal } = {}) { signal?.throwIfAborted(); diff --git a/src/layers/satellites/source.js b/src/layers/satellites/source.js index 8bcab7b02..e900d34b0 100644 --- a/src/layers/satellites/source.js +++ b/src/layers/satellites/source.js @@ -19,7 +19,10 @@ export function createSatelliteSource({ const response = await fetchImpl(`/api/celestrak/${group}`, { signal }); const text = response.ok ? await response.text() : ''; signal?.throwIfAborted(); - return { ok: response.ok, status: response.status, text }; + // The proxy serves its last copy, marked STALE-ERROR, when CelesTrak + // is down. + const stale = response.headers?.get?.('x-tle-cache') === 'STALE-ERROR'; + return { ok: response.ok, status: response.status, text, stale }; }, }; } diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 0bf520d0d..df904297c 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -118,10 +118,17 @@ export const aircraftInArea = defineTool({ .map((record) => aircraftRow(record, center)) .sort((a, b) => a.distance_km - b.distance_km); const kind = args.military ? 'military aircraft' : 'aircraft'; + // A regional fallback feed covers only part of the world around the + // area's center; say so rather than imply a worldwide answer. + const regional = /regional/i.test(snapshot.coverage || ''); + const notes = [ + ...(regional ? [`regional feed: ${snapshot.coverage}`] : []), + ...(snapshot.freshness === 'stale' ? ['data may be stale'] : []), + ]; return { summary: `${rows.length} ${kind} in ${area.label}` + - (snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'), + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) }, }; }, diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 7594029ce..4db854345 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -337,3 +337,17 @@ test('aircraft searches keep the feeds that answered and name the others', async error.code === 'unavailable' && error.message === 'OpenSky HTTP 503', ); }); + +test('a regional fallback feed is named in the answer', async () => { + const regional = feed([record('abc123', 37.6, -122.3)], { + source: 'adsb.lol', + coverage: '250nm regional fallback', + }); + const result = await composeCatalog({ + tools: coreTools, + services: { aircraft: regional }, + }).call('aircraft_in_area', { + area: { lat: 37.6, lon: -122.3, radius_km: 20 }, + }); + assert.match(result.summary, /\(regional feed: 250nm regional fallback\)\.$/); +}); diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js index a0a551196..b4d3bf38c 100644 --- a/src/tools/queries/media.js +++ b/src/tools/queries/media.js @@ -116,7 +116,8 @@ export const findRadioStations = defineTool({ name: 'find_radio_stations', title: 'Radio stations', description: - 'Internet radio stations from the Radio Browser directory, by area and/or ' + + 'Internet radio stations from a directory of popular Radio Browser ' + + 'stations, not every station, by area and/or ' + 'a search term matched against name, tags, language and country. Each ' + "result includes the broadcaster's public stream URL.", inputSchema: { @@ -186,9 +187,20 @@ export const findRadioStations = defineTool({ if (center) rows.sort((a, b) => a.distance_km - b.distance_km); const where = area ? ` in ${area.label}` : ''; const what = args.query ? ` matching "${args.query}"` : ''; + // The directory is a selection of popular stations, not every station. + const notes = [ + `from a directory of ${directory.stations.length} popular stations`, + ...(directory.stale ? ['the directory may be stale'] : []), + ...(directory.degraded ? ['the directory is incomplete right now'] : []), + ]; return { - summary: `${countNoun(rows.length, 'radio station')}${what}${where}.`, - data: capRows(rows, args.limit), + summary: `${countNoun(rows.length, 'radio station')}${what}${where} (${notes.join('; ')}).`, + data: { + ...capRows(rows, args.limit), + directory_size: directory.stations.length, + stale: directory.stale === true, + degraded: directory.degraded === true, + }, }; }, }); diff --git a/src/tools/queries/media.test.mjs b/src/tools/queries/media.test.mjs index a1916c111..a5c7a4a56 100644 --- a/src/tools/queries/media.test.mjs +++ b/src/tools/queries/media.test.mjs @@ -185,7 +185,12 @@ test('radio stations match area and every search term', async () => { const jazz = await catalog.call('find_radio_stations', { query: 'jazz norwegian', }); - assert.equal(jazz.summary, '1 radio station matching "jazz norwegian".'); + assert.equal( + jazz.summary, + '1 radio station matching "jazz norwegian" (from a directory of 3 popular stations).', + ); + assert.equal(jazz.data.directory_size, 3); + assert.equal(jazz.data.stale, false); assert.equal(jazz.data.rows[0].distance_km, undefined); const classicalTexas = await catalog.call('find_radio_stations', { query: 'classical texas', @@ -220,3 +225,24 @@ test('malformed image results are a programming error', async () => { /malformed images/, ); }); + +test('a stale or degraded radio directory is reported', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + radio: { + getDirectory: async () => ({ + stations: [], + stale: true, + degraded: true, + }), + }, + }, + }); + const result = await catalog.call('find_radio_stations', { query: 'jazz' }); + assert.match( + result.summary, + /the directory may be stale; the directory is incomplete right now\)\.$/, + ); + assert.equal(result.data.degraded, true); +}); diff --git a/src/tools/queries/space.js b/src/tools/queries/space.js index d02796429..ad65f80e8 100644 --- a/src/tools/queries/space.js +++ b/src/tools/queries/space.js @@ -25,7 +25,12 @@ export const getRecentLaunches = defineTool({ }, requires: ['launches'], async run(args, { services, signal }) { - const payload = await services.launches.getLaunches({ signal }); + const { payload, stale } = services.launches.getLaunchSnapshot + ? await services.launches.getLaunchSnapshot({ signal }) + : { + payload: await services.launches.getLaunches({ signal }), + stale: false, + }; const launches = Array.isArray(payload) ? payload : payload.results; if (!Array.isArray(launches)) throw new ToolError('malformed', 'The launch feed returned no launches'); @@ -49,8 +54,10 @@ export const getRecentLaunches = defineTool({ .sort((a, b) => String(b.time).localeCompare(String(a.time))); const result = capRows(rows, args.limit); return { - summary: `${countNoun(rows.length, 'launch', 'launches')} in the last 30 days${rows[0] ? `; latest ${rows[0].name}` : ''}.`, - data: result, + summary: + `${countNoun(rows.length, 'launch', 'launches')} in the last 30 days${rows[0] ? `; latest ${rows[0].name}` : ''}` + + (stale ? ' (data may be stale).' : '.'), + data: { ...result, stale }, }; }, }); @@ -103,7 +110,7 @@ async function readCatalog(services, group, signal) { 'unavailable', `The ${group} satellite catalog is unavailable (HTTP ${result.status})`, ); - return parseTleText(result.text).flatMap((entry) => { + const entries = parseTleText(result.text).flatMap((entry) => { try { return [ { @@ -116,6 +123,9 @@ async function readCatalog(services, group, signal) { return []; } }); + // The proxy serves its last copy when CelesTrak is down; old orbital + // elements make predictions drift. + return { entries, stale: result.stale === true }; } function matchSatellite(entries, wanted) { @@ -164,7 +174,8 @@ export const nextSatellitePass = defineTool({ async run(args, { services, signal }) { const point = await resolvePoint(args.location, { services, signal }); const group = args.group ?? 'stations'; - const entries = await readCatalog(services, group, signal); + const { entries, stale } = await readCatalog(services, group, signal); + const staleNote = stale ? ' (orbit data may be stale)' : ''; const satellite = matchSatellite(entries, args.satellite); if (!satellite) throw new ToolError( @@ -184,8 +195,9 @@ export const nextSatellitePass = defineTool({ const name = satellite.name; if (!pass) return { - summary: `${name} has no ${args.visible_only ? 'visible ' : ''}pass over ${point.label} in the next ${hours} hours.`, + summary: `${name} has no ${args.visible_only ? 'visible ' : ''}pass over ${point.label} in the next ${hours} hours${staleNote}.`, data: { + stale, location: point, satellite: name, norad: satellite.norad, @@ -196,8 +208,9 @@ export const nextSatellitePass = defineTool({ summary: `${name} next rises over ${point.label} at ${isoTime(pass.riseMs)} in the ${compass(pass.riseAzDeg)}, ` + `peaking at ${Math.round(pass.maxElevDeg)}°` + - `${pass.visible ? '; visible to the naked eye' : ''}.`, + `${pass.visible ? '; visible to the naked eye' : ''}${staleNote}.`, data: { + stale, location: point, satellite: name, norad: satellite.norad, @@ -238,7 +251,8 @@ export const satellitesOverhead = defineTool({ const group = args.group ?? 'stations'; const minimum = args.min_elevation_deg ?? 10; const at = nowMs(services); - const rows = (await readCatalog(services, group, signal)) + const { entries, stale } = await readCatalog(services, group, signal); + const rows = entries .flatMap((entry) => { const look = lookAnglesAt(entry.satrec, at, point.lat, point.lon); return look && look.elevDeg >= minimum @@ -255,9 +269,12 @@ export const satellitesOverhead = defineTool({ }) .sort((a, b) => b.elevation_deg - a.elevation_deg); return { - summary: `${countNoun(rows.length, 'satellite')} from the ${group} group ${rows.length === 1 ? 'is' : 'are'} at least ${minimum}° above ${point.label} at ${isoTime(at)}.`, + summary: + `${countNoun(rows.length, 'satellite')} from the ${group} group ${rows.length === 1 ? 'is' : 'are'} at least ${minimum}° above ${point.label} at ${isoTime(at)}` + + (stale ? ' (orbit data may be stale).' : '.'), data: { ...capRows(rows, args.limit), + stale, location: point, group, at: isoTime(at), diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 8f77fb041..376cf54d7 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -150,3 +150,49 @@ test('satellites overhead are those above the elevation, highest first', async ( /^0 satellites from the stations group are at least 90° above/, ); }); + +test('stale orbit and launch data are reported', async () => { + const stale = { + readGroup: async () => ({ ok: true, status: 200, text: TLE, stale: true }), + }; + const catalog = composeCatalog({ + tools: coreTools, + services: { + satellites: stale, + clock: CLOCK, + launches: { + getLaunchSnapshot: async () => ({ payload: [], stale: true }), + }, + }, + }); + const overhead = await catalog.call('satellites_overhead', { + location: { lat: 0, lon: 0 }, + }); + assert.equal(overhead.data.stale, true); + assert.match(overhead.summary, /\(orbit data may be stale\)\.$/); + const launches = await catalog.call('get_recent_launches', {}); + assert.equal(launches.data.stale, true); + assert.match(launches.summary, /\(data may be stale\)\.$/); +}); + +test('satellite and launch sources read the proxies stale markers', async () => { + const { createSatelliteSource } = + await import('../../layers/satellites/source.js'); + const { createLaunchSource } = + await import('../../layers/launches/source.js'); + const satellites = createSatelliteSource({ + fetchImpl: async () => + new Response(TLE, { headers: { 'x-tle-cache': 'STALE-ERROR' } }), + }); + assert.equal((await satellites.readGroup('stations')).stale, true); + const launches = createLaunchSource({ + fetchImpl: async () => + Response.json([], { headers: { 'X-GEV-Cache': 'STALE-ERROR' } }), + }); + assert.deepEqual(await launches.getLaunchSnapshot(), { + payload: [], + stale: true, + }); + const { getLaunches } = launches; + assert.deepEqual(await getLaunches(), []); +}); diff --git a/src/tools/queries/surveillance.js b/src/tools/queries/surveillance.js index d6fbd346a..6571036d5 100644 --- a/src/tools/queries/surveillance.js +++ b/src/tools/queries/surveillance.js @@ -77,12 +77,21 @@ export const findAlprCameras = defineTool({ distance_km: round(distanceKm(center, point), 2), })) .sort((a, b) => a.distance_km - b.distance_km); + const notes = [ + ...(result.saturated + ? ['partial: some cameras were not loaded; use a smaller area'] + : []), + ...(result.stale ? ['data may be stale'] : []), + ]; return { summary: `${countNoun(rows.length, 'license plate reader camera')} mapped in ${area.label}` + - (result.stale ? ' (data may be stale).' : '.'), + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { ...capRows(rows, args.limit), + // Some tiles failed, or a dense area was trimmed to the source limit. + complete: !result.saturated, + stale: result.stale === true, source: services.alpr.label ?? null, attribution: services.alpr.attribution ?? null, }, diff --git a/src/tools/queries/surveillance.test.mjs b/src/tools/queries/surveillance.test.mjs index 8b4a151bc..5c76a04f2 100644 --- a/src/tools/queries/surveillance.test.mjs +++ b/src/tools/queries/surveillance.test.mjs @@ -95,3 +95,21 @@ test('coverage, size and zoom limits are reported', async () => { /smaller, city-sized area/, ); }); + +test('a saturated camera fetch is reported as partial', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + alpr: alpr({ + records: [camera('alpr:1', 30.3, -97.7)], + stale: false, + saturated: true, + }), + }, + }); + const result = await catalog.call('find_alpr_cameras', { + area: { lat: 30.3, lon: -97.7, radius_km: 5 }, + }); + assert.equal(result.data.complete, false); + assert.match(result.summary, /partial: some cameras were not loaded/); +}); From 66b07418bd12a294427bc58fa9d59da700867064 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 15:44:33 -0700 Subject: [PATCH 050/111] fix: say when the camera catalog serves only part of a region Camera packs keep their nearest cameras and the catalog cap thins them further, so London answered 250 cameras while TfL offers many more. The catalog now records each pack's full count, tags cameras with their pack, and serves the packs it trimmed; find_cctv_cameras names them. --- server/providers/cctv.js | 2 ++ server/providers/cctv/catalog.js | 19 +++++++++++- server/providers/cctv/normalize.js | 5 +++- src/data/cctvCatalogCap.test.mjs | 20 +++++++++++++ src/tools/queries/media.js | 46 ++++++++++++++++++++++++------ src/tools/queries/media.test.mjs | 36 +++++++++++++++++++++++ 6 files changed, 117 insertions(+), 11 deletions(-) diff --git a/server/providers/cctv.js b/server/providers/cctv.js index a71e2af4d..9cf3b0c47 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -143,8 +143,10 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { if (url.pathname === '/sources') { const body = { + trimmedPacks: getCctvSources.trimmedPacks?.() ?? [], sources: sources.map((source) => ({ id: source.id, + pack: source.pack, name: source.name, city: source.city, cityId: source.cityId, diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index ae0ab9afa..cc258d7f4 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -143,6 +143,9 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { /** @type {Promise>|null} In-flight refresh, shared by concurrent * callers so a post-TTL burst launches ONE refetch, not one per request. */ let _cctvSourceInflight = null; + /** @type {Array<{pack: string, available: number, served: number}>} Packs + * that serve fewer cameras than they offer. */ + let _cctvTrimmedPacks = []; /** * Assemble and cache the merged CCTV source list. @@ -209,8 +212,11 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { name, sources: items .filter((item) => item && typeof item === 'object') - .map((item) => normalizeSourceItem(item)) + .map((item) => ({ ...normalizeSourceItem(item), pack: name })) .filter((item) => item.id), + available: Number.isFinite(items.available) + ? items.available + : items.length, }); const packs = [ ...LIVE_PACKS.map((pack, index) => @@ -234,6 +240,14 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { loadGroundHeights(sourceRoot), ); const trimmed = allocation.packs.filter((pack) => pack.kept < pack.offered); + // Each pack's own nearest-first cap and the shared catalog cap both trim. + const trimmedPacks = packs + .map((pack, index) => ({ + pack: pack.name, + available: Math.max(pack.available, allocation.packs[index].offered), + served: allocation.packs[index].kept, + })) + .filter((pack) => pack.served < pack.available); if (trimmed.length) { const detail = trimmed .map((pack) => `${pack.name} ${pack.kept}/${pack.offered}`) @@ -244,6 +258,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { } if (capped.length > 0 || _cctvSourceCache.length === 0) { _cctvSourceCache = capped; + _cctvTrimmedPacks = trimmedPacks; } else { // Every source came back empty (all live packs timed out / upstream outage) // but a good catalog is already cached — serve it stale rather than blanking @@ -257,5 +272,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { return _cctvSourceCache; } + /** Packs serving fewer cameras than they offer, as of the last refresh. */ + getCctvSources.trimmedPacks = () => _cctvTrimmedPacks; return getCctvSources; } diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index b564ef440..99cd54712 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -470,7 +470,10 @@ export function prioritizeSources(cameras, maxCount, anchors) { return a.idx - b.idx; }); - return scored.slice(0, cap).map((entry) => entry.camera); + const kept = scored.slice(0, cap).map((entry) => entry.camera); + // How many cameras the pack offered before trimming, for coverage reports. + Object.defineProperty(kept, 'available', { value: list.length }); + return kept; } /** diff --git a/src/data/cctvCatalogCap.test.mjs b/src/data/cctvCatalogCap.test.mjs index 6f1ff6c59..8e3c08f03 100644 --- a/src/data/cctvCatalogCap.test.mjs +++ b/src/data/cctvCatalogCap.test.mjs @@ -128,6 +128,10 @@ test('the catalog applies the fair cap to configured packs end to end', async () sources.slice(0, 3).map((s) => s.id), ['env-0', 'env-1', 'env-2'], ); + assert.equal(sources[0].pack, 'env'); + assert.deepEqual(getSources.trimmedPacks(), [ + { pack: 'env', available: 12, served: 9 }, + ]); } finally { for (const key of Object.keys(process.env)) { if (!(key in saved)) delete process.env[key]; @@ -135,3 +139,19 @@ test('the catalog applies the fair cap to configured packs end to end', async () Object.assign(process.env, saved); } }); + +test('a pack trimmed to its nearest cameras reports how many it offered', async () => { + const { prioritizeSources } = + await import('../../server/providers/cctv/normalize.js'); + const cameras = Array.from({ length: 5 }, (_, i) => ({ + id: `c${i}`, + lat: i, + lon: 0, + })); + const kept = prioritizeSources(cameras, 2, [{ lat: 0, lon: 0 }]); + assert.deepEqual( + kept.map((camera) => camera.id), + ['c0', 'c1'], + ); + assert.equal(kept.available, 5); +}); diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js index b4d3bf38c..45037ae1b 100644 --- a/src/tools/queries/media.js +++ b/src/tools/queries/media.js @@ -17,12 +17,22 @@ const round = (value, digits) => Number.isFinite(value) ? Number(value.toFixed(digits)) : null; const text = (value) => (typeof value === 'string' && value ? value : null); -async function readCameras(services, signal) { +async function readCatalog(services, signal) { const payload = await services.cctv.getCatalog({ signal }); - return payload.sources.filter( - (camera) => - camera?.id && Number.isFinite(camera.lat) && Number.isFinite(camera.lon), - ); + return { + cameras: payload.sources.filter( + (camera) => + camera?.id && + Number.isFinite(camera.lat) && + Number.isFinite(camera.lon), + ), + // Regional packs the catalog serves only part of, nearest their centers. + trimmed: Array.isArray(payload.trimmedPacks) ? payload.trimmedPacks : [], + }; +} + +async function readCameras(services, signal) { + return (await readCatalog(services, signal)).cameras; } export const findCctvCameras = defineTool({ @@ -41,8 +51,13 @@ export const findCctvCameras = defineTool({ async run(args, { services, signal }) { const area = await resolveArea(args.area, { services, signal }); const center = areaCenter(area); - const rows = (await readCameras(services, signal)) - .filter((camera) => areaContains(area, camera)) + const catalog = await readCatalog(services, signal); + const found = catalog.cameras.filter((camera) => + areaContains(area, camera), + ); + const packs = new Set(found.map((camera) => camera.pack).filter(Boolean)); + const trimmed = catalog.trimmed.filter((entry) => packs.has(entry.pack)); + const rows = found .map((camera) => ({ id: camera.id, name: text(camera.name), @@ -57,8 +72,21 @@ export const findCctvCameras = defineTool({ })) .sort((a, b) => a.distance_km - b.distance_km); return { - summary: `${countNoun(rows.length, 'public camera')} in ${area.label}.`, - data: capRows(rows, args.limit), + summary: + `${countNoun(rows.length, 'public camera')} in ${area.label}` + + (trimmed.length + ? ` (the catalog serves only some cameras here: ${trimmed + .map( + (entry) => + `${entry.served} of ${entry.available} from ${entry.pack}`, + ) + .join(', ')}).` + : '.'), + data: { + ...capRows(rows, args.limit), + complete: trimmed.length === 0, + catalog_trimmed: trimmed, + }, }; }, }); diff --git a/src/tools/queries/media.test.mjs b/src/tools/queries/media.test.mjs index a5c7a4a56..5b5a2e9c4 100644 --- a/src/tools/queries/media.test.mjs +++ b/src/tools/queries/media.test.mjs @@ -246,3 +246,39 @@ test('a stale or degraded radio directory is reported', async () => { ); assert.equal(result.data.degraded, true); }); + +test('cameras from a trimmed catalog pack are reported as partial', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + cctv: { + getCatalog: async () => ({ + trimmedPacks: [ + { pack: 'tfl', available: 900, served: 250 }, + { pack: 'other', available: 10, served: 5 }, + ], + sources: [ + { + id: 'jam-1', + name: 'Strand', + lat: 51.51, + lon: -0.12, + pack: 'tfl', + }, + ], + }), + }, + }, + }); + const result = await catalog.call('find_cctv_cameras', { + area: { lat: 51.51, lon: -0.12, radius_km: 5 }, + }); + assert.equal(result.data.complete, false); + assert.deepEqual(result.data.catalog_trimmed, [ + { pack: 'tfl', available: 900, served: 250 }, + ]); + assert.match( + result.summary, + /\(the catalog serves only some cameras here: 250 of 900 from tfl\)\.$/, + ); +}); From e1ac5ac270be4cf7ce63d5cf19c7afc4ade72d2e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 16:10:43 -0700 Subject: [PATCH 051/111] fix: cancel shared place lookups nobody is waiting for The place cache shared one lookup between callers but started it without a signal or deadline, so a cancelled request kept running and stayed cached. A shared lookup now has its own deadline, is cancelled when every caller has given up, and only a successful answer is kept. --- src/tools/places.js | 88 ++++++++++++++++++++++++++------------- src/tools/places.test.mjs | 56 +++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 29 deletions(-) diff --git a/src/tools/places.js b/src/tools/places.js index 97b4ce8f1..4a715f389 100644 --- a/src/tools/places.js +++ b/src/tools/places.js @@ -22,53 +22,83 @@ export function createGeocodePlaceService({ fetchImpl = (...args) => globalThis.fetch(...args), cacheSize = 200, cacheMs = 10 * 60_000, + timeoutMs = 15_000, now = () => Date.now(), } = {}) { // Recent answers by name, so tools that combine others resolve a place to - // the same point once instead of once per section. + // the same point once instead of once per section. A pending lookup is + // shared by its callers: it has its own deadline, is cancelled when every + // caller has given up, and only a successful answer stays cached. const cache = new Map(); - async function lookup(name) { + async function lookup(name, signal) { const query = new URLSearchParams({ q: name }); - const response = await fetchImpl(`/api/geocode?${query}`); + const response = await fetchImpl(`/api/geocode?${query}`, { signal }); if (!response.ok) throw new Error(`Geocode HTTP ${response.status}`); const payload = await response.json(); return placeFromGeocodeResult(payload?.results?.[0]); } + function start(key, name) { + const controller = new AbortController(); + const entry = { + at: now(), + controller, + waiters: 0, + settled: false, + place: lookup( + name, + AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]), + ), + }; + entry.place.then( + () => { + entry.settled = true; + }, + () => { + entry.settled = true; + if (cache.get(key) === entry) cache.delete(key); + }, + ); + cache.set(key, entry); + while (cache.size > cacheSize) cache.delete(cache.keys().next().value); + return entry; + } + function join(key, entry, signal) { + if (!signal) { + // A caller that cannot cancel keeps the lookup alive. + entry.waiters = Infinity; + return entry.place; + } + signal.throwIfAborted(); + entry.waiters += 1; + return new Promise((resolve, reject) => { + const leave = () => { + entry.waiters -= 1; + reject(signal.reason); + if (entry.waiters <= 0 && !entry.settled) { + entry.controller.abort(signal.reason); + if (cache.get(key) === entry) cache.delete(key); + } + }; + signal.addEventListener('abort', leave, { once: true }); + entry.place + .then(resolve, reject) + .finally(() => signal.removeEventListener('abort', leave)); + }); + } return { async resolve(name, { signal } = {}) { signal?.throwIfAborted(); const key = name.trim().toLowerCase(); - const hit = cache.get(key); - if (hit && now() - hit.at < cacheMs) { + let entry = cache.get(key); + if (entry && now() - entry.at < cacheMs) { cache.delete(key); - cache.set(key, hit); - return waitFor(hit.place, signal); - } - const place = lookup(name); - const entry = { at: now(), place }; - cache.set(key, entry); - while (cache.size > cacheSize) cache.delete(cache.keys().next().value); - place.catch(() => { - if (cache.get(key) === entry) cache.delete(key); - }); - return waitFor(place, signal); + cache.set(key, entry); + } else entry = start(key, name); + return join(key, entry, signal); }, }; } -/** Settle with `promise`, or reject when `signal` aborts first. */ -function waitFor(promise, signal) { - if (!signal) return promise; - signal.throwIfAborted(); - return new Promise((resolve, reject) => { - const abort = () => reject(signal.reason); - signal.addEventListener('abort', abort, { once: true }); - promise - .then(resolve, reject) - .finally(() => signal.removeEventListener('abort', abort)); - }); -} - /** Convert one geocode result to a place with bounds, or null. */ export function placeFromGeocodeResult(result) { const location = result?.geometry?.location; diff --git a/src/tools/places.test.mjs b/src/tools/places.test.mjs index 63fdeb28a..f4391269a 100644 --- a/src/tools/places.test.mjs +++ b/src/tools/places.test.mjs @@ -37,3 +37,59 @@ test('place lookups are cached by name for a while, and failures are not', async aborted.abort(); await assert.rejects(places.resolve('Tokyo', { signal: aborted.signal })); }); + +test('a lookup every caller abandoned is cancelled and not reused', async () => { + const signals = []; + const places = createGeocodePlaceService({ + fetchImpl: (url, { signal }) => { + signals.push(signal); + if (signals.length === 1) + return new Promise((resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ); + return Promise.resolve(Response.json(answer)); + }, + }); + const caller = new AbortController(); + const first = places.resolve('Tokyo', { signal: caller.signal }); + caller.abort(); + await assert.rejects(first); + assert.equal(signals[0].aborted, true); + const again = await places.resolve('Tokyo'); + assert.deepEqual(again.point, { lat: 35.68, lon: 139.76 }); + assert.equal(signals.length, 2); +}); + +test('a shared lookup outlives one cancelled caller, and times out on its own', async () => { + let release; + const signals = []; + const places = createGeocodePlaceService({ + timeoutMs: 50, + fetchImpl: (url, { signal }) => { + signals.push(signal); + if (url.includes('Slow')) + return new Promise((resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ); + return new Promise((resolve) => { + release = () => resolve(Response.json(answer)); + }); + }, + }); + const leaving = new AbortController(); + const gone = places.resolve('Tokyo', { signal: leaving.signal }); + const staying = places.resolve('Tokyo', { + signal: new AbortController().signal, + }); + leaving.abort(); + await assert.rejects(gone); + assert.equal(signals[0].aborted, false); + release(); + assert.deepEqual((await staying).point, { lat: 35.68, lon: 139.76 }); + await assert.rejects( + places.resolve('Slow'), + (error) => error.name === 'TimeoutError', + ); + await places.resolve('Slow').catch(() => {}); + assert.equal(signals.length, 3, 'a timed-out lookup is not cached'); +}); From 4cfca57cfbacaaced36aaad356013aa25925efcc Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 16:19:28 -0700 Subject: [PATCH 052/111] fix: bound large boxes by their farthest point on the sphere A box's radius was the distance to its farthest corner, which falls short for large boxes: a whole-world box measured about 10,000 km, so bike-share and transit selection never considered systems beyond it. The radius now checks whether the box holds the point opposite its center and otherwise samples every edge. --- src/tools/area.js | 40 +++++++++++++++++++++++------ src/tools/area.test.mjs | 17 ++++++++++++ src/tools/queries/mobility.test.mjs | 15 +++++++++++ 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/tools/area.js b/src/tools/area.js index dbd3aabb6..4af62a03c 100644 --- a/src/tools/area.js +++ b/src/tools/area.js @@ -142,14 +142,38 @@ export async function resolvePoint(point, { services, signal } = {}) { export function areaRadiusKm(area) { if (area.center) return area.center.radiusKm; const center = areaCenter(area); - return Math.max( - ...[ - { lat: area.north, lon: area.west }, - { lat: area.north, lon: area.east }, - { lat: area.south, lon: area.west }, - { lat: area.south, lon: area.east }, - ].map((corner) => distanceKm(center, corner)), - ); + // A box holding the point opposite its center reaches half the globe. + const antipode = { + lat: -center.lat, + lon: center.lon > 0 ? center.lon - 180 : center.lon + 180, + }; + if (areaContains(area, antipode)) return Math.PI * EARTH_RADIUS_KM; + // Otherwise the farthest point is on the boundary: sample each edge, and + // include where each parallel edge comes nearest the antipode's longitude. + const width = + area.west <= area.east + ? area.east - area.west + : 360 - (area.west - area.east); + const lonAt = (t) => area.west + width * t; + const steps = 64; + const points = []; + for (let step = 0; step <= steps; step += 1) { + const t = step / steps; + const lat = area.south + (area.north - area.south) * t; + points.push( + { lat, lon: area.west }, + { lat, lon: area.east }, + { lat: area.north, lon: lonAt(t) }, + { lat: area.south, lon: lonAt(t) }, + ); + } + const offset = (((antipode.lon - area.west) % 360) + 360) % 360; + if (offset <= width) + points.push( + { lat: area.north, lon: antipode.lon }, + { lat: area.south, lon: antipode.lon }, + ); + return Math.max(...points.map((point) => distanceKm(center, point))); } /** Great-circle distance in kilometers. */ diff --git a/src/tools/area.test.mjs b/src/tools/area.test.mjs index d901813b5..7ec551aae 100644 --- a/src/tools/area.test.mjs +++ b/src/tools/area.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test'; import { areaCenter, areaContains, + areaRadiusKm, distanceKm, lineTouchesArea, polygonsTouchArea, @@ -245,3 +246,19 @@ test('polygons touch an area by vertex, crossing edge or enclosing it', async () const circle = await resolveArea({ lat: 0, lon: 0, radius_km: 20 }); assert.ok(polygonsTouchArea([square(5)], circle)); }); + +test('a box radius reaches every point of the box, however large', async () => { + const world = await resolveArea({ bbox: [-180, -90, 180, 90] }); + assert.ok(Math.abs(areaRadiusKm(world) - Math.PI * 6371.0088) < 1); + const wide = await resolveArea({ bbox: [-60, -70, 100, 60] }); + const radius = areaRadiusKm(wide); + const center = areaCenter(wide); + for (let lat = -70; lat <= 60; lat += 2.5) + for (let lon = -60; lon <= 100; lon += 2.5) + assert.ok( + distanceKm(center, { lat, lon }) <= radius + 0.5, + `${lat},${lon}`, + ); + const small = await resolveArea({ bbox: [-97.94, 30.1, -97.56, 30.52] }); + assert.ok(areaRadiusKm(small) < 30); +}); diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 1d3212ff7..af8142305 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -481,3 +481,18 @@ test('traffic counts only roads inside a radius area and reports missing tiles', assert.equal(partial.data.partial, true); assert.match(partial.summary, /Some map tiles did not load/); }); + +test('a whole-world bike-share search names every system it skipped', async () => { + const empty = { data: { stations: [] } }; + const result = await composeCatalog({ + tools: coreTools, + services: { + bikeshare: { + systems: GBFS_CITY_REGISTRY, + getStations: async () => empty, + }, + }, + }).call('get_bike_share', { area: { bbox: [-180, -90, 180, 90] } }); + const named = [...result.data.systems, ...result.data.systems_not_searched]; + assert.equal(named.length, GBFS_CITY_REGISTRY.length); +}); From 7f56679a46453abf0db44d8d307cbdbf1d912003 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 16:21:20 -0700 Subject: [PATCH 053/111] fix: show the whole weather map when a window cannot cover the area The map window was moved inside the product's bounds without checking that it still covered the requested area, so an area across the antimeridian got an image of only one side. A window must now hold the whole area, clipped to the product, or the tool returns the product's full extent. --- src/tools/queries/atmosphere.js | 21 +++++++++++++++------ src/tools/queries/atmosphere.test.mjs | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/tools/queries/atmosphere.js b/src/tools/queries/atmosphere.js index 838e4849f..f8e32012c 100644 --- a/src/tools/queries/atmosphere.js +++ b/src/tools/queries/atmosphere.js @@ -33,10 +33,12 @@ const round = (value, digits) => /** * A 2:1 box snapped to 0.25° around the area, moved inside the product's - * bounds. Returns null when the box would not fit, meaning the whole - * extent should be shown instead. + * bounds. Returns null when no such box covers the whole area (it would not + * fit, or the area crosses the antimeridian), meaning the whole extent + * should be shown instead. */ export function weatherWindow(area, bounds) { + if (area.west > area.east) return null; const center = areaCenter(area); const halfHeight = Math.max( MIN_HALF_HEIGHT_DEGREES, @@ -57,12 +59,19 @@ export function weatherWindow(area, bounds) { snapDown(bounds.north - height), ); const mapBox = { west, south, east: west + width, north: south + height }; - return mapBox.west >= bounds.west && + const insideProduct = + mapBox.west >= bounds.west && mapBox.east <= bounds.east && mapBox.south >= bounds.south && - mapBox.north <= bounds.north - ? mapBox - : null; + mapBox.north <= bounds.north; + // Moving the box inside the product can uncover part of the area; only a + // box that still holds the whole area (clipped to the product) will do. + const coversArea = + mapBox.west <= Math.max(area.west, bounds.west) && + mapBox.east >= Math.min(area.east, bounds.east) && + mapBox.south <= Math.max(area.south, bounds.south) && + mapBox.north >= Math.min(area.north, bounds.north); + return insideProduct && coversArea ? mapBox : null; } export const getWeatherMap = defineTool({ diff --git a/src/tools/queries/atmosphere.test.mjs b/src/tools/queries/atmosphere.test.mjs index 8623904ab..cf19d83f3 100644 --- a/src/tools/queries/atmosphere.test.mjs +++ b/src/tools/queries/atmosphere.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { composeCatalog, coreTools } from '../index.js'; import { weatherWindow } from './atmosphere.js'; +import { resolveArea } from '../area.js'; const CONUS = { west: -130, south: 20, east: -60, north: 55 }; const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]); @@ -156,3 +157,18 @@ test('wind is sampled from the model grid at the location', async () => { (error) => error.code === 'unavailable', ); }); + +test('a weather window that cannot cover the area falls back to the whole map', async () => { + const world = { west: -180, south: -90, east: 180, north: 90 }; + // 100 km around 179.8° crosses the antimeridian. + const dateline = await resolveArea({ lat: 0, lon: 179.8, radius_km: 100 }); + assert.equal(weatherWindow(dateline, world), null); + // Near a product edge the window moves inside and still covers the area. + const conus = { west: -130, south: 20, east: -60, north: 55 }; + const edge = await resolveArea({ lat: 40, lon: -129.5, radius_km: 50 }); + const box = weatherWindow(edge, conus); + assert.ok(box); + assert.ok(box.west <= Math.max(edge.west, conus.west)); + assert.ok(box.east >= edge.east); + assert.ok(box.south <= edge.south && box.north >= edge.north); +}); From f610de77dcc07b6ed17468cbca2627f533643b35 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 16:23:30 -0700 Subject: [PATCH 054/111] fix: judge camera catalog trimming by region, not by returned cameras Coverage was checked only for packs among the cameras found, so an area whose cameras were all trimmed away answered zero cameras, complete. The catalog now reports where each trimmed pack's cameras are, and the tool marks an answer partial whenever its area overlaps that region. --- server/providers/cctv/catalog.js | 5 +++- server/providers/cctv/normalize.js | 23 ++++++++++++++++- src/data/cctvCatalogCap.test.mjs | 11 +++++--- src/tools/queries/media.js | 21 +++++++++++++++- src/tools/queries/media.test.mjs | 40 ++++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 6 deletions(-) diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index cc258d7f4..7e9be7ac9 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -3,7 +3,7 @@ import path from 'node:path'; import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js'; import { allocateSourceCap, resolveCatalogCap } from './cap.js'; import { loadGroundHeights, joinGroundHeights } from './groundHeights.js'; -import { normalizeSourceItem } from './normalize.js'; +import { cameraRegion, normalizeSourceItem } from './normalize.js'; import { loadAustinSourcesFromOpenData, loadCaltransSourcesFromOpenData, @@ -217,6 +217,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { available: Number.isFinite(items.available) ? items.available : items.length, + region: items.region ?? null, }); const packs = [ ...LIVE_PACKS.map((pack, index) => @@ -246,6 +247,8 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { pack: pack.name, available: Math.max(pack.available, allocation.packs[index].offered), served: allocation.packs[index].kept, + // Where the pack's cameras are, including those not served. + region: pack.region ?? cameraRegion(pack.sources), })) .filter((pack) => pack.served < pack.available); if (trimmed.length) { diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index 99cd54712..9e183b23d 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -471,11 +471,32 @@ export function prioritizeSources(cameras, maxCount, anchors) { }); const kept = scored.slice(0, cap).map((entry) => entry.camera); - // How many cameras the pack offered before trimming, for coverage reports. + // How many cameras the pack offered before trimming, and where, for + // coverage reports. Object.defineProperty(kept, 'available', { value: list.length }); + Object.defineProperty(kept, 'region', { value: cameraRegion(list) }); return kept; } +/** The bounding box of cameras with coordinates, or null. */ +export function cameraRegion(cameras) { + let region = null; + for (const camera of cameras || []) { + const lat = Number(camera?.lat); + const lon = Number(camera?.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + region = region + ? { + west: Math.min(region.west, lon), + south: Math.min(region.south, lat), + east: Math.max(region.east, lon), + north: Math.max(region.north, lat), + } + : { west: lon, south: lat, east: lon, north: lat }; + } + return region; +} + /** * Normalize a raw CCTV source item into a canonical shape with safe defaults. * diff --git a/src/data/cctvCatalogCap.test.mjs b/src/data/cctvCatalogCap.test.mjs index 8e3c08f03..9d93778db 100644 --- a/src/data/cctvCatalogCap.test.mjs +++ b/src/data/cctvCatalogCap.test.mjs @@ -129,9 +129,13 @@ test('the catalog applies the fair cap to configured packs end to end', async () ['env-0', 'env-1', 'env-2'], ); assert.equal(sources[0].pack, 'env'); - assert.deepEqual(getSources.trimmedPacks(), [ - { pack: 'env', available: 12, served: 9 }, - ]); + const [trimmed] = getSources.trimmedPacks(); + assert.deepEqual( + { ...trimmed, region: undefined }, + { pack: 'env', available: 12, served: 9, region: undefined }, + ); + assert.equal(trimmed.region.south, 30); + assert.ok(Math.abs(trimmed.region.north - 30.11) < 1e-9); } finally { for (const key of Object.keys(process.env)) { if (!(key in saved)) delete process.env[key]; @@ -154,4 +158,5 @@ test('a pack trimmed to its nearest cameras reports how many it offered', async ['c0', 'c1'], ); assert.equal(kept.available, 5); + assert.deepEqual(kept.region, { west: 0, south: 0, east: 0, north: 4 }); }); diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js index 45037ae1b..675ef2a27 100644 --- a/src/tools/queries/media.js +++ b/src/tools/queries/media.js @@ -31,6 +31,21 @@ async function readCatalog(services, signal) { }; } +/** Whether an area's box overlaps a plain region box. */ +function boxesOverlap(area, region) { + if (area.south > region.north || area.north < region.south) return false; + const spans = + area.west <= area.east + ? [[area.west, area.east]] + : [ + [area.west, 180], + [-180, area.east], + ]; + return spans.some( + ([west, east]) => west <= region.east && east >= region.west, + ); +} + async function readCameras(services, signal) { return (await readCatalog(services, signal)).cameras; } @@ -55,8 +70,12 @@ export const findCctvCameras = defineTool({ const found = catalog.cameras.filter((camera) => areaContains(area, camera), ); + // A trimmed pack matters wherever its cameras are, served or not, so + // its region decides, not the cameras returned. const packs = new Set(found.map((camera) => camera.pack).filter(Boolean)); - const trimmed = catalog.trimmed.filter((entry) => packs.has(entry.pack)); + const trimmed = catalog.trimmed.filter((entry) => + entry.region ? boxesOverlap(area, entry.region) : packs.has(entry.pack), + ); const rows = found .map((camera) => ({ id: camera.id, diff --git a/src/tools/queries/media.test.mjs b/src/tools/queries/media.test.mjs index 5b5a2e9c4..d7d014df7 100644 --- a/src/tools/queries/media.test.mjs +++ b/src/tools/queries/media.test.mjs @@ -282,3 +282,43 @@ test('cameras from a trimmed catalog pack are reported as partial', async () => /\(the catalog serves only some cameras here: 250 of 900 from tfl\)\.$/, ); }); + +test('an area the catalog trimmed away entirely is not reported complete', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + cctv: { + getCatalog: async () => ({ + trimmedPacks: [ + { + pack: 'tfl', + available: 900, + served: 250, + region: { west: -0.6, south: 51.3, east: 0.3, north: 51.7 }, + }, + ], + // Every served camera is in central London, none in the suburb. + sources: [ + { + id: 'jam-1', + name: 'Strand', + lat: 51.51, + lon: -0.12, + pack: 'tfl', + }, + ], + }), + }, + }, + }); + const suburb = await catalog.call('find_cctv_cameras', { + area: { lat: 51.4, lon: -0.5, radius_km: 3 }, + }); + assert.equal(suburb.data.rows.length, 0); + assert.equal(suburb.data.complete, false); + assert.match(suburb.summary, /250 of 900 from tfl/); + const elsewhere = await catalog.call('find_cctv_cameras', { + area: { lat: 40.7, lon: -74, radius_km: 3 }, + }); + assert.equal(elsewhere.data.complete, true); +}); From ad9a3976a9d93b695c2e56762c5ff51f704896c1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 16:25:48 -0700 Subject: [PATCH 055/111] fix: say the camera catalog has no cameras rather than none exist An empty camera answer outside the catalog's regions read as "0 public cameras", as if the area had none. It now says the catalog has no cameras there. --- src/tools/queries/media.js | 6 +++++- src/tools/queries/media.test.mjs | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js index 675ef2a27..f866c9c86 100644 --- a/src/tools/queries/media.js +++ b/src/tools/queries/media.js @@ -92,7 +92,11 @@ export const findCctvCameras = defineTool({ .sort((a, b) => a.distance_km - b.distance_km); return { summary: - `${countNoun(rows.length, 'public camera')} in ${area.label}` + + (rows.length || trimmed.length + ? `${countNoun(rows.length, 'public camera')} in ${area.label}` + : // The catalog covers selected regions; an empty answer elsewhere + // means it has no cameras there, not that none exist. + `The camera catalog has no cameras in ${area.label}`) + (trimmed.length ? ` (the catalog serves only some cameras here: ${trimmed .map( diff --git a/src/tools/queries/media.test.mjs b/src/tools/queries/media.test.mjs index d7d014df7..a024191e7 100644 --- a/src/tools/queries/media.test.mjs +++ b/src/tools/queries/media.test.mjs @@ -321,4 +321,8 @@ test('an area the catalog trimmed away entirely is not reported complete', async area: { lat: 40.7, lon: -74, radius_km: 3 }, }); assert.equal(elsewhere.data.complete, true); + assert.equal( + elsewhere.summary, + 'The camera catalog has no cameras in 3 km around 40.700, -74.000.', + ); }); From 41d1352930407935f22f0023a558ad2e8eed7255 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 17:38:02 -0700 Subject: [PATCH 056/111] fix: search every vessel the server retains Vessel tools read the source's default of the newest 12,000 vessels while the server keeps up to 50,000, so a lookup could miss a vessel still held. They now ask for more than any server keeps and receive everything it has. --- src/tools/queries/maritime.js | 14 ++++++++++++-- src/tools/queries/maritime.test.mjs | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index c86193ac0..5c2107a68 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -22,6 +22,7 @@ const MMSI_SCHEMA = Object.freeze({ description: 'Maritime Mobile Service Identity, such as "366999712".', }); const MAX_TRACK_POINTS = 200; +const ALL_VESSELS = 1_000_000; const round = (value, digits) => Number.isFinite(value) ? Number(value.toFixed(digits)) : null; @@ -45,6 +46,15 @@ function vesselRow(record, from) { }; } +/** + * Every vessel the source retains. The source's default is the newest few + * thousand, which can leave out a vessel the server still holds; asking for + * more than any server keeps lets the server return all of them. + */ +function readVessels(services, signal) { + return services.vessels.getSnapshot({ maxRows: ALL_VESSELS }, { signal }); +} + function snapshotInfo(snapshot) { return { source: snapshot.source ?? null, @@ -79,7 +89,7 @@ export const vesselsInArea = defineTool({ async run(args, { services, signal }) { const area = await resolveArea(args.area, { services, signal }); const center = areaCenter(area); - const snapshot = await services.vessels.getSnapshot({}, { signal }); + const snapshot = await readVessels(services, signal); const wanted = args.type?.toLowerCase(); const rows = snapshot.records .filter( @@ -130,7 +140,7 @@ export const findVessel = defineTool({ imo: (record) => String(record.imo).replace(/^IMO/i, '') === wanted, name: (record) => String(record.name).toUpperCase().includes(wanted), }[key]; - const snapshot = await services.vessels.getSnapshot({}, { signal }); + const snapshot = await readVessels(services, signal); const rows = snapshot.records .filter(matches) .map((record) => vesselRow(record)); diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index 00211992c..21cdad630 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { composeCatalog, coreTools } from '../index.js'; import { LiveSourceError } from '../../sources/live/contract.js'; +import { createAisStreamSource } from '../../sources/live/standalone.js'; +import { AISSTREAM_CACHE_MAX } from '../../../server/providers/vessels/ais-store.js'; const vessel = (id, latitude, longitude, extra = {}) => ({ id, @@ -179,3 +181,23 @@ test('a missing AIS key surfaces as an unavailable feed', async () => { error.message === 'AISSTREAM_API_KEY not set', ); }); + +test('vessel searches ask for every vessel the server retains', async () => { + const requested = []; + const source = createAisStreamSource({ + origin: () => 'http://localhost', + fetchImpl: async (url) => { + requested.push(new URL(url).searchParams.get('maxRows')); + return Response.json({ rows: [], source: 'AISStream', status: 'live' }); + }, + }); + const catalog = composeCatalog({ + tools: coreTools, + services: { vessels: source }, + }); + await catalog.call('find_vessel', { mmsi: '366999712' }); + await catalog.call('vessels_in_area', { area: bay }); + assert.equal(requested.length, 2); + for (const maxRows of requested) + assert.ok(Number(maxRows) >= AISSTREAM_CACHE_MAX, maxRows); +}); From 33445aec735d278b3336b98e94d05d904b6e950b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 17:39:48 -0700 Subject: [PATCH 057/111] fix: keep each aircraft feed's freshness in search answers find_aircraft merged the records of the feeds that answered and dropped their freshness, so a stale OpenSky snapshot read as a current "not reported". Answers now list each feed with its freshness and coverage and say which feed's data may be stale. --- src/tools/queries/aviation.js | 19 ++++++++++++++- src/tools/queries/aviation.test.mjs | 37 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index df904297c..d4b95d3b2 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -201,6 +201,15 @@ export const findAircraft = defineTool({ ) .filter((record) => !seen.has(record.id) && seen.add(record.id)) .map((record) => aircraftRow(record)); + // Each feed that answered, with its own freshness and coverage. + const answered = feeds.flatMap(({ name }, index) => + settled[index].status === 'fulfilled' + ? [{ feed: name, ...snapshotInfo(settled[index].value) }] + : [], + ); + const stale = answered + .filter((feed) => feed.freshness === 'stale') + .map((feed) => feed.feed); return { summary: (rows.length @@ -208,8 +217,16 @@ export const findAircraft = defineTool({ : `No aircraft with ${key} ${wanted} is currently reported.`) + (unavailable.length ? ` The ${unavailable.join(' and ')} feed did not answer.` + : '') + + (stale.length + ? ` The ${stale.join(' and ')} feed data may be stale.` : ''), - data: { ...capRows(rows, args.limit), unavailable_feeds: unavailable }, + data: { + ...capRows(rows, args.limit), + unavailable_feeds: unavailable, + stale_feeds: stale, + feeds: answered, + }, }; }, }); diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 4db854345..ca800f8e9 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -351,3 +351,40 @@ test('a regional fallback feed is named in the answer', async () => { }); assert.match(result.summary, /\(regional feed: 250nm regional fallback\)\.$/); }); + +test('aircraft searches say when a feed that answered is stale', async () => { + const stale = feed([], { freshness: 'stale' }); + const result = await composeCatalog({ + tools: coreTools, + services: { aircraft: stale, military: feed([]) }, + }).call('find_aircraft', { callsign: 'UAL1' }); + assert.equal( + result.summary, + 'No aircraft with callsign UAL1 is currently reported. The civil feed data may be stale.', + ); + assert.deepEqual(result.data.stale_feeds, ['civil']); + assert.deepEqual( + result.data.feeds.map((entry) => [entry.feed, entry.freshness]), + [ + ['civil', 'stale'], + ['military', 'current'], + ], + ); +}); + +test('the real OpenSky source marks an hour-old snapshot stale for searches', async () => { + const { createOpenSkySource } = + await import('../../sources/live/standalone.js'); + const now = Date.UTC(2026, 0, 1, 12); + const opensky = createOpenSkySource({ + now: () => now, + fetchImpl: async () => + Response.json({ time: now / 1000 - 3600, states: [] }), + }); + const result = await composeCatalog({ + tools: coreTools, + services: { aircraft: opensky }, + }).call('find_aircraft', { callsign: 'UAL1' }); + assert.deepEqual(result.data.stale_feeds, ['civil']); + assert.match(result.summary, /may be stale\.$/); +}); From 759d05db4e2cd49785946b96b41537b68f67b15c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 17:41:39 -0700 Subject: [PATCH 058/111] fix: name only tools voice offers in its instructions The voice instructions recommended aircraft_in_area, vessels_in_area and get_earthquakes, which voice no longer offers, and the docs said voice gets nearly every query. The instructions now name tools voice has and send aircraft, ship and hazard questions to analyst_query; a test keeps the instructions to tools the voice session lists. --- docs/TOOLS.md | 7 +++++-- server/providers/openai/instructions.js | 2 +- src/tooling/localServices.test.mjs | 16 ++++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 5797cbad5..eec6adba9 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -109,8 +109,11 @@ sending them. The voice session token endpoint takes its tool list as `realtime.tools`. `realtimeSessionTools(additional)` appends function tools to the app actions, skipping names an action already uses, so `next_satellite_pass` stays the -action. The standalone server supplies every core query except -`open_in_gods_eye_view`, since voice runs inside the app. +action. The standalone server supplies the core queries that +`src/tools/surfaces.js` offers on voice, which leaves out tools that answer +with images, link to the app, or repeat what voice's app actions answer +(aircraft, ships, earthquakes, fires, datacenters, dams and satellites +overhead, which `analyst_query` covers). In the browser, `initGevVoiceCommands({ toolCatalog })` takes a function that resolves a catalog. App action names go to the action runner; other names the diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 6633dab10..5c62fa3db 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -19,7 +19,7 @@ function realtimeInstructions( 'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.', 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.', 'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, earthquakes, satellites, datacenters, or dams ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.', - 'WORLD QUERIES — the query tools (get_weather, aircraft_in_area, vessels_in_area, get_earthquakes, search_places, plan_route, situation_brief, military_awareness and the other get_*, find_* and *_in_area tools) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "any ships near Singapore", "how do I drive from Austin to Dallas"). Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.', + 'WORLD QUERIES — the query tools (get_weather, get_wind, search_places, places_nearby, plan_route, get_cyclones, get_fire_perimeters, get_bike_share, get_transit_vehicles, get_traffic_flow, situation_brief, military_awareness and the other get_* and find_* tools you have) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "how do I drive from Austin to Dallas", "any wildfires in California"). For aircraft, ships, earthquakes, active fires, datacenters, dams and satellites overhead, use analyst_query over the layers instead, turning the layer on first when the user wants it. Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.', 'FEED STATE CONTRACT — analyst_query and get_current_view_state carry feedProvenance from the same layerFeedState the Data Layers chips use (nominal/loading/degraded/stale/fallback/unavailable, or off). NEVER narrate a count as live or current when feedProvenance.overall is not nominal. Say the feedState in the same breath as the count ("12 flights in view, STALE"). If unavailable, do not invent a count; say the feed is unavailable. Fallback is valid data — name the fallback source, never call it an enable failure. Confirm only what the tool result returned.', 'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near " means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.', 'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.', diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index d6d6d4325..65168ecf4 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -469,3 +469,19 @@ test('the debug log rotates instead of growing without bound', async (t) => { assert.ok(live + previous < 64 * 1024 * 1024); assert.ok(!existsSync(`${file}.2`), 'exactly one generation is retained'); }); + +test('the voice instructions name only tools the voice session offers', async () => { + const { coreTools } = await import('../tools/index.js'); + const tools = new Set(standaloneVoiceTools().map((tool) => tool.name)); + const known = new Set([ + ...coreTools.map((tool) => tool.name), + ...GEV_REALTIME_TOOLS.map((tool) => tool.name), + ]); + const named = new Set( + realtimeInstructions().match(/\b[a-z]+(?:_[a-z]+)+\b/g), + ); + const missing = [...named].filter( + (name) => known.has(name) && !tools.has(name), + ); + assert.deepEqual(missing, []); +}); From de1e0836b35d6a06ec12480e9abca61b8ac31786 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 19:43:01 -0700 Subject: [PATCH 059/111] fix: send "vessels" to the ships layer in voice Voice hears audio directly, and "vessels" is easily heard as "visuals", which the instructions route to visual styles, so asking for vessels turned on other layers or a CRT style. The instructions now say a vessels request without a style name means the ships layer, and a style needs its name. --- server/providers/openai/instructions.js | 1 + src/tooling/localServices.test.mjs | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 5c62fa3db..5658f9ece 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -49,6 +49,7 @@ function realtimeInstructions( // string is the whole rollback. 'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.', 'For visual filter requests, call set_visual_style with one of the allowed style IDs.', + 'SHIPS vs STYLES: "vessels" is easily heard as "visuals". "Show me vessels", "turn on vessels", "the vessels", "visuals" or "vestibules" said WITHOUT a style name means the ships layer: set_layer_visibility{layerId:"ais-live-vessels"}, exactly as "ships" or "boats" does. Call set_visual_style only when the user names a style (retro/CRT, surveillance/night vision, thermal, anime, noir, snow, normal) or says filter, effect or look. Never turn on a different data layer (such as fires) for a ships request.', 'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.', 'HUD requests ("hud on/off", "switch to operator/minimal/tactical/cyber layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.', 'Cyber sonar requests use set_cyber_sonar: enabled, rings, rangePct, intensityPct (Power), opacityPct, sectorDeg. For settings questions read get_current_view_state.controls.hud.sonar. Sonar is not detection. Do not silently switch themes, reveal the HUD or enable sonar when changing a slider. If the user explicitly requests Cyber plus sonar settings, call set_hud first, wait for success, then set_cyber_sonar. Confirm returned values; mapSweepActive and contactSweepActive distinguish saved settings from active map effects, including in Cockpit or reduced motion. Do not claim visible contacts solely because enabled is true.', diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index 65168ecf4..3eddb1b40 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -485,3 +485,9 @@ test('the voice instructions name only tools the voice session offers', async () ); assert.deepEqual(missing, []); }); + +test('the voice instructions send vessels, however heard, to the ships layer', () => { + const text = realtimeInstructions(); + assert.match(text, /"vessels" is easily heard as "visuals"/); + assert.match(text, /set_layer_visibility\{layerId:"ais-live-vessels"\}/); +}); From 96a8a9addbf669087da6d2f4ef04d7d0f5269413 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 20:50:32 -0700 Subject: [PATCH 060/111] feat: describe what the app shows as a view A view is a camera, data layers, visual style, map imagery and an aircraft, military aircraft or satellite to follow, written in the share-link format the app already restores. gods-eye-view/view builds, encodes and decodes one, and share links take their style names from it. open_in_gods_eye_view now builds its link from a full view: an area or camera, layers, style, map and something to follow. --- docs/TOOLS.md | 18 ++- package.json | 3 +- scripts/check-import-directions.mjs | 1 + scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 15 +- src/sharelink.js | 13 +- src/tools/queries/share.js | 125 ++++++++------- src/tools/queries/share.test.mjs | 41 ++++- src/view/index.js | 237 ++++++++++++++++++++++++++++ src/view/index.test.mjs | 79 ++++++++++ 10 files changed, 463 insertions(+), 72 deletions(-) create mode 100644 src/view/index.js create mode 100644 src/view/index.test.mjs diff --git a/docs/TOOLS.md b/docs/TOOLS.md index eec6adba9..2b38d9cf1 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -79,6 +79,22 @@ the GBFS source. `createGeocodePlaceService` resolves place names through `/api/ `createPlaceSearchService` searches `/api/google/*` and reports when no search key is configured; `createRouteService` plans routes through `/api/route`. +## Views + +`gods-eye-view/view` (`src/view/index.js`) describes what the app shows, +independent of how it is shown: a camera (lat, lon, altitude, heading, +pitch), data layers, visual style, map imagery, and an aircraft, military +aircraft or satellite to follow. `createView` builds and bounds one, +`viewToParams` and `viewFromParams` write and read it in the share-link +format the app restores, and `viewUrl` gives the address that opens it. The +style names are the ones share links use. Ships cannot be followed from a +link yet. + +Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a +`camera`, plus `layers`, `style`, `map` and `follow`; camera fields given +with an area override its framing. `resolveViewArguments` turns them into a +view. + ## The `area` argument Location-scoped tools take `area` as exactly one of a `place` name, a `bbox` @@ -187,4 +203,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 looking straight down on an area, with chosen layers on | +| `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 | diff --git a/package.json b/package.json index 7169a4c24..3b5e18094 100644 --- a/package.json +++ b/package.json @@ -254,6 +254,7 @@ "./sources/map-features": "./src/sources/overpassFeatures.js", "./tools": "./src/tools/index.js", "./tools/mcp": "./src/tools/mcp/index.js", - "./tools/services": "./src/tools/services.js" + "./tools/services": "./src/tools/services.js", + "./view": "./src/view/index.js" } } diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 5eeca6ae2..ac1a3f732 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -40,6 +40,7 @@ const portableExport = (key) => './tools', './tools/mcp', './tools/services', + './view', './voice/action-schemas', './voice/session', './data/lifecycle', diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 580988971..1a98d255b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -888,5 +888,6 @@ "src/tools/queries/events.test.mjs", "src/tools/functions.test.mjs", "src/tools/surfaces.test.mjs", - "src/tools/places.test.mjs" + "src/tools/places.test.mjs", + "src/view/index.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7da7e6c08..c7c3fb2f3 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2038,7 +2038,8 @@ "src/data/infrastructureOverlayEntry.js", "src/sources/infrastructureData.js", "src/tools/functions.js", - "src/voice/actionSchemas.js" + "src/voice/actionSchemas.js", + "src/view/index.js" ], "external": [ "hls.js", @@ -3131,7 +3132,8 @@ "src/data/layerSnapshot.js", "src/data/feedState.js", "src/loadingFeedback.js", - "src/data/installationFeedback.js" + "src/data/installationFeedback.js", + "src/view/index.js" ], "external": ["satellite.js"] }, @@ -3211,5 +3213,14 @@ "src/sources/eventPacks.js" ], "external": ["@mapbox/vector-tile", "pbf"] + }, + "view": { + "exports": ["./view"], + "modules": [ + "src/view/index.js", + "src/data/layerState.js", + "src/data/layerStateTokenReservations.json" + ], + "external": [] } } diff --git a/src/sharelink.js b/src/sharelink.js index 08360932b..9c7c3728e 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -9,6 +9,7 @@ import { decodeLayerStateParams, encodeLayerStateParams, } from './data/layerState.js'; +import { STYLE_URL_NAMES } from './view/index.js'; /** * Share Links — URL Hash State Management @@ -20,16 +21,8 @@ import { const DEBOUNCE_MS = 500; const LEGACY_BLOOM_FALLBACK = 50; -// Style name mapping: internal → URL-friendly -const STYLE_TO_URL = { - normal: 'normal', - retro: 'crt', - surveillance: 'nvg', - thermal: 'flir', - anime: 'anime', - noir: 'noir', - snow: 'snow', -}; +// Style name mapping: internal → URL-friendly, shared with views. +const STYLE_TO_URL = STYLE_URL_NAMES; const SHARE_UI_STATE_PARAM = 'ui'; const SHARE_STYLE_PARAMS_PARAM = 'sp'; diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index cbb3e8bab..f15ce34d2 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -1,10 +1,6 @@ -/** A link that opens God's Eye View on an area. */ +/** Links that open God's Eye View at a view. */ -import { - REGISTERED_LAYER_IDS, - createDefaultLayerState, - encodeLayerStateParams, -} from '../../data/layerState.js'; +import { VIEW_PROPERTIES, createView, viewUrl } from '../../view/index.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, areaCenter, areaRadiusKm, resolveArea } from '../area.js'; @@ -13,65 +9,86 @@ const MAX_ALTITUDE_M = 15_000_000; // A top-down view sees roughly this many meters of ground per meter of altitude. const GROUND_PER_ALTITUDE = 0.55; -export const openInGodsEyeView = defineTool({ - name: 'open_in_gods_eye_view', - title: "Open in God's Eye View", - description: - "A link that opens God's Eye View looking straight down on an area, " + - 'optionally with chosen data layers turned on.', - inputSchema: { - type: 'object', - properties: { - area: AREA_SCHEMA, - layers: { - type: 'array', - maxItems: REGISTERED_LAYER_IDS.length, - items: { type: 'string', enum: [...REGISTERED_LAYER_IDS] }, - description: 'Layers to turn on, such as "flights" or "earthquakes".', - }, - }, - required: ['area'], - additionalProperties: false, - }, - requires: ['app'], - async run(args, { services, signal }) { - let base; - try { - base = new URL(services.app.baseUrl); - } catch { - throw new ToolError('unavailable', "The app's address is not configured"); - } +/** Tool arguments that describe a view: an area to frame, or a camera. */ +export const VIEW_ARGUMENTS = Object.freeze({ + area: AREA_SCHEMA, + ...VIEW_PROPERTIES, +}); + +/** + * The view described by tool arguments. An area frames the camera straight + * down over it; camera fields given alongside it override the framing. + */ +export async function resolveViewArguments(args, { services, signal }) { + let camera = { ...args.camera }; + let label = null; + if (args.area) { const area = await resolveArea(args.area, { services, signal }); const center = areaCenter(area); - const altitude = Math.round( - Math.min( + label = area.label; + camera = { + lat: center.lat, + lon: center.lon, + altitude_m: Math.min( MAX_ALTITUDE_M, Math.max( MIN_ALTITUDE_M, (areaRadiusKm(area) * 1000) / GROUND_PER_ALTITUDE, ), ), + ...camera, + }; + } + if (!Number.isFinite(camera.lat) || !Number.isFinite(camera.lon)) + throw new ToolError( + 'invalid_arguments', + 'Give an area, or a camera with lat and lon', ); - // The share-link camera and layer fields; the app restores its defaults - // for everything else. - const params = new URLSearchParams({ - v: '2', - lat: center.lat.toFixed(4), - lon: center.lon.toFixed(4), - alt: String(altitude), - heading: '0', - pitch: '-90', + const view = createView({ + camera, + layers: args.layers, + style: args.style ?? null, + map: args.map ?? null, + follow: args.follow ?? null, + }); + return { + view, + label: + label ?? `${view.camera.lat.toFixed(3)}, ${view.camera.lon.toFixed(3)}`, + }; +} + +function appBase(services) { + try { + return new URL(services.app.baseUrl).href; + } catch { + throw new ToolError('unavailable', "The app's address is not configured"); + } +} + +export const openInGodsEyeView = defineTool({ + name: 'open_in_gods_eye_view', + title: "Open in God's Eye View", + description: + "A link that opens God's Eye View at a view: an area framed from above " + + 'or a camera position, with chosen data layers, visual style and map, ' + + 'optionally following an aircraft or satellite.', + inputSchema: { + type: 'object', + properties: VIEW_ARGUMENTS, + additionalProperties: false, + }, + requires: ['app'], + async run(args, { services, signal }) { + const base = appBase(services); + const { view, label } = await resolveViewArguments(args, { + services, + signal, }); - const layers = [...new Set(args.layers || [])]; - if (layers.length) - encodeLayerStateParams(params, { - ...createDefaultLayerState(), - enabledLayerIds: layers, - }); - base.hash = params.toString(); + const url = viewUrl(base, view); return { - summary: `Open ${area.label} in God's Eye View: ${base.href}`, - data: { url: base.href, center, altitude_m: altitude, layers }, + summary: `Open ${label} in God's Eye View: ${url}`, + data: { url, view }, }; }, }); diff --git a/src/tools/queries/share.test.mjs b/src/tools/queries/share.test.mjs index 038e1eb88..06e4c29fe 100644 --- a/src/tools/queries/share.test.mjs +++ b/src/tools/queries/share.test.mjs @@ -22,6 +22,7 @@ test('links open the app looking straight down on the area', async () => { alt: '9091', heading: '0', pitch: '-90', + roll: '0', }); assert.equal( result.summary, @@ -37,11 +38,11 @@ test('altitude is bounded for tiny and planet-sized areas', async () => { const tiny = await catalog.call('open_in_gods_eye_view', { area: { lat: 0, lon: 0, radius_km: 0.1 }, }); - assert.equal(tiny.data.altitude_m, 500); + assert.equal(tiny.data.view.camera.altitude_m, 500); const world = await catalog.call('open_in_gods_eye_view', { area: { bbox: [-180, -85, 180, 85] }, }); - assert.equal(world.data.altitude_m, 15000000); + assert.equal(world.data.view.camera.altitude_m, 15000000); const broken = composeCatalog({ tools: coreTools, services: { app: { baseUrl: 'not a url' } }, @@ -64,7 +65,7 @@ test('links turn on the requested layers with the app share-link codec', async ( area: { lat: 30.27, lon: -97.74, radius_km: 10 }, layers: ['earthquakes', 'flights', 'flights'], }); - assert.deepEqual(result.data.layers, ['earthquakes', 'flights']); + assert.deepEqual(result.data.view.layers, ['earthquakes', 'flights']); const params = new URLSearchParams(new URL(result.data.url).hash.slice(1)); assert.deepEqual(decodeLayerStateParams(params).enabledLayerIds.sort(), [ 'earthquakes', @@ -78,3 +79,37 @@ test('links turn on the requested layers with the app share-link codec', async ( /must be one of/, ); }); + +test('links carry a camera, style, map and an entity to follow', async () => { + const { viewFromParams } = await import('../../view/index.js'); + const catalog = composeCatalog({ + tools: coreTools, + services: { app: { baseUrl: 'http://localhost:4173/' } }, + }); + const result = await catalog.call('open_in_gods_eye_view', { + area: { lat: 25, lon: 121, radius_km: 200 }, + camera: { pitch_deg: -40, heading_deg: 350 }, + layers: ['ais-live-vessels'], + style: 'thermal', + map: 'esri-imagery', + follow: { kind: 'military_aircraft', id: 'AE1234' }, + }); + const params = new URLSearchParams(new URL(result.data.url).hash.slice(1)); + assert.equal(params.get('style'), 'flir'); + assert.deepEqual(viewFromParams(params), result.data.view); + assert.deepEqual(result.data.view.layers, ['ais-live-vessels', 'military']); + assert.deepEqual(result.data.view.follow, { + kind: 'military_aircraft', + id: 'ae1234', + }); + assert.equal(result.data.view.camera.pitch_deg, -40); + const camera = await catalog.call('open_in_gods_eye_view', { + 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/, + ); +}); diff --git a/src/view/index.js b/src/view/index.js new file mode 100644 index 000000000..d988918ed --- /dev/null +++ b/src/view/index.js @@ -0,0 +1,237 @@ +/** + * A view: what God's Eye View shows, independent of how it is shown. Camera, + * data layers, visual style, map imagery and an entity to follow. Views are + * written in the share-link format, so the app restores them when a link + * opens and every surface that produces one agrees on its meaning. + */ + +import { + REGISTERED_LAYER_IDS, + createDefaultLayerState, + decodeLayerStateParams, + encodeLayerStateParams, +} from '../data/layerState.js'; + +/** Visual styles by internal name, with the name share links use. */ +export const STYLE_URL_NAMES = Object.freeze({ + normal: 'normal', + retro: 'crt', + surveillance: 'nvg', + thermal: 'flir', + anime: 'anime', + noir: 'noir', + snow: 'snow', +}); + +const STYLES_BY_URL_NAME = Object.freeze( + Object.fromEntries( + Object.entries(STYLE_URL_NAMES).map(([name, url]) => [url, name]), + ), +); + +export const VIEW_STYLES = Object.freeze(Object.keys(STYLE_URL_NAMES)); + +export const VIEW_MAPS = Object.freeze([ + 'photoreal', + 'bing-aerial', + 'bing-labels', + 'esri-imagery', + 'osm', +]); + +/** + * What can be followed, the layer that shows it, and where the layer-state + * codec keeps its identifier. + */ +const FOLLOWABLE = Object.freeze({ + aircraft: { + layer: 'flights', + owner: 'flights', + key: 'selectedFlightsTrackingId', + }, + military_aircraft: { + layer: 'military', + owner: 'flights', + key: 'selectedMilitaryTrackingId', + }, + satellite: { + layer: 'satellites', + owner: 'satellites', + key: 'selectedSatTrackingId', + }, +}); + +export const VIEW_FOLLOW_KINDS = Object.freeze(Object.keys(FOLLOWABLE)); + +const MIN_ALTITUDE_M = 50; +const MAX_ALTITUDE_M = 20_000_000; + +/** JSON Schema for the parts of a view a caller chooses directly. */ +export const VIEW_PROPERTIES = Object.freeze({ + camera: Object.freeze({ + type: 'object', + description: + 'Camera position: lat and lon it is above, altitude in meters, heading ' + + '(0 is north) and pitch (-90 looks straight down, 0 at the horizon).', + properties: { + lat: { type: 'number', minimum: -90, maximum: 90 }, + lon: { type: 'number', minimum: -180, maximum: 180 }, + altitude_m: { + type: 'number', + minimum: MIN_ALTITUDE_M, + maximum: MAX_ALTITUDE_M, + }, + heading_deg: { type: 'number', minimum: 0, maximum: 360 }, + pitch_deg: { type: 'number', minimum: -90, maximum: 0 }, + }, + additionalProperties: false, + }), + layers: Object.freeze({ + type: 'array', + maxItems: REGISTERED_LAYER_IDS.length, + items: { type: 'string', enum: [...REGISTERED_LAYER_IDS] }, + description: 'Data layers to turn on, such as "flights" or "earthquakes".', + }), + style: Object.freeze({ + type: 'string', + enum: [...VIEW_STYLES], + description: + 'Visual style: retro is CRT, surveillance is night vision, thermal is FLIR.', + }), + map: Object.freeze({ + type: 'string', + enum: [...VIEW_MAPS], + description: 'Map imagery; photoreal is Google 3D tiles.', + }), + follow: Object.freeze({ + type: 'object', + description: + 'An entity the camera follows: an aircraft or military aircraft by ' + + 'ICAO 24-bit address, or a satellite by NORAD catalog number. Its layer ' + + 'is turned on.', + properties: { + kind: { type: 'string', enum: [...VIEW_FOLLOW_KINDS] }, + id: { type: 'string', pattern: '^[0-9A-Za-z~_-]{1,16}$' }, + }, + required: ['kind', 'id'], + additionalProperties: false, + }), +}); + +const round = (value, digits) => Number(value.toFixed(digits)); +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + +/** + * A complete view from its parts. `camera` needs lat and lon; the rest has + * defaults: 800 km up, looking straight down, no layers, the recipient's own + * style and map. + */ +export function createView({ + camera, + layers = [], + style = null, + map = null, + follow = null, +} = {}) { + if (!Number.isFinite(camera?.lat) || !Number.isFinite(camera?.lon)) + throw new TypeError('A view needs a camera lat and lon'); + if (style !== null && !VIEW_STYLES.includes(style)) + throw new TypeError(`Unknown view style: ${style}`); + if (map !== null && !VIEW_MAPS.includes(map)) + throw new TypeError(`Unknown view map: ${map}`); + const followed = follow ? FOLLOWABLE[follow.kind] : null; + if (follow && !followed) + throw new TypeError(`Cannot follow a ${follow.kind}`); + const enabled = new Set( + layers.filter((layer) => REGISTERED_LAYER_IDS.includes(layer)), + ); + if (followed) enabled.add(followed.layer); + return Object.freeze({ + camera: Object.freeze({ + lat: round(clamp(camera.lat, -90, 90), 4), + lon: round(clamp(camera.lon, -180, 180), 4), + altitude_m: Math.round( + clamp(camera.altitude_m ?? 800_000, MIN_ALTITUDE_M, MAX_ALTITUDE_M), + ), + heading_deg: Math.round(camera.heading_deg ?? 0) % 360, + pitch_deg: Math.round(clamp(camera.pitch_deg ?? -90, -90, 0)), + }), + layers: Object.freeze( + REGISTERED_LAYER_IDS.filter((layer) => enabled.has(layer)), + ), + style, + map, + follow: follow + ? Object.freeze({ + kind: follow.kind, + id: String(follow.id).toLowerCase(), + }) + : null, + }); +} + +/** Share-link hash parameters that restore a view. */ +export function viewToParams(view) { + const params = new URLSearchParams({ + v: '2', + lat: view.camera.lat.toFixed(4), + lon: view.camera.lon.toFixed(4), + alt: String(view.camera.altitude_m), + heading: String(view.camera.heading_deg), + pitch: String(view.camera.pitch_deg), + roll: '0', + }); + if (view.style) params.set('style', STYLE_URL_NAMES[view.style]); + if (view.map) params.set('map', view.map); + if (view.layers.length) { + const state = createDefaultLayerState(); + state.enabledLayerIds = [...view.layers]; + const followed = view.follow ? FOLLOWABLE[view.follow.kind] : null; + if (followed) + state.options[followed.owner] = { + ...state.options[followed.owner], + [followed.key]: view.follow.id, + }; + encodeLayerStateParams(params, state); + } + return params; +} + +/** The view a set of share-link hash parameters restores, or null. */ +export function viewFromParams(params) { + const lat = Number.parseFloat(params.get('lat')); + const lon = Number.parseFloat(params.get('lon')); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + const number = (name) => { + const value = Number.parseFloat(params.get(name)); + return Number.isFinite(value) ? value : undefined; + }; + const state = decodeLayerStateParams(params); + let follow = null; + for (const [kind, followed] of Object.entries(FOLLOWABLE)) { + const id = state?.options?.[followed.owner]?.[followed.key]; + if (id !== null && id !== undefined && !follow) + follow = { kind, id: String(id) }; + } + const map = params.get('map'); + return createView({ + camera: { + lat, + lon, + altitude_m: number('alt'), + heading_deg: number('heading'), + pitch_deg: number('pitch'), + }, + layers: state?.enabledLayerIds ?? [], + style: STYLES_BY_URL_NAME[params.get('style')] ?? null, + map: VIEW_MAPS.includes(map) ? map : null, + follow, + }); +} + +/** The address that opens the app at a view. */ +export function viewUrl(baseUrl, view) { + const url = new URL(baseUrl); + url.hash = viewToParams(view).toString(); + return url.href; +} diff --git a/src/view/index.test.mjs b/src/view/index.test.mjs new file mode 100644 index 000000000..49497c6d7 --- /dev/null +++ b/src/view/index.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + STYLE_URL_NAMES, + createView, + viewFromParams, + viewToParams, + viewUrl, +} from './index.js'; + +test('a view round-trips through share-link parameters', () => { + const view = createView({ + camera: { + lat: 35.68, + lon: 139.76, + altitude_m: 300000, + heading_deg: 15, + pitch_deg: -45, + }, + layers: ['earthquakes', 'ais-live-vessels'], + style: 'retro', + map: 'osm', + follow: { kind: 'aircraft', id: 'AE1234' }, + }); + const params = viewToParams(view); + assert.equal(params.get('style'), 'crt'); + assert.equal(params.get('map'), 'osm'); + assert.deepEqual(viewFromParams(params), view); + assert.deepEqual(view.layers, ['ais-live-vessels', 'earthquakes', 'flights']); + const satellite = createView({ + camera: { lat: 0, lon: 0 }, + follow: { kind: 'satellite', id: '25544' }, + }); + assert.deepEqual(viewFromParams(viewToParams(satellite)).follow, { + kind: 'satellite', + id: '25544', + }); +}); + +test('views have defaults, bounds, and reject what links cannot carry', () => { + const view = createView({ camera: { lat: 91, lon: 10, altitude_m: 1 } }); + assert.deepEqual(view.camera, { + lat: 90, + lon: 10, + altitude_m: 50, + heading_deg: 0, + pitch_deg: -90, + }); + assert.equal(view.style, null); + const params = viewToParams(view); + assert.equal(params.has('style'), false); + assert.equal(params.has('l'), false); + assert.throws(() => createView({ camera: {} }), /lat and lon/); + assert.throws( + () => createView({ camera: { lat: 0, lon: 0 }, style: 'sepia' }), + /Unknown view style/, + ); + assert.throws( + () => + createView({ + camera: { lat: 0, lon: 0 }, + follow: { kind: 'ship', id: '1' }, + }), + /Cannot follow a ship/, + ); + assert.equal(viewFromParams(new URLSearchParams('style=crt')), null); + assert.equal( + new URL(viewUrl('https://maps.example/app', view)).hash.slice(1), + params.toString(), + ); +}); + +test('views use the same style names as the app share links', async () => { + const source = await import('node:fs/promises').then((fs) => + fs.readFile(new URL('../sharelink.js', import.meta.url), 'utf8'), + ); + assert.match(source, /const STYLE_TO_URL = STYLE_URL_NAMES;/); + assert.equal(STYLE_URL_NAMES.retro, 'crt'); +}); From d6473b6049285f75620bc041ecfc46829ab168fa Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 21:25:27 -0700 Subject: [PATCH 061/111] feat: return the view that shows each answer Tool answers with something to show now include data.view: the view with the matching layers on, the area framed from above or a single aircraft or satellite followed, and the link that opens it. Area framing moves to src/tools/views.js so links and answers frame alike. --- docs/TOOLS.md | 11 ++- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 3 +- src/tools/queries/atmosphere.js | 26 +++++++ src/tools/queries/aviation.js | 49 +++++++++++-- src/tools/queries/brief.js | 21 +++++- src/tools/queries/cables.js | 11 +++ src/tools/queries/environment.js | 20 +++++- src/tools/queries/events.js | 17 +++++ src/tools/queries/hazards.js | 12 ++++ src/tools/queries/imagery.js | 2 + src/tools/queries/infrastructure.js | 2 + src/tools/queries/maritime.js | 28 +++++++- src/tools/queries/media.js | 8 +++ src/tools/queries/mobility.js | 4 ++ src/tools/queries/share.js | 22 +----- src/tools/queries/space.js | 29 +++++++- src/tools/queries/surveillance.js | 2 + src/tools/views.js | 55 +++++++++++++++ src/tools/views.test.mjs | 102 ++++++++++++++++++++++++++++ 20 files changed, 393 insertions(+), 34 deletions(-) create mode 100644 src/tools/views.js create mode 100644 src/tools/views.test.mjs diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 2b38d9cf1..c13dd9073 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -52,7 +52,10 @@ as `structuredContent`, for clients that read only one of them. `src/tools/surfaces.js` lists which tools MCP and voice offer. A tool is on both unless `TOOL_SURFACES` turns it off; edit an entry to turn a tool on or -off for one surface. `catalogForSurface(catalog, surface, overrides)` is the +off for one surface. MCP leaves out place search, routing, plain +weather and wind, the regional brief, the HUD caption, radio, bike share and +transit, which assistants already cover or which add little without the +globe. `catalogForSurface(catalog, surface, overrides)` is the view a surface exposes: it lists and calls only the tools it offers, while composite tools still reach the whole catalog. `toolsForSurface` gives the same selection as a list of definitions, such as for the voice session's tool @@ -95,6 +98,12 @@ Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a with an area override its framing. `resolveViewArguments` turns them into a view. +Answers that have something to show include `data.view`: the view that +shows them, with the matching layers on, an area framed from above, and a +single aircraft or satellite followed, plus `url` to open it (null when the +app's address is not configured). `suggestView` in `src/tools/views.js` +builds one. + ## The `area` argument Location-scoped tools take `area` as exactly one of a `place` name, a `bbox` diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 1a98d255b..b32160de0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -889,5 +889,6 @@ "src/tools/functions.test.mjs", "src/tools/surfaces.test.mjs", "src/tools/places.test.mjs", - "src/view/index.test.mjs" + "src/view/index.test.mjs", + "src/tools/views.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c7c3fb2f3..b42038d1a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3133,7 +3133,8 @@ "src/data/feedState.js", "src/loadingFeedback.js", "src/data/installationFeedback.js", - "src/view/index.js" + "src/view/index.js", + "src/tools/views.js" ], "external": ["satellite.js"] }, diff --git a/src/tools/queries/atmosphere.js b/src/tools/queries/atmosphere.js index f8e32012c..d20a58027 100644 --- a/src/tools/queries/atmosphere.js +++ b/src/tools/queries/atmosphere.js @@ -1,5 +1,6 @@ /** Weather map imagery (radar, satellite, lightning) and wind at a point. */ +import { suggestView } from '../views.js'; import { windFrom } from '../../layers/wind/inspection.js'; import { sampleWind, windSpeed } from '../../layers/wind/model.js'; import { defineTool, ToolError } from '../catalog.js'; @@ -20,6 +21,13 @@ const MAPS = { global_satellite: 'clouds', lightning: 'lightning', }; +/** The app layer that shows each map. */ +const MAP_LAYERS = { + radar: 'weather-radar', + satellite: 'weather-satellite', + global_satellite: 'weather-satellite', + lightning: 'weather-lightning', +}; const IMAGE_SIZE = { width: 1024, height: 512 }; const SNAP_DEGREES = 0.25; const MIN_HALF_HEIGHT_DEGREES = 0.5; @@ -136,6 +144,19 @@ export const getWeatherMap = defineTool({ return { summary: `${snapshot.title || args.map} at ${snapshot.latest}${where}.`, data: { + view: suggestView( + services, + area + ? { area, layers: [MAP_LAYERS[args.map]] } + : { + point: { + lat: (snapshot.bounds.south + snapshot.bounds.north) / 2, + lon: (snapshot.bounds.west + snapshot.bounds.east) / 2, + }, + altitudeM: 8_000_000, + layers: [MAP_LAYERS[args.map]], + }, + ), map: args.map, title: snapshot.title ?? null, description: snapshot.description ?? null, @@ -188,6 +209,11 @@ export const getWind = defineTool({ `Wind at ${point.label}: ${kph.toFixed(0)} km/h` + (from === 'Calm' ? ', calm.' : ` from the ${from}.`), data: { + view: suggestView(services, { + point, + altitudeM: 2_000_000, + layers: ['wind'], + }), location: point, speed_mps: round(speed, 1), speed_kph: round(kph, 1), diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index d4b95d3b2..0b8b54b79 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -1,5 +1,6 @@ /** Aviation queries over the live aircraft sources (src/sources/live). */ +import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, @@ -129,7 +130,14 @@ export const aircraftInArea = defineTool({ summary: `${rows.length} ${kind} in ${area.label}` + (notes.length ? ` (${notes.join('; ')}).` : '.'), - data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) }, + data: { + view: suggestView(services, { + area, + layers: [args.military ? 'military' : 'flights'], + }), + ...capRows(rows, args.limit), + ...snapshotInfo(snapshot), + }, }; }, }); @@ -190,17 +198,25 @@ export const findAircraft = defineTool({ .map(({ name }) => name); if (unavailable.length === feeds.length) throw settled[0].reason; const seen = new Set(); - const rows = settled - .filter((result) => result.status === 'fulfilled') - .flatMap((result) => result.value.records) + const matches = settled + .flatMap((result, index) => + result.status === 'fulfilled' + ? result.value.records.map((record) => ({ + record, + feed: feeds[index].name, + })) + : [], + ) .filter( - (record) => + ({ record }) => String(record[field] || '') .trim() .toUpperCase() === wanted, ) - .filter((record) => !seen.has(record.id) && seen.add(record.id)) - .map((record) => aircraftRow(record)); + .filter(({ record }) => !seen.has(record.id) && seen.add(record.id)); + const rows = matches.map(({ record }) => aircraftRow(record)); + // One match is shown followed; several are not framed. + const only = matches.length === 1 ? matches[0] : null; // Each feed that answered, with its own freshness and coverage. const answered = feeds.flatMap(({ name }, index) => settled[index].status === 'fulfilled' @@ -222,6 +238,16 @@ export const findAircraft = defineTool({ ? ` The ${stale.join(' and ')} feed data may be stale.` : ''), data: { + view: only + ? suggestView(services, { + point: { lat: only.record.latitude, lon: only.record.longitude }, + follow: { + kind: + only.feed === 'military' ? 'military_aircraft' : 'aircraft', + id: only.record.id, + }, + }) + : null, ...capRows(rows, args.limit), unavailable_feeds: unavailable, stale_feeds: stale, @@ -264,6 +290,15 @@ export const getAircraftTrack = defineTool({ ? `${countNoun(points.length, 'position')} for ${icao24} from ${isoTime(first.observedAtMs)} to ${isoTime(last.observedAtMs)}.` : `No recent track is available for ${icao24}.`, data: { + view: last + ? suggestView(services, { + point: { lat: last.latitude, lon: last.longitude }, + follow: { + kind: args.military ? 'military_aircraft' : 'aircraft', + id: icao24, + }, + }) + : null, icao24, total: points.length, returned: kept.length, diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index e1900873d..ba8e858dd 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -1,5 +1,6 @@ /** Composite queries that combine other tools' answers for one area. */ +import { suggestView } from '../views.js'; import { feedProvenanceEnvelope } from '../../data/layerSnapshot.js'; import { defineTool, ToolError } from '../catalog.js'; import { @@ -28,30 +29,35 @@ const SECTIONS = [ }, { key: 'earthquakes', + layer: 'earthquakes', label: 'Earthquakes', tool: 'get_earthquakes', args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'fires', + layer: 'local-firms', label: 'Active fires', tool: 'get_active_fires', args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'aircraft', + layer: 'flights', label: 'Aircraft', tool: 'aircraft_in_area', args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'vessels', + layer: 'ais-live-vessels', label: 'Ships', tool: 'vessels_in_area', args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'cyclones', + layer: 'weather-cyclones', label: 'Tropical cyclones', tool: 'get_cyclones', args: (area) => ({ area: area.argument }), @@ -187,7 +193,16 @@ export const situationBrief = defineTool({ const { area, brief, lines } = await buildBrief(args, context); return { summary: `Situation in ${area.label}: ${lines.join(' ')}`, - data: { area: area.label, sections: brief }, + data: { + view: suggestView(context.services, { + area, + layers: SECTIONS.filter(({ key }) => brief[key]) + .map(({ layer }) => layer) + .filter(Boolean), + }), + area: area.label, + sections: brief, + }, }; }, }); @@ -277,6 +292,10 @@ export const militaryAwareness = defineTool({ return { summary: `Military awareness within ${radiusKm} km of ${point.label}: ${lines.join(' ')}`, data: { + view: suggestView(services, { + area, + layers: ['military', 'ais-live-vessels', 'military-installations'], + }), location: point, radius_km: radiusKm, sections: answers, diff --git a/src/tools/queries/cables.js b/src/tools/queries/cables.js index ddf69f33d..8d8c0cecf 100644 --- a/src/tools/queries/cables.js +++ b/src/tools/queries/cables.js @@ -1,5 +1,6 @@ /** Submarine cables and their landing points. */ +import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, @@ -80,6 +81,16 @@ export const findSubmarineCables = defineTool({ ? `, with ${countNoun(landingRows.length, 'landing point')}.` : '.'), data: { + view: suggestView( + services, + area + ? { area, layers: ['telegeography-submarine-cables'] } + : { + point: { lat: 20, lon: 0 }, + altitudeM: 15_000_000, + layers: ['telegeography-submarine-cables'], + }, + ), ...capRows(rows, args.limit), landing_points: area ? capRows(landingRows, args.limit) : null, attribution: ATTRIBUTION, diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index 4c79dce5c..4f653ff32 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -3,6 +3,7 @@ * terrain height, mapped military sites and map features. */ +import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, @@ -221,6 +222,16 @@ export const getCyclones = defineTool({ ? `; strongest ${storms[0].name} at ${storms[0].wind_kt} kt.` : '.'), data: { + view: suggestView( + services, + area + ? { area, layers: ['weather-cyclones'] } + : { + point: { lat: 20, lon: 0 }, + altitudeM: 15_000_000, + layers: ['weather-cyclones'], + }, + ), storms, coverage: snapshot.coverage ?? null, source: snapshot.source ?? null, @@ -269,7 +280,10 @@ export const getFirePerimeters = defineTool({ .sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0)); return { summary: `${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}.`, - data: capRows(rows, args.limit), + data: { + view: suggestView(services, { area, layers: ['fire-perimeters'] }), + ...capRows(rows, args.limit), + }, }; }, }); @@ -436,6 +450,10 @@ export const findMilitaryInstallations = defineTool({ `${countNoun(rows.length, 'mapped military installation')} in ${area.label}` + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { + view: suggestView(services, { + area, + layers: ['military-installations'], + }), ...capRows(rows, args.limit), complete, names_pending: namesPending, diff --git a/src/tools/queries/events.js b/src/tools/queries/events.js index 9b530b40b..8b654014f 100644 --- a/src/tools/queries/events.js +++ b/src/tools/queries/events.js @@ -1,5 +1,6 @@ /** The bundled Bhote Koshi 2026 outburst flood event pack. */ +import { suggestView } from '../views.js'; import { BHOTE_KOSHI_FLOOD_PATH } from '../../data/bhoteKoshiFloodPath.js'; import { defineTool, ToolError } from '../catalog.js'; import { distanceKm } from '../area.js'; @@ -26,6 +27,18 @@ function pathLengthKm(path) { const pathPoint = ([lon, lat]) => ({ lat, lon }); +/** The box around a `[lon, lat]` path, as an area to frame. */ +function pathArea(path) { + const lons = path.map(([lon]) => lon); + const lats = path.map(([, lat]) => lat); + return { + west: Math.min(...lons), + south: Math.min(...lats), + east: Math.max(...lons), + north: Math.max(...lats), + }; +} + function evidenceRow(record) { return { sequence: record.sequence ?? null, @@ -88,6 +101,10 @@ export const getBhoteKoshiFlood = defineTool({ (args.phase ? ` in the ${args.phase} phase` : '') + ` along a ${lengthKm} km mapped flood path.`, data: { + view: suggestView(services, { + area: pathArea(BHOTE_KOSHI_FLOOD_PATH), + layers: ['bhote-koshi-2026'], + }), id: event.id, title: event.title, observed_date: event.observedDate, diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index c841d197b..475df97ba 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -1,5 +1,6 @@ /** Natural hazard queries: earthquakes and active fires. */ +import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; @@ -38,6 +39,16 @@ export const getEarthquakes = defineTool({ return { summary: `${countNoun(events.length, 'earthquake')} of M${minimum}+ in the last 24 hours${where}${strongest}.`, data: { + view: suggestView( + services, + area + ? { area, layers: ['earthquakes'] } + : { + point: { lat: 20, lon: 0 }, + altitudeM: 15_000_000, + layers: ['earthquakes'], + }, + ), ...result, rows: result.rows.map((event) => ({ id: event.usgsId ?? event.stableId, @@ -93,6 +104,7 @@ export const getActiveFires = defineTool({ `${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours` + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { + view: suggestView(services, { area, layers: ['local-firms'] }), stale, missing_sources: missing, fetched_at: isoTime(snapshot.fetchedAt), diff --git a/src/tools/queries/imagery.js b/src/tools/queries/imagery.js index 5f412c5e2..310f27056 100644 --- a/src/tools/queries/imagery.js +++ b/src/tools/queries/imagery.js @@ -1,5 +1,6 @@ /** The most recent satellite image of an area. */ +import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, areaRadiusKm, resolveArea } from '../area.js'; import { toBase64 } from '../results.js'; @@ -105,6 +106,7 @@ export const getRecentImagery = defineTool({ return { summary: `Most recent image of ${area.label}: ${sensor}, ${candidate.day}, ${quality}${reason === 'overview' ? '' : `, ${cloud}`}.`, data: { + view: suggestView(services, { area, layers: ['recent-imagery'] }), area: area.label, image: { product: candidate.product, diff --git a/src/tools/queries/infrastructure.js b/src/tools/queries/infrastructure.js index fd64f5fa5..8f80fa441 100644 --- a/src/tools/queries/infrastructure.js +++ b/src/tools/queries/infrastructure.js @@ -1,5 +1,6 @@ /** Bundled OpenStreetMap infrastructure: datacenters and dams. */ +import { suggestView } from '../views.js'; import { defineTool } from '../catalog.js'; import { AREA_SCHEMA, @@ -62,6 +63,7 @@ export const findInfrastructure = defineTool({ return { summary: `${countNoun(rows.length, `mapped ${noun}`)} in ${area.label}.`, data: { + view: suggestView(services, { area, layers: [layerId] }), ...capRows(rows, args.limit), attribution: ATTRIBUTION[args.kind], }, diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index 5c2107a68..045f148dc 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -1,5 +1,6 @@ /** Maritime queries over the live vessel source (src/sources/live). */ +import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, @@ -104,7 +105,11 @@ export const vesselsInArea = defineTool({ summary: `${countNoun(rows.length, kind)} in ${area.label}` + (snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'), - data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) }, + data: { + view: suggestView(services, { area, layers: ['ais-live-vessels'] }), + ...capRows(rows, args.limit), + ...snapshotInfo(snapshot), + }, }; }, }); @@ -148,7 +153,19 @@ export const findVessel = defineTool({ summary: rows.length ? `Found ${countNoun(rows.length, 'vessel')} with ${key} ${args[key]}.` : `No vessel with ${key} ${args[key]} is currently reported.`, - data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) }, + data: { + // Ships cannot be followed from a link; one match is framed closely. + view: + rows.length === 1 + ? suggestView(services, { + point: { lat: rows[0].lat, lon: rows[0].lon }, + layers: ['ais-live-vessels'], + altitudeM: 5_000, + }) + : null, + ...capRows(rows, args.limit), + ...snapshotInfo(snapshot), + }, }; }, }); @@ -177,6 +194,13 @@ export const getVesselTrack = defineTool({ ? `${countNoun(points.length, 'position')} for ${args.mmsi} from ${isoTime(points[0].observedAtMs)} to ${isoTime(points.at(-1).observedAtMs)}.` : `No recent track is available for ${args.mmsi}.`, data: { + view: kept.length + ? suggestView(services, { + point: { lat: kept.at(-1).latitude, lon: kept.at(-1).longitude }, + layers: ['ais-live-vessels'], + altitudeM: 20_000, + }) + : null, mmsi: args.mmsi, total: points.length, returned: kept.length, diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js index f866c9c86..4879d57c5 100644 --- a/src/tools/queries/media.js +++ b/src/tools/queries/media.js @@ -1,5 +1,6 @@ /** Public camera and radio queries over the CCTV and radio sources. */ +import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, @@ -106,6 +107,7 @@ export const findCctvCameras = defineTool({ .join(', ')}).` : '.'), data: { + view: suggestView(services, { area, layers: ['cctv'] }), ...capRows(rows, args.limit), complete: trimmed.length === 0, catalog_trimmed: trimmed, @@ -150,6 +152,11 @@ export const getCctvSnapshot = defineTool({ return { summary: `Current view from ${name}${camera.city ? ` in ${camera.city}` : ''}${camera.credit ? `, courtesy of ${camera.credit}` : ''}.`, data: { + view: suggestView(services, { + point: { lat: camera.lat, lon: camera.lon }, + altitudeM: 1_500, + layers: ['cctv'], + }), id: camera.id, name, city: text(camera.city), @@ -247,6 +254,7 @@ export const findRadioStations = defineTool({ return { summary: `${countNoun(rows.length, 'radio station')}${what}${where} (${notes.join('; ')}).`, data: { + view: area ? suggestView(services, { area, layers: ['radio'] }) : null, ...capRows(rows, args.limit), directory_size: directory.stations.length, stale: directory.stale === true, diff --git a/src/tools/queries/mobility.js b/src/tools/queries/mobility.js index 2b5c735e2..743fa22a2 100644 --- a/src/tools/queries/mobility.js +++ b/src/tools/queries/mobility.js @@ -1,5 +1,6 @@ /** Bike-share and public transit queries. */ +import { suggestView } from '../views.js'; import { parseStationInformation, parseStationStatus, @@ -134,6 +135,7 @@ export const getBikeShare = defineTool({ `${countNoun(bikes, 'bike')} available` + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { + view: suggestView(services, { area, layers: ['bikeshare'] }), ...capRows(rows, args.limit), systems: systems.map((system) => system.city), systems_not_searched: skipped.map((system) => system.city), @@ -275,6 +277,7 @@ export const getTransitVehicles = defineTool({ `${countNoun(rows.length, 'transit vehicle')}${what} in ${area.label}` + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { + view: suggestView(services, { area, layers: ['transit'] }), ...capRows(rows, args.limit), stale: staleFeeds > 0, expired_positions_dropped: expired, @@ -472,6 +475,7 @@ export const getTrafficFlow = defineTool({ ? ' Some map tiles did not load, so these figures are partial.' : ''), data: { + view: suggestView(services, { area, layers: ['traffic'] }), area: area.label, partial: flow.partial, speed_pct_of_free_flow: speedPct, diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index f15ce34d2..6820aefbd 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -2,12 +2,8 @@ import { VIEW_PROPERTIES, createView, viewUrl } from '../../view/index.js'; import { defineTool, ToolError } from '../catalog.js'; -import { AREA_SCHEMA, areaCenter, areaRadiusKm, resolveArea } from '../area.js'; - -const MIN_ALTITUDE_M = 500; -const MAX_ALTITUDE_M = 15_000_000; -// A top-down view sees roughly this many meters of ground per meter of altitude. -const GROUND_PER_ALTITUDE = 0.55; +import { AREA_SCHEMA, resolveArea } from '../area.js'; +import { cameraForArea } from '../views.js'; /** Tool arguments that describe a view: an area to frame, or a camera. */ export const VIEW_ARGUMENTS = Object.freeze({ @@ -24,20 +20,8 @@ export async function resolveViewArguments(args, { services, signal }) { let label = null; if (args.area) { const area = await resolveArea(args.area, { services, signal }); - const center = areaCenter(area); label = area.label; - camera = { - lat: center.lat, - lon: center.lon, - altitude_m: Math.min( - MAX_ALTITUDE_M, - Math.max( - MIN_ALTITUDE_M, - (areaRadiusKm(area) * 1000) / GROUND_PER_ALTITUDE, - ), - ), - ...camera, - }; + camera = { ...cameraForArea(area), ...camera }; } if (!Number.isFinite(camera.lat) || !Number.isFinite(camera.lon)) throw new ToolError( diff --git a/src/tools/queries/space.js b/src/tools/queries/space.js index ad65f80e8..ef59c0be5 100644 --- a/src/tools/queries/space.js +++ b/src/tools/queries/space.js @@ -1,5 +1,6 @@ /** Space queries: launches, satellite passes and satellites overhead. */ +import { suggestView } from '../views.js'; import { twoline2satrec } from 'satellite.js'; import { findNextSatellitePass, @@ -57,7 +58,15 @@ export const getRecentLaunches = defineTool({ summary: `${countNoun(rows.length, 'launch', 'launches')} in the last 30 days${rows[0] ? `; latest ${rows[0].name}` : ''}` + (stale ? ' (data may be stale).' : '.'), - data: { ...result, stale }, + data: { + view: suggestView(services, { + point: { lat: 20, lon: 0 }, + altitudeM: 15_000_000, + layers: ['rocket-launches'], + }), + ...result, + stale, + }, }; }, }); @@ -103,6 +112,17 @@ const round = (value, digits) => Number.isFinite(value) ? Number(value.toFixed(digits)) : null; const nowMs = (services) => services.clock?.now() ?? Date.now(); +/** A view following a satellite from above an observer. */ +function followSatellite(services, point, satellite) { + return satellite.norad + ? suggestView(services, { + point, + altitudeM: 3_000_000, + follow: { kind: 'satellite', id: String(satellite.norad) }, + }) + : null; +} + async function readCatalog(services, group, signal) { const result = await services.satellites.readGroup(group, { signal }); if (!result.ok) @@ -197,6 +217,7 @@ export const nextSatellitePass = defineTool({ return { summary: `${name} has no ${args.visible_only ? 'visible ' : ''}pass over ${point.label} in the next ${hours} hours${staleNote}.`, data: { + view: followSatellite(services, point, satellite), stale, location: point, satellite: name, @@ -210,6 +231,7 @@ export const nextSatellitePass = defineTool({ `peaking at ${Math.round(pass.maxElevDeg)}°` + `${pass.visible ? '; visible to the naked eye' : ''}${staleNote}.`, data: { + view: followSatellite(services, point, satellite), stale, location: point, satellite: name, @@ -273,6 +295,11 @@ export const satellitesOverhead = defineTool({ `${countNoun(rows.length, 'satellite')} from the ${group} group ${rows.length === 1 ? 'is' : 'are'} at least ${minimum}° above ${point.label} at ${isoTime(at)}` + (stale ? ' (orbit data may be stale).' : '.'), data: { + view: suggestView(services, { + point, + altitudeM: 3_000_000, + layers: ['satellites'], + }), ...capRows(rows, args.limit), stale, location: point, diff --git a/src/tools/queries/surveillance.js b/src/tools/queries/surveillance.js index 6571036d5..06723007a 100644 --- a/src/tools/queries/surveillance.js +++ b/src/tools/queries/surveillance.js @@ -1,5 +1,6 @@ /** Automated license plate reader cameras mapped in OpenStreetMap. */ +import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, @@ -88,6 +89,7 @@ export const findAlprCameras = defineTool({ `${countNoun(rows.length, 'license plate reader camera')} mapped in ${area.label}` + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { + view: suggestView(services, { area, layers: ['alpr-cameras'] }), ...capRows(rows, args.limit), // Some tiles failed, or a dense area was trimmed to the source limit. complete: !result.saturated, diff --git a/src/tools/views.js b/src/tools/views.js new file mode 100644 index 000000000..9ffd6d588 --- /dev/null +++ b/src/tools/views.js @@ -0,0 +1,55 @@ +/** + * Suggested views: the God's Eye View view that shows a tool's answer, and + * the link that opens it when the app's address is known. + */ + +import { createView, viewUrl } from '../view/index.js'; +import { areaCenter, areaRadiusKm } from './area.js'; + +const MIN_ALTITUDE_M = 500; +const MAX_ALTITUDE_M = 15_000_000; +// A top-down view sees roughly this many meters of ground per meter of altitude. +const GROUND_PER_ALTITUDE = 0.55; +const POINT_ALTITUDE_M = 50_000; + +/** A camera looking straight down that frames a resolved area. */ +export function cameraForArea(area) { + const center = areaCenter(area); + return { + lat: center.lat, + lon: center.lon, + altitude_m: Math.min( + MAX_ALTITUDE_M, + Math.max( + MIN_ALTITUDE_M, + (areaRadiusKm(area) * 1000) / GROUND_PER_ALTITUDE, + ), + ), + }; +} + +/** The app's address, or null when it is not configured. */ +function appUrl(services) { + try { + return services.app?.baseUrl ? new URL(services.app.baseUrl).href : null; + } catch { + return null; + } +} + +/** + * A view of an area or point with layers on and optionally something + * followed, plus `url` (null without the app's address). A point is framed + * from `altitudeM` above it. + */ +export function suggestView( + services, + { area, point, layers = [], follow = null, altitudeM = POINT_ALTITUDE_M }, +) { + const camera = area + ? cameraForArea(area) + : { lat: point.lat, lon: point.lon, altitude_m: altitudeM }; + const view = createView({ camera, layers, follow }); + const base = appUrl(services); + return { ...view, url: base ? viewUrl(base, view) : null }; +} diff --git a/src/tools/views.test.mjs b/src/tools/views.test.mjs new file mode 100644 index 000000000..6e8d9f321 --- /dev/null +++ b/src/tools/views.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from './index.js'; +import { viewFromParams } from '../view/index.js'; +import { cameraForArea, suggestView } from './views.js'; +import { resolveArea } from './area.js'; + +const app = { baseUrl: 'https://maps.example/' }; +const snapshot = (records, extra = {}) => ({ + records, + complete: true, + source: 'Test feed', + observedAtMs: Date.UTC(2026, 0, 1), + freshness: 'current', + ...extra, +}); +const aircraft = (id, latitude, longitude, extra = {}) => ({ + id, + latitude, + longitude, + callsign: 'UAL1', + baroAltitudeM: 10000, + onGround: false, + positionTimeMs: Date.UTC(2026, 0, 1), + ...extra, +}); +const feed = (records) => ({ getSnapshot: async () => snapshot(records) }); + +test('suggested views frame an area or point and link to the app', async () => { + const area = await resolveArea({ lat: 30, lon: -97, radius_km: 10 }); + const view = suggestView({ app }, { area, layers: ['flights'] }); + assert.deepEqual(view.camera, { + ...cameraForArea(area), + altitude_m: Math.round(cameraForArea(area).altitude_m), + heading_deg: 0, + pitch_deg: -90, + }); + assert.deepEqual(view.layers, ['flights']); + const params = new URLSearchParams(new URL(view.url).hash.slice(1)); + const { url, ...rest } = view; + assert.deepEqual(viewFromParams(params), rest); + assert.equal(suggestView({}, { area, layers: [] }).url, null); + assert.equal( + suggestView({ app: { baseUrl: 'not a url' } }, { area }).url, + null, + ); + const point = suggestView({ app }, { point: { lat: 1, lon: 2 } }); + assert.equal(point.camera.altitude_m, 50000); +}); + +test('answers carry the view that shows them', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + app, + aircraft: feed([aircraft('abc123', 37.62, -122.38)]), + military: feed([]), + }, + }); + const area = await catalog.call('aircraft_in_area', { + area: { lat: 37.62, lon: -122.38, radius_km: 20 }, + }); + assert.deepEqual(area.data.view.layers, ['flights']); + assert.match(area.data.view.url, /^https:\/\/maps\.example\/#v=2&/); + const found = await catalog.call('find_aircraft', { callsign: 'UAL1' }); + assert.deepEqual(found.data.view.follow, { kind: 'aircraft', id: 'abc123' }); + assert.deepEqual(found.data.view.layers, ['flights']); +}); + +test('the flood and the brief frame what they describe', async () => { + const { readFile } = await import('node:fs/promises'); + const bundled = JSON.parse( + await readFile( + new URL( + '../../public/events/bhote-koshi-2026/event.json', + import.meta.url, + ), + 'utf8', + ), + ); + const flood = await composeCatalog({ + tools: coreTools, + services: { app, events: { getEvent: async () => bundled } }, + }).call('get_bhote_koshi_flood', {}); + assert.deepEqual(flood.data.view.layers, ['bhote-koshi-2026']); + assert.ok(Math.abs(flood.data.view.camera.lat - 28.13) < 0.2); + assert.ok(Math.abs(flood.data.view.camera.lon - 85.32) < 0.2); + const brief = await composeCatalog({ + tools: coreTools, + services: { + app, + weather: { + getConditions: async () => ({ status: 'ready', weather: {} }), + }, + aircraft: feed([]), + earthquakes: { getSnapshot: async () => [] }, + }, + }).call('situation_brief', { + area: { lat: 30, lon: -97, radius_km: 50 }, + }); + assert.deepEqual(brief.data.view.layers, ['earthquakes', 'flights']); +}); From af8856cedfc55127b2e3d9710af2aa17bb12afa8 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 21:25:27 -0700 Subject: [PATCH 062/111] feat: lead the MCP tool list with what the globe shows MCP no longer lists place search, routing, plain weather and wind, the regional brief, the HUD caption, radio, bike share and transit. Assistants already cover these or they add little without the globe; voice keeps them, and tools that combine others still reach them. --- src/tools/localServer.test.mjs | 4 ++-- src/tools/surfaces.js | 18 ++++++++++++++++-- src/tools/surfaces.test.mjs | 34 +++++++++++++++++++++++++--------- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/src/tools/localServer.test.mjs b/src/tools/localServer.test.mjs index 19e09516b..3cb61ebbb 100644 --- a/src/tools/localServer.test.mjs +++ b/src/tools/localServer.test.mjs @@ -4,7 +4,7 @@ import { PassThrough } from 'node:stream'; import { createLocalMcpServer } from '../../server/mcp/server.js'; import { createApiFetch } from '../../server/mcp/services.js'; import { parseArgs, serveStdio } from '../../server/mcp/stdio.js'; -import { coreTools } from './index.js'; +import { coreTools, toolsForSurface } from './index.js'; const usgs = { type: 'FeatureCollection', @@ -70,7 +70,7 @@ test('the stdio server answers newline-delimited requests using only its data so // Every Core tool's services are composed locally. assert.deepEqual( byId.get(2).result.tools.map((tool) => tool.name), - coreTools.map((tool) => tool.name), + toolsForSurface(coreTools, 'mcp').map((tool) => tool.name), ); assert.equal( byId.get(3).result.content[0].text, diff --git a/src/tools/surfaces.js b/src/tools/surfaces.js index 73a45e499..7b1030f88 100644 --- a/src/tools/surfaces.js +++ b/src/tools/surfaces.js @@ -2,6 +2,8 @@ * Which surfaces offer each tool. A tool is on every surface unless this * table turns it off; edit an entry to turn a tool on or off for MCP, voice, * or both. Applications can pass overrides when they compose a surface. + * Tools off a surface stay reachable from tools that combine others, such as + * situation_brief using get_weather. */ import { ToolError } from './catalog.js'; @@ -9,6 +11,7 @@ import { ToolError } from './catalog.js'; export const SURFACES = Object.freeze(['mcp', 'voice']); const voiceOff = Object.freeze({ voice: false }); +const mcpOff = Object.freeze({ mcp: false }); export const TOOL_SURFACES = Object.freeze({ // Voice runs inside the app, so a link to it adds nothing. @@ -19,8 +22,8 @@ export const TOOL_SURFACES = Object.freeze({ get_weather_map: voiceOff, get_recent_imagery: voiceOff, get_cctv_snapshot: voiceOff, - // Voice shows the HUD caption in the app itself. - get_hud_caption: voiceOff, + // The app shows its HUD caption itself, and assistants write their own. + get_hud_caption: Object.freeze({ voice: false, mcp: false }), // Voice answers these from the loaded layers with analyst_query. aircraft_in_area: voiceOff, vessels_in_area: voiceOff, @@ -30,6 +33,17 @@ export const TOOL_SURFACES = Object.freeze({ get_active_fires: voiceOff, find_infrastructure: voiceOff, satellites_overhead: voiceOff, + // MCP leads with what the globe shows; assistants already search places, + // plan routes and report weather, and these layers add little in a chat. + search_places: mcpOff, + places_nearby: mcpOff, + plan_route: mcpOff, + get_weather: mcpOff, + get_wind: mcpOff, + get_regional_brief: mcpOff, + find_radio_stations: mcpOff, + get_bike_share: mcpOff, + get_transit_vehicles: mcpOff, }); /** diff --git a/src/tools/surfaces.test.mjs b/src/tools/surfaces.test.mjs index 5fc7424da..255b7c9fd 100644 --- a/src/tools/surfaces.test.mjs +++ b/src/tools/surfaces.test.mjs @@ -11,17 +11,32 @@ import { const names = (tools) => tools.map((tool) => tool.name); -test('MCP offers every tool and voice leaves out the table entries', () => { - assert.deepEqual(names(toolsForSurface(coreTools, 'mcp')), names(coreTools)); +test('each surface offers the tools the table does not turn off', () => { + for (const surface of ['mcp', 'voice']) { + const offered = names(toolsForSurface(coreTools, surface)); + const expected = names(coreTools).filter( + (name) => TOOL_SURFACES[name]?.[surface] !== false, + ); + 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', + ]) + assert.ok(mcp.includes(name), name); + for (const name of [ + 'search_places', + 'plan_route', + 'get_weather', + 'get_hud_caption', + ]) + assert.ok(!mcp.includes(name), name); const voice = names(toolsForSurface(coreTools, 'voice')); - assert.equal( - voice.length, - coreTools.length - Object.keys(TOOL_SURFACES).length, - ); - for (const name of Object.keys(TOOL_SURFACES)) - assert.ok(!voice.includes(name), name); assert.ok(voice.includes('get_weather')); assert.ok(voice.includes('military_awareness')); + assert.ok(!voice.includes('aircraft_in_area')); }); test('overrides turn a tool on or off for one surface', () => { @@ -44,7 +59,8 @@ test('every table entry names a core tool and a known surface', () => { const names = new Set(coreTools.map((tool) => tool.name)); for (const [name, entry] of Object.entries(TOOL_SURFACES)) { assert.ok(names.has(name), name); - assert.deepEqual(Object.keys(entry), ['voice']); + for (const key of Object.keys(entry)) + assert.ok(['voice', 'mcp'].includes(key), `${name}: ${key}`); } }); From f68aa988ec07c13df224a99272b91d25c24c13fa Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 22:08:18 -0700 Subject: [PATCH 063/111] feat: carry annotations in views and aim tilted views at the area A view can carry the marks the app's annotate_map action draws, and links carry them in the an parameter, bounded in count and size. A tilted view of an area now places the camera behind its center so the area stays in the middle of the frame. --- src/tools/queries/share.js | 18 ++++- src/tools/views.js | 37 ++++++++++ src/tools/views.test.mjs | 28 +++++++- src/view/index.js | 137 +++++++++++++++++++++++++++++++++++++ src/view/index.test.mjs | 49 +++++++++++++ 5 files changed, 265 insertions(+), 4 deletions(-) diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index 6820aefbd..941fd4616 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -3,7 +3,7 @@ import { VIEW_PROPERTIES, createView, viewUrl } from '../../view/index.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, resolveArea } from '../area.js'; -import { cameraForArea } from '../views.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({ @@ -21,7 +21,17 @@ export async function resolveViewArguments(args, { services, signal }) { if (args.area) { const area = await resolveArea(args.area, { services, signal }); label = area.label; - camera = { ...cameraForArea(area), ...camera }; + const framed = cameraForArea(area); + // A tilted view of an area looks at its center from behind, unless the + // caller placed the camera itself. + camera = + camera.lat === undefined && camera.lon === undefined + ? cameraLookingAt(framed, { + altitudeM: camera.altitude_m ?? framed.altitude_m, + headingDeg: camera.heading_deg ?? 0, + pitchDeg: camera.pitch_deg ?? -90, + }) + : { ...framed, ...camera }; } if (!Number.isFinite(camera.lat) || !Number.isFinite(camera.lon)) throw new ToolError( @@ -34,6 +44,7 @@ export async function resolveViewArguments(args, { services, signal }) { style: args.style ?? null, map: args.map ?? null, follow: args.follow ?? null, + annotations: args.annotations ?? [], }); return { view, @@ -56,7 +67,8 @@ export const openInGodsEyeView = defineTool({ description: "A link that opens God's Eye View at a view: an area framed from above " + 'or a camera position, with chosen data layers, visual style and map, ' + - 'optionally following an aircraft or satellite.', + 'optionally following an aircraft or satellite and with marks drawn on ' + + 'the globe.', inputSchema: { type: 'object', properties: VIEW_ARGUMENTS, diff --git a/src/tools/views.js b/src/tools/views.js index 9ffd6d588..59e8fe257 100644 --- a/src/tools/views.js +++ b/src/tools/views.js @@ -28,6 +28,43 @@ export function cameraForArea(area) { }; } +const EARTH_RADIUS_M = 6_371_008.8; + +/** + * The camera position for looking at a point from `altitudeM` with a heading + * and a tilt: straight above it when looking down, otherwise pulled back + * along the heading so the point stays in the middle of the view. + */ +export function cameraLookingAt( + target, + { altitudeM, headingDeg = 0, pitchDeg = -90 }, +) { + const camera = { + lat: target.lat, + lon: target.lon, + altitude_m: altitudeM, + heading_deg: headingDeg, + pitch_deg: pitchDeg, + }; + if (pitchDeg <= -89.5) return camera; + // Ground distance from the camera to the point it looks at, on flat ground. + const back = altitudeM / Math.tan((-Math.min(pitchDeg, -5) * Math.PI) / 180); + const heading = (headingDeg * Math.PI) / 180; + const lat = + target.lat - + ((back * Math.cos(heading)) / EARTH_RADIUS_M) * (180 / Math.PI); + const lon = + target.lon - + ((back * Math.sin(heading)) / + (EARTH_RADIUS_M * Math.cos((target.lat * Math.PI) / 180))) * + (180 / Math.PI); + return { + ...camera, + lat: Math.max(-90, Math.min(90, lat)), + lon: ((((lon + 180) % 360) + 360) % 360) - 180, + }; +} + /** The app's address, or null when it is not configured. */ function appUrl(services) { try { diff --git a/src/tools/views.test.mjs b/src/tools/views.test.mjs index 6e8d9f321..474b37032 100644 --- a/src/tools/views.test.mjs +++ b/src/tools/views.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { composeCatalog, coreTools } from './index.js'; import { viewFromParams } from '../view/index.js'; -import { cameraForArea, suggestView } from './views.js'; +import { cameraForArea, cameraLookingAt, suggestView } from './views.js'; import { resolveArea } from './area.js'; const app = { baseUrl: 'https://maps.example/' }; @@ -100,3 +100,29 @@ test('the flood and the brief frame what they describe', async () => { }); assert.deepEqual(brief.data.view.layers, ['earthquakes', 'flights']); }); + +test('a tilted camera sits behind the point it looks at', () => { + const target = { lat: 25, lon: 121 }; + assert.deepEqual(cameraLookingAt(target, { altitudeM: 1000 }), { + lat: 25, + lon: 121, + altitude_m: 1000, + heading_deg: 0, + pitch_deg: -90, + }); + // Looking north at 45 degrees from 10 km up: about 10 km south. + const north = cameraLookingAt(target, { + altitudeM: 10000, + headingDeg: 0, + pitchDeg: -45, + }); + assert.ok(Math.abs(north.lat - (25 - 10000 / 111195)) < 1e-3); + assert.ok(Math.abs(north.lon - 121) < 1e-9); + // Looking east: the camera is to the west. + const east = cameraLookingAt(target, { + altitudeM: 10000, + headingDeg: 90, + pitchDeg: -45, + }); + assert.ok(east.lon < 121 && Math.abs(east.lat - 25) < 1e-9); +}); diff --git a/src/view/index.js b/src/view/index.js index d988918ed..7bd0cd18c 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -63,6 +63,26 @@ const FOLLOWABLE = Object.freeze({ export const VIEW_FOLLOW_KINDS = Object.freeze(Object.keys(FOLLOWABLE)); +/** Annotation kinds the app draws, as in its annotate_map action. */ +export const VIEW_ANNOTATION_TYPES = Object.freeze([ + 'pin', + 'highlight', + 'area', + 'arrow', + 'route', + 'label', +]); +const ANNOTATION_COLORS = Object.freeze([ + 'primary', + 'amber', + 'cyan', + 'green', + 'red', +]); +const MAX_ANNOTATIONS = 24; +const MAX_ANNOTATION_PARAM_CHARS = 6000; +const ANNOTATION_PARAM = 'an'; + const MIN_ALTITUDE_M = 50; const MAX_ALTITUDE_M = 20_000_000; @@ -103,6 +123,43 @@ export const VIEW_PROPERTIES = Object.freeze({ enum: [...VIEW_MAPS], description: 'Map imagery; photoreal is Google 3D tiles.', }), + annotations: Object.freeze({ + type: 'array', + maxItems: MAX_ANNOTATIONS, + description: + 'Marks drawn on the globe: a pin, highlight, area or label at a place ' + + 'name (target) or lat/lon, an arrow between two points, or a route ' + + 'through points.', + items: { + type: 'object', + properties: { + type: { type: 'string', enum: [...VIEW_ANNOTATION_TYPES] }, + target: { type: 'string', minLength: 1, maxLength: 200 }, + latitude: { type: 'number', minimum: -90, maximum: 90 }, + longitude: { type: 'number', minimum: -180, maximum: 180 }, + toLatitude: { type: 'number', minimum: -90, maximum: 90 }, + toLongitude: { type: 'number', minimum: -180, maximum: 180 }, + points: { + type: 'array', + minItems: 2, + maxItems: 12, + items: { + type: 'object', + properties: { + latitude: { type: 'number', minimum: -90, maximum: 90 }, + longitude: { type: 'number', minimum: -180, maximum: 180 }, + }, + required: ['latitude', 'longitude'], + additionalProperties: false, + }, + }, + label: { type: 'string', maxLength: 120 }, + color: { type: 'string', enum: [...ANNOTATION_COLORS] }, + }, + required: ['type'], + additionalProperties: false, + }, + }), follow: Object.freeze({ type: 'object', description: @@ -119,6 +176,59 @@ export const VIEW_PROPERTIES = Object.freeze({ }); const round = (value, digits) => Number(value.toFixed(digits)); +const latitude = (value) => + Number.isFinite(value) && Math.abs(value) <= 90 ? value : undefined; +const longitude = (value) => + Number.isFinite(value) && Math.abs(value) <= 180 ? value : undefined; +const text = (value, max) => + typeof value === 'string' && value.trim() + ? value.trim().slice(0, max) + : undefined; + +/** + * One annotation with only the fields the app draws, or null when it has + * nothing to place. Unknown fields and out-of-range values are dropped. + */ +function annotationOf(input) { + if (!input || !VIEW_ANNOTATION_TYPES.includes(input.type)) return null; + const points = Array.isArray(input.points) + ? input.points + .slice(0, 12) + .map((point) => ({ + latitude: latitude(point?.latitude), + longitude: longitude(point?.longitude), + })) + .filter( + (point) => + point.latitude !== undefined && point.longitude !== undefined, + ) + : []; + // Coordinates are kept or dropped in pairs; half a position places nothing. + const pair = (lat, lon) => + latitude(lat) !== undefined && longitude(lon) !== undefined + ? [lat, lon] + : [undefined, undefined]; + const [lat, lon] = pair(input.latitude, input.longitude); + const [toLat, toLon] = pair(input.toLatitude, input.toLongitude); + const entry = Object.fromEntries( + Object.entries({ + type: input.type, + target: text(input.target, 200), + latitude: lat, + longitude: lon, + toLatitude: toLat, + toLongitude: toLon, + points: points.length >= 2 ? points : undefined, + label: text(input.label, 120), + color: ANNOTATION_COLORS.includes(input.color) ? input.color : undefined, + }).filter(([, value]) => value !== undefined), + ); + const placed = + entry.target !== undefined || + entry.points !== undefined || + (entry.latitude !== undefined && entry.longitude !== undefined); + return placed ? Object.freeze(entry) : null; +} const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); /** @@ -132,6 +242,7 @@ export function createView({ style = null, map = null, follow = null, + annotations = [], } = {}) { if (!Number.isFinite(camera?.lat) || !Number.isFinite(camera?.lon)) throw new TypeError('A view needs a camera lat and lon'); @@ -167,6 +278,12 @@ export function createView({ id: String(follow.id).toLowerCase(), }) : null, + annotations: Object.freeze( + (Array.isArray(annotations) ? annotations : []) + .slice(0, MAX_ANNOTATIONS) + .map(annotationOf) + .filter(Boolean), + ), }); } @@ -194,9 +311,28 @@ export function viewToParams(view) { }; encodeLayerStateParams(params, state); } + if (view.annotations.length) { + const encoded = JSON.stringify(view.annotations); + if (encoded.length <= MAX_ANNOTATION_PARAM_CHARS) + params.set(ANNOTATION_PARAM, encoded); + } return params; } +/** The annotations a share link carries, or an empty list. */ +export function annotationsFromParams(params) { + const raw = params.get(ANNOTATION_PARAM); + if (!raw || raw.length > MAX_ANNOTATION_PARAM_CHARS) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) + ? parsed.slice(0, MAX_ANNOTATIONS).map(annotationOf).filter(Boolean) + : []; + } catch { + return []; + } +} + /** The view a set of share-link hash parameters restores, or null. */ export function viewFromParams(params) { const lat = Number.parseFloat(params.get('lat')); @@ -226,6 +362,7 @@ export function viewFromParams(params) { style: STYLES_BY_URL_NAME[params.get('style')] ?? null, map: VIEW_MAPS.includes(map) ? map : null, follow, + annotations: annotationsFromParams(params), }); } diff --git a/src/view/index.test.mjs b/src/view/index.test.mjs index 49497c6d7..818feb1ba 100644 --- a/src/view/index.test.mjs +++ b/src/view/index.test.mjs @@ -77,3 +77,52 @@ test('views use the same style names as the app share links', async () => { assert.match(source, /const STYLE_TO_URL = STYLE_URL_NAMES;/); assert.equal(STYLE_URL_NAMES.retro, 'crt'); }); + +test('annotations travel in the link, keeping only what the app draws', async () => { + const { annotationsFromParams } = await import('./index.js'); + const view = createView({ + camera: { lat: 25, lon: 121 }, + annotations: [ + { type: 'pin', target: 'Taipei 101', label: 'Taipei 101', color: 'red' }, + { + type: 'arrow', + latitude: 24, + longitude: 119, + toLatitude: 25, + toLongitude: 121, + }, + { + type: 'route', + points: [ + { latitude: 1, longitude: 2 }, + { latitude: 3, longitude: 4 }, + ], + screenX: 0.5, + }, + { type: 'pin' }, + { type: 'sparkle', target: 'x' }, + { type: 'label', latitude: 200, longitude: 0, target: 'Here' }, + ], + }); + assert.equal(view.annotations.length, 4); + assert.equal(view.annotations[2].screenX, undefined); + assert.deepEqual(view.annotations[3], { type: 'label', target: 'Here' }); + const params = viewToParams(view); + assert.ok(params.has('an')); + assert.deepEqual(viewFromParams(params), view); + assert.deepEqual( + annotationsFromParams(new URLSearchParams('an=not json')), + [], + ); + const many = createView({ + camera: { lat: 0, lon: 0 }, + annotations: Array.from({ length: 30 }, () => ({ + type: 'label', + target: 'x'.repeat(200), + label: 'y'.repeat(120), + })), + }); + assert.equal(many.annotations.length, 24); + // Too long for a link: the view keeps them, the link leaves them out. + assert.equal(viewToParams(many).has('an'), false); +}); From 60c40865e98cd974417732a8818d2df1b68f80ca Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 22:08:18 -0700 Subject: [PATCH 064/111] feat: add an embed mode that shows only the globe ?embed=1 shows the globe alone: clean view with the HUD, panels, welcome and setup prompts hidden, and provider attribution kept. The page that frames it sends views with postMessage; the app applies each through its own actions and reports every step. Annotations in any link are drawn once it restores. Only embed-mode documents may be framed; every other page keeps X-Frame-Options DENY and frame-ancestors 'none', and GEV_EMBED_FRAME_ANCESTORS can restrict who frames them. --- .env.example | 5 + build/embed-framing.js | 55 +++++++++ build/vite.js | 9 +- docs/TOOLS.md | 28 ++++- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 9 +- src/app/embed.js | 198 ++++++++++++++++++++++++++++++++ src/app/embed.test.mjs | 172 +++++++++++++++++++++++++++ src/app/tools.js | 10 ++ src/firstRunExperience.js | 2 + src/tooling/viteBuild.test.mjs | 65 ++++++++++- src/ui/styles/embed.css | 14 +++ style.css | 1 + 13 files changed, 559 insertions(+), 12 deletions(-) create mode 100644 build/embed-framing.js create mode 100644 src/app/embed.js create mode 100644 src/app/embed.test.mjs create mode 100644 src/ui/styles/embed.css diff --git a/.env.example b/.env.example index 67a1e74a7..7a802927c 100644 --- a/.env.example +++ b/.env.example @@ -186,3 +186,8 @@ AISSTREAM_API_KEY= # Optional: comma-separated Overpass URLs; use only instances you run or pay for. OVERPASS_UPSTREAMS= + +# Optional: who may frame the app in embed mode (?embed=1), as CSP +# frame-ancestors sources such as "https://example.com". Unset allows any +# page; every other page of the app can never be framed. +# GEV_EMBED_FRAME_ANCESTORS= diff --git a/build/embed-framing.js b/build/embed-framing.js new file mode 100644 index 000000000..f3b53d5dc --- /dev/null +++ b/build/embed-framing.js @@ -0,0 +1,55 @@ +/** + * Lets other pages frame the app in embed mode (`?embed=1`). Every other + * document keeps the server's `X-Frame-Options: DENY` and + * `frame-ancestors 'none'`, which protect Provider Settings. Embed mode + * shows only the globe, so for those documents framing is limited to + * `ancestors` instead: `GEV_EMBED_FRAME_ANCESTORS`, or any frame by default. + * "Any" sends no framing restriction at all: `frame-ancestors *` would still + * refuse sandboxed frames with an opaque origin, which panel hosts use. + */ + +const FRAMING_HEADERS = new Set(['x-frame-options', 'content-security-policy']); + +/** Whether a request is for an embed-mode document. */ +export function isEmbedDocumentRequest(url) { + let parsed; + try { + parsed = new URL(url || '/', 'http://localhost'); + } catch { + return false; + } + return ( + parsed.searchParams.get('embed') === '1' && + (parsed.pathname === '/' || parsed.pathname.endsWith('.html')) + ); +} + +/** Vite plugin applying the embed framing policy on dev and preview servers. */ +export function embedFramingPlugin({ + ancestors = process.env.GEV_EMBED_FRAME_ANCESTORS || '*', +} = {}) { + const anywhere = ancestors.trim() === '*'; + const policy = `frame-ancestors ${ancestors}`; + const install = (server) => { + server.middlewares.use((req, res, next) => { + if (!isEmbedDocumentRequest(req.url)) return next(); + // The server's own headers are written when the response is sent, so + // intercept them rather than setting ours first. + const setHeader = res.setHeader.bind(res); + res.setHeader = (name, value) => { + const key = String(name).toLowerCase(); + if (!FRAMING_HEADERS.has(key)) return setHeader(name, value); + if (key === 'content-security-policy' && !anywhere) + return setHeader(name, policy); + return res; + }; + if (!anywhere) setHeader('Content-Security-Policy', policy); + next(); + }); + }; + return { + name: 'embed-framing', + configureServer: install, + configurePreviewServer: install, + }; +} diff --git a/build/vite.js b/build/vite.js index cdff4a27c..c683dbe03 100644 --- a/build/vite.js +++ b/build/vite.js @@ -1,5 +1,6 @@ import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; +import { embedFramingPlugin } from './embed-framing.js'; /** Build browser assets with explicit inputs; never load environment or providers. */ export function createBrowserViteConfig({ @@ -12,7 +13,12 @@ export function createBrowserViteConfig({ command, } = {}) { return { - plugins: [cesium(), applicationHtmlPlugin(), ...plugins], + plugins: [ + cesium(), + applicationHtmlPlugin(), + ...plugins, + embedFramingPlugin(), + ], ...(publicDir === undefined ? {} : { publicDir }), // A production build must not clean the dependency cache a running dev // server is still serving optimized module URLs from. @@ -39,6 +45,7 @@ export function createBrowserViteConfig({ deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'], }, // These headers protect the document containing Provider Settings. + // Embed-mode documents are framable instead; see embed-framing.js. headers: { 'X-Frame-Options': 'DENY', 'Content-Security-Policy': "frame-ancestors 'none'", diff --git a/docs/TOOLS.md b/docs/TOOLS.md index c13dd9073..9c01cfbdf 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -93,10 +93,32 @@ format the app restores, and `viewUrl` gives the address that opens it. The style names are the ones share links use. Ships cannot be followed from a link yet. +A view can also carry `annotations`, the marks the app's `annotate_map` +action draws (pins, highlights, areas, arrows, routes and labels at a place +name or coordinates). Links carry them in the `an` parameter, and the app +draws them once the link has been restored. + Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a -`camera`, plus `layers`, `style`, `map` and `follow`; camera fields given -with an area override its framing. `resolveViewArguments` turns them into a -view. +`camera`, plus `layers`, `style`, `map`, `follow` and `annotations`. A +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. + +### Embed mode + +`?embed=1` shows only the globe: clean view, with the HUD, panels, welcome +and setup prompts hidden; provider attribution stays. A page that frames it +changes the view by posting `{ type: 'gev:view', id, view }` to the frame. +The app applies it through its own actions (style, map, exactly the view's +layers, annotations, the camera, then the followed entity, retried until its +layer has it) and answers `{ type: 'gev:view-applied', id, ok, steps }`. It +posts `{ type: 'gev:ready' }` once it can take views, and only its parent +page can send them. See `src/app/embed.js`. + +The development and preview servers let other pages frame embed-mode +documents only; every other document keeps `X-Frame-Options: DENY` and +`frame-ancestors 'none'`. `GEV_EMBED_FRAME_ANCESTORS` restricts which pages +may frame them (any by default). Answers that have something to show include `data.view`: the view that shows them, with the matching layers on, an area framed from above, and a diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b32160de0..e7a4caa3b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -890,5 +890,6 @@ "src/tools/surfaces.test.mjs", "src/tools/places.test.mjs", "src/view/index.test.mjs", - "src/tools/views.test.mjs" + "src/tools/views.test.mjs", + "src/app/embed.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b42038d1a..73b86600b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -28,7 +28,11 @@ "vite-build": { "runtime": "node", "exports": ["./build/vite", "./build/html"], - "modules": ["build/vite.js", "build/application-html.js"], + "modules": [ + "build/vite.js", + "build/application-html.js", + "build/embed-framing.js" + ], "external": ["vite-plugin-cesium"] }, "live-providers": { @@ -2039,7 +2043,8 @@ "src/sources/infrastructureData.js", "src/tools/functions.js", "src/voice/actionSchemas.js", - "src/view/index.js" + "src/view/index.js", + "src/app/embed.js" ], "external": [ "hls.js", diff --git a/src/app/embed.js b/src/app/embed.js new file mode 100644 index 000000000..7892a9500 --- /dev/null +++ b/src/app/embed.js @@ -0,0 +1,198 @@ +/** + * Embed mode and views in the running app. + * + * With `?embed=1` the app shows only the globe: clean view, with the HUD, + * welcome and setup prompts hidden. A page that frames it changes what it + * shows by posting `{ type: 'gev:view', id, view }`; the app applies the view + * through its own actions and answers `{ type: 'gev:view-applied', id, ok, + * steps }`. It announces `{ type: 'gev:ready' }` once it can take views. + * + * Annotations in a share link are drawn once the link has been restored, + * embedded or not. + */ + +import * as Cesium from 'cesium'; +import { announceNavigationAuthority } from '../navigationPolicy.js'; +import { annotationsFromParams, createView } from '../view/index.js'; + +export const EMBED_VIEW_MESSAGE = 'gev:view'; +export const EMBED_APPLIED_MESSAGE = 'gev:view-applied'; +export const EMBED_READY_MESSAGE = 'gev:ready'; + +const FOLLOW_LAYERS = { + aircraft: 'flights', + military_aircraft: 'military', + satellite: 'satellites', +}; +const FOLLOW_ATTEMPTS = 20; +const FOLLOW_RETRY_MS = 1000; +const FLIGHT_SECONDS = 2; + +/** Whether this page was opened in embed mode. */ +export function isEmbedded(location = globalThis.location) { + return new URLSearchParams(location?.search || '').get('embed') === '1'; +} + +const delay = (ms, signal) => + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + +/** Fly the camera to a view's camera and resolve when the flight ends. */ +function flyTo(viewer, camera) { + announceNavigationAuthority('embed-view'); + return new Promise((resolve) => { + viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees( + camera.lon, + camera.lat, + camera.altitude_m, + ), + orientation: { + heading: Cesium.Math.toRadians(camera.heading_deg), + pitch: Cesium.Math.toRadians(camera.pitch_deg), + roll: 0, + }, + duration: FLIGHT_SECONDS, + complete: () => resolve(true), + cancel: () => resolve(false), + }); + }); +} + +/** + * Make the app show a view: style and map, exactly the view's layers, + * annotations, the camera, and the followed entity. Each step runs through the + * app's own actions (`run(name, args)`); a failed step is reported and the + * rest still run. Resolves to the steps and whether each succeeded. + */ +export async function applyView( + view, + { viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS }, +) { + const steps = []; + const act = async (name, args) => { + let result = null; + try { + result = await run(name, args); + } catch (error) { + result = { ok: false, error: error?.message || String(error) }; + } + steps.push({ + step: name, + ok: result?.ok !== false, + ...(result?.error ? { error: result.error } : {}), + }); + return result; + }; + if (view.style) await act('set_visual_style', { style: view.style }); + if (view.map) await act('set_map_stack', { stack: view.map }); + const wanted = new Set(view.layers); + for (const layer of dataManager.getAll()) { + if (layer.enabled && !wanted.has(layer.id)) + await act('set_layer_visibility', { layerId: layer.id, enabled: false }); + } + for (const layerId of view.layers) { + if (!dataManager.isEnabled(layerId)) + await act('set_layer_visibility', { layerId, enabled: true }); + } + if (!view.follow) await act('stop_tracking', {}); + // Annotations go first: drawing them may frame the marks, and the view's + // own camera should have the last word. + await act('clear_annotations', {}); + if (view.annotations.length) + await act('annotate_map', { annotations: [...view.annotations] }); + steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) }); + if (view.follow) { + // The followed entity appears once its layer has data; retry until then. + const args = { + query: view.follow.id, + layerId: FOLLOW_LAYERS[view.follow.kind], + }; + let followed = false; + for (let attempt = 0; attempt < FOLLOW_ATTEMPTS && !followed; attempt++) { + if (signal?.aborted) break; + try { + followed = (await run('track_entity', args))?.ok === true; + } catch { + followed = false; + } + if (!followed) await delay(retryMs, signal); + } + steps.push({ step: 'follow', ok: followed }); + } + return steps; +} + +/** + * Install embed mode when the page asks for it, and draw any annotations the + * opening link carries. Returns a function that removes the message handler. + */ +export function installViews({ + shell, + viewer, + dataManager, + run, + signal, + location = globalThis.location, + windowRef = globalThis.window, +}) { + const ready = Promise.resolve(shell.initialRestorePromise).catch(() => {}); + const linked = annotationsFromParams( + new URLSearchParams(String(location?.hash || '').replace(/^#/, '')), + ); + if (linked.length) + void ready.then(() => { + if (!signal?.aborted) void run('annotate_map', { annotations: linked }); + }); + if (!isEmbedded(location)) return () => {}; + + windowRef.document.body.classList.add('ui-embed'); + shell.setCleanView?.(true); + const parent = windowRef.parent; + const post = (message) => { + if (parent && parent !== windowRef) parent.postMessage(message, '*'); + }; + // Views apply one at a time, in the order they arrive. + let queue = ready; + const onMessage = (event) => { + if (event.source !== parent || event.data?.type !== EMBED_VIEW_MESSAGE) + return; + const { id = null } = event.data; + let view; + try { + view = createView(event.data.view); + } catch (error) { + post({ + type: EMBED_APPLIED_MESSAGE, + id, + ok: false, + error: error.message, + }); + return; + } + queue = queue.then(async () => { + if (signal?.aborted) return; + const steps = await applyView(view, { viewer, dataManager, run, signal }); + post({ + type: EMBED_APPLIED_MESSAGE, + id, + ok: steps.every((step) => step.ok), + steps, + }); + }); + }; + windowRef.addEventListener('message', onMessage); + void ready.then(() => { + if (!signal?.aborted) post({ type: EMBED_READY_MESSAGE }); + }); + return () => windowRef.removeEventListener('message', onMessage); +} diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs new file mode 100644 index 000000000..ebe9bfabc --- /dev/null +++ b/src/app/embed.test.mjs @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyView, installViews, isEmbedded } from './embed.js'; +import { createView } from '../view/index.js'; + +const fakeViewer = () => { + const flights = []; + return { + flights, + camera: { + flyTo(options) { + flights.push(options); + options.complete(); + }, + }, + }; +}; +const fakeLayers = (enabled) => ({ + getAll: () => + ['flights', 'earthquakes', 'ais-live-vessels'].map((id) => ({ + id, + enabled: enabled.has(id), + })), + isEnabled: (id) => enabled.has(id), +}); +const recorder = (answers = {}) => { + const calls = []; + const run = async (name, args) => { + calls.push([name, args]); + const answer = answers[name]; + return typeof answer === 'function' + ? answer(calls) + : (answer ?? { ok: true }); + }; + return { calls, run }; +}; + +test('embed mode is opt-in by query parameter', () => { + assert.equal(isEmbedded({ search: '?embed=1' }), true); + assert.equal(isEmbedded({ search: '?welcome=0' }), false); +}); + +test('a view is applied through the app actions in order', async () => { + const viewer = fakeViewer(); + const { calls, run } = recorder(); + const view = createView({ + camera: { lat: 25, lon: 121, altitude_m: 300000, pitch_deg: -40 }, + layers: ['ais-live-vessels'], + style: 'thermal', + map: 'osm', + annotations: [{ type: 'pin', target: 'Taipei 101' }], + }); + const steps = await applyView(view, { + viewer, + dataManager: fakeLayers(new Set(['earthquakes'])), + run, + }); + assert.deepEqual( + calls.map(([name, args]) => [ + name, + args.layerId ?? args.style ?? args.stack ?? null, + args.enabled ?? null, + ]), + [ + ['set_visual_style', 'thermal', null], + ['set_map_stack', 'osm', null], + ['set_layer_visibility', 'earthquakes', false], + ['set_layer_visibility', 'ais-live-vessels', true], + ['stop_tracking', null, null], + ['clear_annotations', null, null], + ['annotate_map', null, null], + ], + ); + assert.equal(viewer.flights.length, 1); + assert.ok(steps.every((step) => step.ok)); + assert.ok(steps.some((step) => step.step === 'camera')); +}); + +test('a followed entity is retried until its layer has it', async () => { + let tries = 0; + const { run } = recorder({ + track_entity: () => ({ ok: ++tries >= 3 }), + }); + const view = createView({ + camera: { lat: 0, lon: 0 }, + follow: { kind: 'aircraft', id: 'abc123' }, + }); + const steps = await applyView(view, { + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + retryMs: 1, + }); + assert.equal(tries, 3); + assert.deepEqual(steps.at(-1), { step: 'follow', ok: true }); +}); + +test('an embedded page takes views only from its parent and answers it', async () => { + const posted = []; + const parent = { postMessage: (message) => posted.push(message) }; + const windowRef = new EventTarget(); + windowRef.parent = parent; + windowRef.document = { body: { classList: new Set() } }; + windowRef.document.body.classList.add = Set.prototype.add; + const shell = { + initialRestorePromise: Promise.resolve(), + clean: null, + setCleanView(value) { + this.clean = value; + }, + }; + const { calls, run } = recorder(); + const remove = installViews({ + shell, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { search: '?embed=1', hash: '' }, + windowRef, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(shell.clean, true); + assert.deepEqual(posted, [{ type: 'gev:ready' }]); + const send = (source, data) => { + const event = new Event('message'); + Object.assign(event, { source, data }); + windowRef.dispatchEvent(event); + }; + send({}, { type: 'gev:view', id: 1, view: { camera: { lat: 1, lon: 2 } } }); + send(parent, { type: 'gev:view', id: 2, view: { camera: {} } }); + send(parent, { + type: 'gev:view', + id: 3, + view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] }, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.deepEqual(posted[1], { + type: 'gev:view-applied', + id: 2, + ok: false, + error: 'A view needs a camera lat and lon', + }); + assert.equal(posted[2].id, 3); + assert.equal(posted[2].ok, true); + assert.ok( + calls.some( + ([name, args]) => + name === 'set_layer_visibility' && args.layerId === 'flights', + ), + ); + assert.equal(posted.filter((message) => message.id === 1).length, 0); + remove(); +}); + +test('annotations in a link are drawn after it restores, embedded or not', async () => { + const { calls, run } = recorder(); + const an = encodeURIComponent( + JSON.stringify([{ type: 'pin', target: 'Austin' }]), + ); + installViews({ + shell: { initialRestorePromise: Promise.resolve() }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { search: '', hash: `#v=2&lat=1&lon=2&an=${an}` }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, [ + ['annotate_map', { annotations: [{ type: 'pin', target: 'Austin' }] }], + ]); +}); diff --git a/src/app/tools.js b/src/app/tools.js index 613add71c..32cc3cf71 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -4,6 +4,7 @@ import { initDrawTool } from '../annotations/drawTool.js'; import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; +import { installViews } from './embed.js'; import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; import { installRenderGovernor, @@ -176,5 +177,14 @@ export function createApplicationTools({ delete window.__gevVoiceCommands; }); debug.voiceCommands = voiceCommands; + defer( + installViews({ + shell: styleManager, + viewer, + dataManager, + run: (name, args) => voiceCommands.runner(name, args, { signal }), + signal, + }), + ); return { sceneDirector, annotations, voiceCommands }; } diff --git a/src/firstRunExperience.js b/src/firstRunExperience.js index 6dffb7c5e..33af95d5f 100644 --- a/src/firstRunExperience.js +++ b/src/firstRunExperience.js @@ -211,6 +211,8 @@ export function shouldShowFirstRun({ if (hasShareState) return false; const params = new URLSearchParams(location?.search || ''); if (params.get('welcome') === '0') return false; + // An embedded globe never shows the welcome launcher. + if (params.get('embed') === '1') return false; // The demo/support escape hatch outranks both suppressions on purpose. if (params.get('welcome') === '1') return true; if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 6900420a6..169e9d515 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -51,7 +51,10 @@ test('build helper does not discover environment values or construct local provi config.define['import.meta.env.GOOGLE_MAPS_API_KEY'], undefined, ); - assert.equal(config.plugins.length, 2); + assert.deepEqual( + config.plugins.slice(2).map((plugin) => plugin.name), + ['embed-framing'], + ); } finally { if (before === undefined) delete process.env.GOOGLE_MAPS_API_KEY; else process.env.GOOGLE_MAPS_API_KEY = before; @@ -63,13 +66,14 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }); assert.deepEqual( - config.plugins.slice(2, -2).map((plugin) => plugin.name), + config.plugins.slice(2, -3).map((plugin) => plugin.name), providers.localProviderPlugins().map((plugin) => plugin.name), ); - assert.equal(config.plugins.at(-3).name, 'gev-key-setup'); + assert.equal(config.plugins.at(-4).name, 'gev-key-setup'); // The local MCP route follows every provider and precedes the API fallback. - assert.equal(config.plugins.at(-2).name, 'local-mcp'); - assert.equal(config.plugins.at(-1).name, 'api-not-found'); + assert.equal(config.plugins.at(-3).name, 'local-mcp'); + assert.equal(config.plugins.at(-2).name, 'api-not-found'); + assert.equal(config.plugins.at(-1).name, 'embed-framing'); }); test('build export resolves in Node and has no browser fallback', async () => { @@ -80,3 +84,54 @@ test('build export resolves in Node and has no browser fallback', async () => { ); assert.deepEqual(pkg.exports['./build/vite'], { node: './build/vite.js' }); }); + +test('only embed-mode documents may be framed, and only by the allowed ancestors', async () => { + const { embedFramingPlugin, isEmbedDocumentRequest } = + await import('../../build/embed-framing.js'); + assert.equal(isEmbedDocumentRequest('/?embed=1'), true); + assert.equal(isEmbedDocumentRequest('/index.html?embed=1#v=2'), true); + assert.equal(isEmbedDocumentRequest('/?embed=0'), false); + assert.equal(isEmbedDocumentRequest('/api/x?embed=1'), false); + assert.equal(isEmbedDocumentRequest('/src/main.js?embed=1'), false); + let middleware; + embedFramingPlugin({ ancestors: 'https://a.example' }).configureServer({ + middlewares: { use: (handler) => (middleware = handler) }, + }); + const response = () => { + const headers = new Map(); + return { + headers, + setHeader(name, value) { + headers.set(name.toLowerCase(), value); + return this; + }, + }; + }; + const embedded = response(); + middleware({ url: '/?embed=1' }, embedded, () => {}); + // The server's protections, written later at send time. + embedded.setHeader('X-Frame-Options', 'DENY'); + embedded.setHeader('Content-Security-Policy', "frame-ancestors 'none'"); + embedded.setHeader('Content-Type', 'text/html'); + assert.deepEqual(Object.fromEntries(embedded.headers), { + 'content-security-policy': 'frame-ancestors https://a.example', + 'content-type': 'text/html', + }); + let open; + embedFramingPlugin({ ancestors: '*' }).configureServer({ + middlewares: { use: (handler) => (open = handler) }, + }); + const anywhere = response(); + open({ url: '/?embed=1' }, anywhere, () => {}); + anywhere.setHeader('X-Frame-Options', 'DENY'); + anywhere.setHeader('Content-Security-Policy', "frame-ancestors 'none'"); + assert.deepEqual(Object.fromEntries(anywhere.headers), {}); + const normal = response(); + middleware({ url: '/' }, normal, () => {}); + normal.setHeader('X-Frame-Options', 'DENY'); + normal.setHeader('Content-Security-Policy', "frame-ancestors 'none'"); + assert.deepEqual(Object.fromEntries(normal.headers), { + 'x-frame-options': 'DENY', + 'content-security-policy': "frame-ancestors 'none'", + }); +}); diff --git a/src/ui/styles/embed.css b/src/ui/styles/embed.css new file mode 100644 index 000000000..3567f6a50 --- /dev/null +++ b/src/ui/styles/embed.css @@ -0,0 +1,14 @@ +/* Embed mode (?embed=1): the globe alone, on top of clean view. Provider + attribution stays visible. */ +body.ui-embed #clean-view-exit, +body.ui-embed #intel-hud, +body.ui-embed #key-setup-chip, +body.ui-embed #key-setup, +body.ui-embed #first-run-launcher, +body.ui-embed #command-dock, +body.ui-embed #left-panel-stack, +body.ui-embed #right-context-rail, +body.ui-embed #world-overlay-actions, +body.ui-embed #view-switcher { + display: none !important; +} diff --git a/style.css b/style.css index 8c62283a8..bc4831a7f 100644 --- a/style.css +++ b/style.css @@ -12,6 +12,7 @@ @import './src/ui/styles/bhote-koshi.css'; @import './src/ui/styles/recent-imagery.css'; @import './src/ui/styles/recording.css'; +@import './src/ui/styles/embed.css'; @import './src/ui/styles/responsive.css'; @import './src/ui/styles/command-dock.css'; @import './src/ui/styles/command-dock-compact.css'; From 216a75ac719a0046f4f9aa9d2853fec7813cfd3f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 22:26:48 -0700 Subject: [PATCH 065/111] 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. --- docs/CURRENT-STATE.md | 10 ++ docs/TOOLS.md | 18 +++- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 3 +- server/mcp/server.js | 8 +- src/tooling/localServices.test.mjs | 2 +- src/tools/catalog.js | 7 ++ src/tools/functions.test.mjs | 4 +- src/tools/globePanel.js | 158 +++++++++++++++++++++++++++++ src/tools/globePanel.test.mjs | 61 +++++++++++ src/tools/index.js | 4 +- src/tools/mcp/mcp.test.mjs | 76 ++++++++++++++ src/tools/mcp/protocol.js | 29 +++++- src/tools/queries/share.js | 47 ++++++--- src/tools/queries/share.test.mjs | 20 ++-- src/tools/surfaces.js | 2 +- src/tools/surfaces.test.mjs | 6 +- 17 files changed, 417 insertions(+), 41 deletions(-) create mode 100644 src/tools/globePanel.js create mode 100644 src/tools/globePanel.test.mjs 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 ` + + + +God's Eye View + + + +
Waiting for a view of the globe…
+ + + + +`; +} + +/** + * 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 }, + }, + }); +} diff --git a/src/tools/globePanel.test.mjs b/src/tools/globePanel.test.mjs new file mode 100644 index 000000000..cc7db1611 --- /dev/null +++ b/src/tools/globePanel.test.mjs @@ -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', + ); +}); diff --git a/src/tools/index.js b/src/tools/index.js index 6f0fd8dfd..106457e52 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -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, ]); diff --git a/src/tools/mcp/mcp.test.mjs b/src/tools/mcp/mcp.test.mjs index e82f4ee16..ff94659f0 100644 --- a/src/tools/mcp/mcp.test.mjs +++ b/src/tools/mcp/mcp.test.mjs @@ -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: '

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', From 47ef168815fc98d8ebaf67845297853c2b9ae9c7 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 22:47:32 -0700 Subject: [PATCH 066/111] refactor: name the view tool show_in_gods_eye_view The tool and its panel now say "Show in God's Eye View" rather than "show on the globe", in tool names, descriptions, server instructions and docs. --- docs/CURRENT-STATE.md | 8 ++++---- docs/TOOLS.md | 6 +++--- server/mcp/server.js | 6 +++--- src/tooling/localServices.test.mjs | 2 +- src/tools/functions.test.mjs | 4 ++-- src/tools/globePanel.js | 10 +++++----- src/tools/globePanel.test.mjs | 8 ++++---- src/tools/index.js | 4 ++-- src/tools/queries/share.js | 18 +++++++++--------- src/tools/queries/share.test.mjs | 18 +++++++++--------- src/tools/surfaces.js | 2 +- src/tools/surfaces.test.mjs | 6 +++++- 12 files changed, 48 insertions(+), 44 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index da089a934..1aefb801c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,11 +1,11 @@ # God's Eye View Current State -## The globe in conversations — October 2, 2026 +## God's Eye View in conversations — October 2, 2026 -Tool answers that can be seen on the globe include a view: camera, layers, +Tool answers that can be shown in God's Eye View 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. +share-link format (`gods-eye-view/view`). `show_in_gods_eye_view` shows a view as live +God's Eye View 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). diff --git a/docs/TOOLS.md b/docs/TOOLS.md index b11d518b3..4aba0a727 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -104,9 +104,9 @@ 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 +### The God's Eye View panel -`show_on_globe` names an MCP Apps view (`io.modelcontextprotocol/ui`): +`show_in_gods_eye_view` 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 @@ -250,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 | -| `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 | +| `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live 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/server/mcp/server.js b/server/mcp/server.js index a9a8b5ea1..aa11e8ef1 100644 --- a/server/mcp/server.js +++ b/server/mcp/server.js @@ -14,9 +14,9 @@ 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. ' + - '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 ' + + "Answers that can be shown in God's Eye View include data.view; to show one, call " + + 'show_in_gods_eye_view with that view, adding layers, style, a camera or marks as ' + + "needed. It shows live God's Eye View where the client displays apps and " + 'returns a link everywhere.'; /** Construct the local MCP server for Core's tools. */ diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index c26de99a0..61f25271a 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('show_on_globe')); + assert.ok(!names.includes('show_in_gods_eye_view')); 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/functions.test.mjs b/src/tools/functions.test.mjs index 51776ea75..439d7aeef 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: ['show_on_globe'], + exclude: ['show_in_gods_eye_view'], }); assert.equal(records.length, coreTools.length - 1); - assert.ok(!records.some((tool) => tool.name === 'show_on_globe')); + assert.ok(!records.some((tool) => tool.name === 'show_in_gods_eye_view')); 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 index 96b20620a..f87179222 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -1,7 +1,7 @@ /** - * The globe panel: an MCP Apps view (`io.modelcontextprotocol/ui`) that shows + * The God's Eye View 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 + * the app in embed mode and sends it each view the show_in_gods_eye_view tool * returns; see docs/TOOLS.md. */ @@ -33,7 +33,7 @@ function panelHtml(appOrigin) { -
Waiting for a view of the globe…
+
Waiting for a view…
diff --git a/src/tools/globePanel.test.mjs b/src/tools/globePanel.test.mjs index 7500a580a..b231dc9bd 100644 --- a/src/tools/globePanel.test.mjs +++ b/src/tools/globePanel.test.mjs @@ -21,6 +21,9 @@ test('the globe panel is an MCP Apps resource that may frame only the app', () = assert.match(resource.text, /'ui\/initialize'/); assert.match(resource.text, /'ui\/notifications\/tool-result'/); assert.match(resource.text, /protocolVersion: "2026-01-26"/); + // The MCP Apps SDK's initialize parameters; hosts reject anything else. + assert.match(resource.text, /appInfo: \{ name: 'gods-eye-view'/); + assert.doesNotMatch(resource.text, /clientInfo/); }); test('show_in_gods_eye_view names the panel and shows a view another answer returned', async () => { From cb04fd5b19d7be3c1f9e7c55d3079e11d10bc2f9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 09:40:12 -0700 Subject: [PATCH 070/111] feat: say in the panel what a host's security policy blocked When a host's content security policy refuses part of the panel, such as its frame, the panel now names the blocked directive and address instead of waiting until its load timeout. --- src/tools/globePanel.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index 982914e3d..dbe886902 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -40,6 +40,14 @@ function panelHtml(appOrigin) { const APP_ORIGIN = ${JSON.stringify(appOrigin)}; const LOAD_TIMEOUT_MS = 20000; const status = document.getElementById('status'); + // When the host's security policy blocks part of the panel, say what was + // blocked; otherwise a refused frame only shows as a load that never ends. + let blocked = null; + document.addEventListener('securitypolicyviolation', (event) => { + blocked = event.effectiveDirective + ' blocked ' + (event.blockedURI || 'a resource'); + if (status.isConnected) + status.textContent = "God's Eye View could not load here: " + blocked + '.'; + }); const open = document.getElementById('open'); let nextId = 1; const pending = new Map(); @@ -88,7 +96,9 @@ function panelHtml(appOrigin) { setTimeout(() => { if (!frameReady) status.textContent = - "God's Eye View did not load here. Use Open in God's Eye View above."; + "God's Eye View did not load here" + + (blocked ? ' (' + blocked + ')' : '') + + ". Use Open in God's Eye View above."; }, LOAD_TIMEOUT_MS); return; } From 63341b947e84015dabb161e1a65e01438bb4538b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 09:58:26 -0700 Subject: [PATCH 071/111] Run God's Eye View inside the MCP Apps panel instead of framing it Hosts such as Claude do not let a panel frame other sites, so the panel now loads the app's page, styles and scripts into itself in inline embed mode, and declares the app and map providers as resource and connect domains. The dev server answers panel origins with CORS. --- build/vite.js | 10 +++ src/app/embed.js | 25 ++++-- src/app/embed.test.mjs | 35 ++++++++ src/firstRunExperience.js | 3 +- src/tools/globePanel.js | 148 +++++++++++++++++++++++----------- src/tools/globePanel.test.mjs | 19 ++++- 6 files changed, 185 insertions(+), 55 deletions(-) diff --git a/build/vite.js b/build/vite.js index c683dbe03..977e25eb2 100644 --- a/build/vite.js +++ b/build/vite.js @@ -2,6 +2,12 @@ import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; import { embedFramingPlugin } from './embed-framing.js'; +/** Origins allowed to read the dev and preview servers' responses. */ +export const PANEL_CORS_ORIGINS = Object.freeze([ + /^https?:\/\/(?:(?:[^:]+\.)?localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/, + /^https:\/\/[a-z0-9]+\.claudemcpcontent\.com$/, +]); + /** Build browser assets with explicit inputs; never load environment or providers. */ export function createBrowserViteConfig({ plugins = [], @@ -44,6 +50,10 @@ export function createBrowserViteConfig({ fs: { deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'], }, + // Cross-origin reads: local pages, as Vite allows by default, plus the + // MCP Apps panels that load the app into a conversation (Claude's + // sandbox origins). Development and preview only. + cors: { origin: PANEL_CORS_ORIGINS }, // These headers protect the document containing Provider Settings. // Embed-mode documents are framable instead; see embed-framing.js. headers: { diff --git a/src/app/embed.js b/src/app/embed.js index 7892a9500..71566f54c 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -28,9 +28,21 @@ const FOLLOW_ATTEMPTS = 20; const FOLLOW_RETRY_MS = 1000; const FLIGHT_SECONDS = 2; -/** Whether this page was opened in embed mode. */ +/** + * Whether this page shows the app embedded: `?embed=1` when another page + * frames it, or inline when a panel page loads the app into itself and sets + * `globalThis.GEV_EMBED_INLINE` first. + */ export function isEmbedded(location = globalThis.location) { - return new URLSearchParams(location?.search || '').get('embed') === '1'; + return ( + isEmbeddedInline() || + new URLSearchParams(location?.search || '').get('embed') === '1' + ); +} + +/** Whether a panel page loaded the app into itself. */ +export function isEmbeddedInline() { + return globalThis.GEV_EMBED_INLINE === true; } const delay = (ms, signal) => @@ -157,14 +169,17 @@ export function installViews({ windowRef.document.body.classList.add('ui-embed'); shell.setCleanView?.(true); - const parent = windowRef.parent; + // A framing page talks to the app across frames; an inline panel shares + // the page with it and talks through the page's own window. + const peer = isEmbeddedInline() ? windowRef : windowRef.parent; const post = (message) => { - if (parent && parent !== windowRef) parent.postMessage(message, '*'); + if (peer && (peer !== windowRef || isEmbeddedInline())) + peer.postMessage(message, '*'); }; // Views apply one at a time, in the order they arrive. let queue = ready; const onMessage = (event) => { - if (event.source !== parent || event.data?.type !== EMBED_VIEW_MESSAGE) + if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE) return; const { id = null } = event.data; let view; diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index ebe9bfabc..b2e9b3c3f 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -152,6 +152,41 @@ test('an embedded page takes views only from its parent and answers it', async ( remove(); }); +test('an app loaded inline into a panel page talks through its own window', async () => { + const posted = []; + const windowRef = new EventTarget(); + windowRef.parent = windowRef; + windowRef.postMessage = (message) => posted.push(message); + windowRef.document = { body: { classList: new Set() } }; + windowRef.document.body.classList.add = Set.prototype.add; + globalThis.GEV_EMBED_INLINE = true; + try { + assert.equal(isEmbedded({ search: '' }), true); + const remove = installViews({ + shell: { initialRestorePromise: Promise.resolve() }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run: recorder().run, + location: { search: '', hash: '' }, + windowRef, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(posted, [{ type: 'gev:ready' }]); + const event = new Event('message'); + Object.assign(event, { + source: windowRef, + data: { type: 'gev:view', id: 7, view: { camera: { lat: 1, lon: 2 } } }, + }); + windowRef.dispatchEvent(event); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(posted[1].id, 7); + assert.equal(posted[1].ok, true); + remove(); + } finally { + delete globalThis.GEV_EMBED_INLINE; + } +}); + test('annotations in a link are drawn after it restores, embedded or not', async () => { const { calls, run } = recorder(); const an = encodeURIComponent( diff --git a/src/firstRunExperience.js b/src/firstRunExperience.js index 33af95d5f..60bd5d2a8 100644 --- a/src/firstRunExperience.js +++ b/src/firstRunExperience.js @@ -212,7 +212,8 @@ export function shouldShowFirstRun({ const params = new URLSearchParams(location?.search || ''); if (params.get('welcome') === '0') return false; // An embedded globe never shows the welcome launcher. - if (params.get('embed') === '1') return false; + if (params.get('embed') === '1' || globalThis.GEV_EMBED_INLINE === true) + return false; // The demo/support escape hatch outranks both suppressions on purpose. if (params.get('welcome') === '1') return true; if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index dbe886902..58d8007dd 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -1,20 +1,47 @@ /** * The God's Eye View 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_in_gods_eye_view tool - * returns; see docs/TOOLS.md. + * God's Eye View inside a conversation. The panel is a small page that runs + * the app inside itself in embed mode and sends it each view the + * show_in_gods_eye_view tool returns; see docs/TOOLS.md. */ export const GLOBE_PANEL_URI = 'ui://gods-eye-view/globe'; + +/** + * Sites the app's browser code loads from directly: map imagery, 3D tiles, + * terrain and fonts. Everything else goes through the app's own server. + */ +const PROVIDER_ORIGINS = Object.freeze([ + 'https://tile.googleapis.com', + 'https://maps.googleapis.com', + 'https://api.cesium.com', + 'https://assets.ion.cesium.com', + 'https://assets.cesium.com', + 'https://dev.virtualearth.net', + 'https://ecn.t0.tiles.virtualearth.net', + 'https://ecn.t1.tiles.virtualearth.net', + 'https://ecn.t2.tiles.virtualearth.net', + 'https://ecn.t3.tiles.virtualearth.net', + 'https://services.arcgisonline.com', + 'https://server.arcgisonline.com', + 'https://tile.openstreetmap.org', + 'https://tiles.openfreemap.org', + 'https://gibs.earthdata.nasa.gov', + 'https://fonts.googleapis.com', + 'https://fonts.gstatic.com', +]); + +/** The WebSocket origin matching an http(s) origin, for live-reload. */ +const socketOrigin = (origin) => origin.replace(/^http/, 'ws'); 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. + * for each tool result that carries a view: loads the app in embed mode the + * first time, and posts the view to the app once it is ready, so later views + * change the globe without reloading. */ function panelHtml(appOrigin) { return ` @@ -25,9 +52,9 @@ function panelHtml(appOrigin) { @@ -61,21 +88,43 @@ function panelHtml(appOrigin) { }); const notify = (method, params) => send({ method, params }); - let frame = null; - let frameReady = false; + let started = false; + let ready = 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, - ); + window.postMessage({ type: 'gev:view', id: nextId++, view }, '*'); + + /** + * Load God's Eye View into this page. Hosts may not frame other sites, so + * the panel runs the app itself: relative addresses resolve to the app's + * server through , the app's styles and markup are copied in, and + * its scripts run here with the app in inline embed mode. + */ + async function startApp() { + window.GEV_EMBED_INLINE = true; + const base = document.createElement('base'); + base.href = APP_ORIGIN + '/'; + document.head.prepend(base); + const response = await fetch(APP_ORIGIN + '/?embed=1'); + if (!response.ok) throw new Error('the app answered ' + response.status); + const page = new DOMParser().parseFromString(await response.text(), 'text/html'); + for (const node of page.head.querySelectorAll('link[rel="stylesheet"], style')) + document.head.appendChild(document.importNode(node, true)); + for (const node of [...page.body.childNodes]) { + if (node.nodeName !== 'SCRIPT') + document.body.insertBefore(document.importNode(node, true), status); + } + for (const original of page.querySelectorAll('script')) { + const script = document.createElement('script'); + if (original.type) script.type = original.type; + if (original.getAttribute('src')) + script.src = new URL(original.getAttribute('src'), base.href).href; + else script.textContent = original.textContent; + document.body.appendChild(script); + } + } function show(result) { const data = result && result.structuredContent; @@ -84,33 +133,30 @@ function panelHtml(appOrigin) { 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 God's Eye View…"; - // Say so, rather than wait forever, when the app cannot load here. - setTimeout(() => { - if (!frameReady) - status.textContent = - "God's Eye View did not load here" + - (blocked ? ' (' + blocked + ')' : '') + - ". Use Open in God's Eye View above."; - }, LOAD_TIMEOUT_MS); - return; - } - if (frameReady) postView(view); - else queued = view; + if (ready) return postView(view); + queued = view; + if (started) return; + started = true; + status.textContent = "Loading God's Eye View…"; + startApp().catch((error) => { + status.textContent = + "God's Eye View could not load here: " + (error && error.message) + '.'; + }); + // Say so, rather than wait forever, when the app cannot load here. + setTimeout(() => { + if (!ready) + status.textContent = + "God's Eye View did not load here" + + (blocked ? ' (' + blocked + ')' : '') + + ". Use Open in God's Eye View above."; + }, LOAD_TIMEOUT_MS); } window.addEventListener('message', (event) => { const message = event.data; - if (frame && event.source === frame.contentWindow) { + if (event.source === window) { if (message && message.type === 'gev:ready') { - frameReady = true; + ready = true; status.remove(); if (queued) postView(queued); queued = null; @@ -161,8 +207,9 @@ function panelHtml(appOrigin) { } /** - * The panel as an MCP resource for an app served at `appUrl`. Only that - * origin may be framed inside the panel. + * The panel as an MCP resource for an app served at `appUrl`. Its security + * policy lets the panel load the app's code from that origin and reach the + * map and imagery providers the app uses directly. */ export function createGlobePanelResource({ appUrl }) { const appOrigin = new URL(appUrl).origin; @@ -174,7 +221,18 @@ export function createGlobePanelResource({ appUrl }) { mimeType: MCP_APP_MIME_TYPE, text: panelHtml(appOrigin), _meta: { - ui: { csp: { frameDomains: [appOrigin] }, prefersBorder: true }, + ui: { + csp: { + resourceDomains: [appOrigin, ...PROVIDER_ORIGINS], + connectDomains: [ + appOrigin, + socketOrigin(appOrigin), + ...PROVIDER_ORIGINS, + ], + baseUriDomains: [appOrigin], + }, + prefersBorder: true, + }, }, }); } diff --git a/src/tools/globePanel.test.mjs b/src/tools/globePanel.test.mjs index b231dc9bd..49de8afee 100644 --- a/src/tools/globePanel.test.mjs +++ b/src/tools/globePanel.test.mjs @@ -7,16 +7,27 @@ import { } from './globePanel.js'; import { composeCatalog, coreTools } from './index.js'; -test('the globe panel is an MCP Apps resource that may frame only the app', () => { +test('the globe panel is an MCP Apps resource that runs the app inside itself', () => { 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'], - }); + const { csp } = resource._meta.ui; + // Hosts may not let a panel frame other sites, so it declares no frames + // and instead loads the app's code and data from the app's origin. + assert.equal(csp.frameDomains, undefined); + assert.equal(csp.resourceDomains[0], 'http://localhost:5173'); + assert.deepEqual(csp.connectDomains.slice(0, 2), [ + 'http://localhost:5173', + 'ws://localhost:5173', + ]); + assert.ok(csp.resourceDomains.includes('https://tile.googleapis.com')); + assert.deepEqual(csp.baseUriDomains, ['http://localhost:5173']); + assert.match(resource.text, /window\.GEV_EMBED_INLINE = true/); + assert.match(resource.text, /fetch\(APP_ORIGIN \+ '\/\?embed=1'\)/); + assert.doesNotMatch(resource.text, / Date: Fri, 2 Oct 2026 11:00:40 -0700 Subject: [PATCH 072/111] Let the app run under a panel page's document base A panel page loads the app with pointing at the app's server, so addresses the app builds resolve against the document base instead of the page location, and hash updates stay on the page's own address. The dev server answers panel origins with CORS on the API routes too. --- build/vite.js | 36 +++++++++++++++++++++++++++- src/data/aisLiveVessels.js | 3 +++ src/keySetup.js | 6 ++++- src/reasonableDefaults.test.mjs | 2 +- src/sharelink.celestial.test.mjs | 4 ++-- src/sharelink.js | 8 ++++++- src/standalone/layerSources.js | 3 +++ src/standalone/toolCatalog.js | 2 +- src/standalone/tools.js | 2 +- src/tooling/viteBuild.test.mjs | 41 +++++++++++++++++++++++++++++--- 10 files changed, 96 insertions(+), 11 deletions(-) diff --git a/build/vite.js b/build/vite.js index 977e25eb2..12915f72f 100644 --- a/build/vite.js +++ b/build/vite.js @@ -8,6 +8,39 @@ export const PANEL_CORS_ORIGINS = Object.freeze([ /^https:\/\/[a-z0-9]+\.claudemcpcontent\.com$/, ]); +/** + * Vite plugin answering cross-origin reads from PANEL_CORS_ORIGINS on every + * path. It runs ahead of the API middlewares, which Vite's own `cors` option + * would not cover. + */ +function panelCorsPlugin() { + const install = (server) => { + server.middlewares.use((req, res, next) => { + const origin = req.headers.origin; + if ( + !origin || + !PANEL_CORS_ORIGINS.some((allowed) => allowed.test(origin)) + ) + return next(); + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + if (req.method !== 'OPTIONS') return next(); + res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, POST, OPTIONS'); + res.setHeader( + 'Access-Control-Allow-Headers', + req.headers['access-control-request-headers'] || '', + ); + res.statusCode = 204; + res.end(); + }); + }; + return { + name: 'panel-cors', + configureServer: install, + configurePreviewServer: install, + }; +} + /** Build browser assets with explicit inputs; never load environment or providers. */ export function createBrowserViteConfig({ plugins = [], @@ -20,6 +53,7 @@ export function createBrowserViteConfig({ } = {}) { return { plugins: [ + panelCorsPlugin(), cesium(), applicationHtmlPlugin(), ...plugins, @@ -52,7 +86,7 @@ export function createBrowserViteConfig({ }, // Cross-origin reads: local pages, as Vite allows by default, plus the // MCP Apps panels that load the app into a conversation (Claude's - // sandbox origins). Development and preview only. + // sandbox origins); see panelCorsPlugin. Development and preview only. cors: { origin: PANEL_CORS_ORIGINS }, // These headers protect the document containing Provider Settings. // Embed-mode documents are framable instead; see embed-framing.js. diff --git a/src/data/aisLiveVessels.js b/src/data/aisLiveVessels.js index 348ca26c3..320318742 100644 --- a/src/data/aisLiveVessels.js +++ b/src/data/aisLiveVessels.js @@ -5,6 +5,9 @@ import { createAisStreamSource } from '../sources/live/standalone.js'; const aisLiveVesselsLayer = createApplicationVessels({ source: createAisStreamSource({ apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live', + // The app's server, also when a panel page loads the app under . + origin: () => + new URL(globalThis.document?.baseURI ?? 'http://localhost').origin, }), options: { maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS, diff --git a/src/keySetup.js b/src/keySetup.js index ee1d3b2dd..d66d23867 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -318,7 +318,11 @@ export async function initKeySetup({ globalThis.location?.hash?.slice(1) || '', ); if (next !== null) - globalThis.history?.replaceState?.(null, '', `#${next}`); + globalThis.history?.replaceState?.( + null, + '', + new URL(`#${next}`, globalThis.location.href).href, + ); } catch { // Continuity is a nicety, never a blocker. } diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs index 4772630aa..dfd951377 100644 --- a/src/reasonableDefaults.test.mjs +++ b/src/reasonableDefaults.test.mjs @@ -61,7 +61,7 @@ function uiBlock(start, end) { /** A ShareLinkManager over a synthetic hash — enough surface for parseInitialHash. */ function managerForHash(hash) { globalThis.window = { location: { hash, href: `http://localhost/${hash}` } }; - globalThis.history = { replaceState(_s, _t, next) { window.location.hash = next; } }; + globalThis.history = { replaceState(_s, _t, next) { window.location.hash = new URL(next, window.location.href).hash; } }; const viewer = { camera: { changed: { addEventListener() {} }, diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index cc2672785..567bb09dd 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -34,8 +34,8 @@ function assertClaimsBefore(block, mutation, label) { function makeManager(hash = '') { globalThis.window = { location: { hash, href: `http://localhost/${hash}` } }; globalThis.history = { - replaceState(_state, _title, nextHash) { - window.location.hash = nextHash; + replaceState(_state, _title, next) { + window.location.hash = new URL(next, window.location.href).hash; }, }; const viewer = { diff --git a/src/sharelink.js b/src/sharelink.js index 9c7c3728e..36862dcbf 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -529,7 +529,13 @@ export class ShareLinkManager { if (this._destroyed || this._initialRestorePending) return; const params = this._buildHashParams(); if (!params) return; - history.replaceState(null, '', `#${params.toString()}`); + // The hash belongs to this page's address, not the document base the + // app may load under. + history.replaceState( + null, + '', + new URL(`#${params.toString()}`, window.location.href).href, + ); } /** Build a deterministic snapshot without mutating history. */ diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index 1856dfb69..e51e64530 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -29,6 +29,9 @@ export function createStandaloneLayerSources() { military: createAdsbLolSource(), vessels: createAisStreamSource({ apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live', + // The app's server, also when a panel page loads the app under . + origin: () => + new URL(globalThis.document?.baseURI ?? 'http://localhost').origin, }), cctv: createCctvSource(), radio: createRadioSource(), diff --git a/src/standalone/toolCatalog.js b/src/standalone/toolCatalog.js index 53a1f6a7b..4d697d9ed 100644 --- a/src/standalone/toolCatalog.js +++ b/src/standalone/toolCatalog.js @@ -16,7 +16,7 @@ export function loadToolCatalog() { tools: coreTools, services: createToolServices({ fetchImpl: (...args) => globalThis.fetch(...args), - appUrl: globalThis.location.origin, + appUrl: new URL(globalThis.document.baseURI).origin, }), }), 'voice', diff --git a/src/standalone/tools.js b/src/standalone/tools.js index 047434924..d2e6043af 100644 --- a/src/standalone/tools.js +++ b/src/standalone/tools.js @@ -8,7 +8,7 @@ export function createStandaloneTools(options) { sceneDataPacks: { sources: { assets: createAssetDirectorySource({ - baseUrl: new URL('/scene-assets/', window.location.href).href, + baseUrl: new URL('/scene-assets/', document.baseURI).href, }), }, }, diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 169e9d515..f2c0c586a 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -12,7 +12,8 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop googleApiKey: 'browser-fixture', cesiumToken: 'ion-fixture', }); - assert.equal(config.plugins[2], plugin); + assert.equal(config.plugins[0].name, 'panel-cors'); + assert.equal(config.plugins[3], plugin); assert.equal(config.server.host, 'localhost'); assert.equal(config.server.port, 4173); assert.deepEqual(config.server.allowedHosts, [ @@ -42,6 +43,40 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop ); }); +test('panel origins may read every dev server path, other sites may not', () => { + let middleware; + createBrowserViteConfig().plugins[0].configureServer({ + middlewares: { use: (fn) => (middleware = fn) }, + }); + const request = (origin, method = 'GET') => { + const headers = {}; + let ended = false; + let passed = false; + middleware( + { method, headers: origin ? { origin } : {} }, + { + setHeader: (name, value) => (headers[name] = value), + end: () => (ended = true), + }, + () => (passed = true), + ); + return { headers, ended, passed }; + }; + const panel = request('https://abc123.claudemcpcontent.com'); + assert.equal( + panel.headers['Access-Control-Allow-Origin'], + 'https://abc123.claudemcpcontent.com', + ); + assert.equal(panel.passed, true); + assert.equal( + request('https://abc123.claudemcpcontent.com', 'OPTIONS').ended, + true, + ); + const other = request('https://example.com'); + assert.deepEqual(other.headers, {}); + assert.equal(other.passed, true); +}); + test('build helper does not discover environment values or construct local providers', () => { const before = process.env.GOOGLE_MAPS_API_KEY; process.env.GOOGLE_MAPS_API_KEY = 'environment-fixture'; @@ -52,7 +87,7 @@ test('build helper does not discover environment values or construct local provi undefined, ); assert.deepEqual( - config.plugins.slice(2).map((plugin) => plugin.name), + config.plugins.slice(3).map((plugin) => plugin.name), ['embed-framing'], ); } finally { @@ -66,7 +101,7 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }); assert.deepEqual( - config.plugins.slice(2, -3).map((plugin) => plugin.name), + config.plugins.slice(3, -3).map((plugin) => plugin.name), providers.localProviderPlugins().map((plugin) => plugin.name), ); assert.equal(config.plugins.at(-4).name, 'gev-key-setup'); From 06460020d08a7c1a00ccd8748760cff8f1071214 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 11:47:27 -0700 Subject: [PATCH 073/111] Send the panel's app requests to the app's server without Claude refuses in panels, so relative addresses reached the host's site: styles, Cesium assets and API calls all failed. The panel now points fetch, XHR, beacons, images, media, links, workers and added style url()s at the app's server, and declares its policy under OpenAI's widget key as well. --- src/tools/globePanel.js | 141 +++++++++++++++++++++++++++++++--- src/tools/globePanel.test.mjs | 8 +- 2 files changed, 137 insertions(+), 12 deletions(-) diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index 58d8007dd..c872e4698 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -96,31 +96,142 @@ function panelHtml(appOrigin) { const postView = (view) => window.postMessage({ type: 'gev:view', id: nextId++, view }, '*'); + // The panel page lives on the host's site, which serves none of the app. + // Hosts may refuse , so the app's own addresses (relative ones, and + // absolute ones on this page's site) are sent to the app's server instead. + // The page's own site; location.origin is "null" in a srcdoc document. + const PAGE_ORIGIN = new URL(document.baseURI).origin; + const toApp = (value) => { + let url; + try { + url = new URL(String(value), document.baseURI); + } catch { + return value; + } + if (!/^https?:$/.test(url.protocol) || url.origin !== PAGE_ORIGIN) + return value; + return APP_ORIGIN + url.pathname + url.search + url.hash; + }; + const isRemote = (value) => { + try { + return new URL(String(value), document.baseURI).origin === APP_ORIGIN; + } catch { + return false; + } + }; + + function redirectRequests() { + const nativeFetch = window.fetch; + window.fetch = (input, init) => + nativeFetch.call( + window, + input instanceof Request + ? new Request(toApp(input.url), input) + : typeof input === 'string' || input instanceof URL + ? toApp(input) + : input, + init, + ); + const nativeOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function (method, url, ...rest) { + return nativeOpen.call(this, method, toApp(url), ...rest); + }; + const nativeBeacon = navigator.sendBeacon?.bind(navigator); + if (nativeBeacon) + navigator.sendBeacon = (url, data) => nativeBeacon(toApp(url), data); + for (const [type, attribute] of [ + [HTMLImageElement, 'src'], + [HTMLMediaElement, 'src'], + [HTMLSourceElement, 'src'], + [HTMLLinkElement, 'href'], + ]) { + const property = Object.getOwnPropertyDescriptor(type.prototype, attribute); + Object.defineProperty(type.prototype, attribute, { + ...property, + set(value) { + property.set.call(this, toApp(value)); + }, + }); + } + // Workers must come from this page's site; one on the app's server + // starts through a small worker here that imports it. + const NativeWorker = window.Worker; + window.Worker = function Worker(url, options) { + const target = toApp(url); + if (!isRemote(target)) return new NativeWorker(url, options); + const source = + options && options.type === 'module' + ? 'import ' + JSON.stringify(String(target)) + ';' + : 'importScripts(' + JSON.stringify(String(target)) + ');'; + return new NativeWorker( + URL.createObjectURL(new Blob([source], { type: 'text/javascript' })), + options, + ); + }; + window.Worker.prototype = NativeWorker.prototype; + // Styles the app adds while it runs name images by root-relative url(). + const rewriteStyle = (style) => { + const text = style.textContent; + const next = text.replace(/url\\((['"]?)\\//g, 'url($1' + APP_ORIGIN + '/'); + if (next !== text) style.textContent = next; + }; + new MutationObserver((records) => { + for (const record of records) { + const target = record.target.nodeName === 'STYLE' ? [record.target] : []; + for (const node of [...target, ...record.addedNodes]) { + if (node.nodeName === 'STYLE') rewriteStyle(node); + // Markup the app adds as HTML text sets src without the setters. + if (node.nodeType !== 1) continue; + for (const element of [node, ...node.querySelectorAll('[src]')]) { + const value = element.getAttribute('src'); + if (value && toApp(value) !== value) + element.setAttribute('src', toApp(value)); + } + } + } + }).observe(document.documentElement, { + childList: true, + subtree: true, + characterData: true, + }); + } + + /** Point an imported element's own addresses at the app's server. */ + function absolutize(root) { + for (const element of [root, ...root.querySelectorAll('*')]) { + for (const attribute of ['src', 'href', 'poster']) { + const value = element.getAttribute(attribute); + if (value && !value.startsWith('#')) + element.setAttribute(attribute, new URL(value, APP_ORIGIN + '/').href); + } + } + return root; + } + /** * Load God's Eye View into this page. Hosts may not frame other sites, so - * the panel runs the app itself: relative addresses resolve to the app's - * server through , the app's styles and markup are copied in, and - * its scripts run here with the app in inline embed mode. + * the panel runs the app itself: its requests go to the app's server, its + * styles and markup are copied in, and its scripts run here with the app + * in inline embed mode. */ async function startApp() { window.GEV_EMBED_INLINE = true; - const base = document.createElement('base'); - base.href = APP_ORIGIN + '/'; - document.head.prepend(base); + redirectRequests(); const response = await fetch(APP_ORIGIN + '/?embed=1'); if (!response.ok) throw new Error('the app answered ' + response.status); const page = new DOMParser().parseFromString(await response.text(), 'text/html'); for (const node of page.head.querySelectorAll('link[rel="stylesheet"], style')) - document.head.appendChild(document.importNode(node, true)); + document.head.appendChild(absolutize(document.importNode(node, true))); for (const node of [...page.body.childNodes]) { - if (node.nodeName !== 'SCRIPT') - document.body.insertBefore(document.importNode(node, true), status); + if (node.nodeName === 'SCRIPT') continue; + const copy = document.importNode(node, true); + document.body.insertBefore(copy.nodeType === 1 ? absolutize(copy) : copy, status); } for (const original of page.querySelectorAll('script')) { const script = document.createElement('script'); if (original.type) script.type = original.type; if (original.getAttribute('src')) - script.src = new URL(original.getAttribute('src'), base.href).href; + script.src = new URL(original.getAttribute('src'), APP_ORIGIN + '/').href; else script.textContent = original.textContent; document.body.appendChild(script); } @@ -229,10 +340,18 @@ export function createGlobePanelResource({ appUrl }) { socketOrigin(appOrigin), ...PROVIDER_ORIGINS, ], - baseUriDomains: [appOrigin], }, prefersBorder: true, }, + // The same policy under the key OpenAI's clients read. + 'openai/widgetCSP': { + connect_domains: [ + appOrigin, + socketOrigin(appOrigin), + ...PROVIDER_ORIGINS, + ], + resource_domains: [appOrigin, ...PROVIDER_ORIGINS], + }, }, }); } diff --git a/src/tools/globePanel.test.mjs b/src/tools/globePanel.test.mjs index 49de8afee..350316d38 100644 --- a/src/tools/globePanel.test.mjs +++ b/src/tools/globePanel.test.mjs @@ -24,7 +24,13 @@ test('the globe panel is an MCP Apps resource that runs the app inside itself', 'ws://localhost:5173', ]); assert.ok(csp.resourceDomains.includes('https://tile.googleapis.com')); - assert.deepEqual(csp.baseUriDomains, ['http://localhost:5173']); + // Hosts may refuse ; the panel sends the app's requests itself. + assert.equal(csp.baseUriDomains, undefined); + assert.deepEqual(resource._meta['openai/widgetCSP'], { + connect_domains: csp.connectDomains, + resource_domains: csp.resourceDomains, + }); + assert.doesNotMatch(resource.text, /createElement\('base'\)/); assert.match(resource.text, /window\.GEV_EMBED_INLINE = true/); assert.match(resource.text, /fetch\(APP_ORIGIN \+ '\/\?embed=1'\)/); assert.doesNotMatch(resource.text, / Date: Fri, 2 Oct 2026 12:00:45 -0700 Subject: [PATCH 074/111] Load images the panel redirects to the app's server with CORS The app treats those images as its own site's and loads them without CORS, so once they come from the app's server WebGL refuses them and Cesium stops rendering. --- src/tools/globePanel.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index c872e4698..219fc27db 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -149,7 +149,12 @@ function panelHtml(appOrigin) { Object.defineProperty(type.prototype, attribute, { ...property, set(value) { - property.set.call(this, toApp(value)); + const target = toApp(value); + // The app treated the address as its own site; now that it is + // another site, images must load with CORS or WebGL refuses them. + if (target !== value && 'crossOrigin' in this && this.crossOrigin === null) + this.crossOrigin = 'anonymous'; + property.set.call(this, target); }, }); } @@ -184,8 +189,10 @@ function panelHtml(appOrigin) { if (node.nodeType !== 1) continue; for (const element of [node, ...node.querySelectorAll('[src]')]) { const value = element.getAttribute('src'); - if (value && toApp(value) !== value) - element.setAttribute('src', toApp(value)); + if (!value || toApp(value) === value) continue; + if ('crossOrigin' in element && element.crossOrigin === null) + element.crossOrigin = 'anonymous'; + element.setAttribute('src', toApp(value)); } } } From 282efac8623ce0a4b6ac1072a05798610e9b8707 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 12:12:10 -0700 Subject: [PATCH 075/111] Start the panel's app at the first view instead of the default place --- src/tools/globePanel.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index 219fc27db..a3412baaa 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -221,9 +221,16 @@ function panelHtml(appOrigin) { * styles and markup are copied in, and its scripts run here with the app * in inline embed mode. */ - async function startApp() { + async function startApp(url) { window.GEV_EMBED_INLINE = true; redirectRequests(); + // Start at the first view, as its link would, instead of the default + // place; the app reads the view from this page's address. + try { + history.replaceState(null, '', location.href.split('#')[0] + new URL(url).hash); + } catch { + // The view still arrives once the app is ready. + } const response = await fetch(APP_ORIGIN + '/?embed=1'); if (!response.ok) throw new Error('the app answered ' + response.status); const page = new DOMParser().parseFromString(await response.text(), 'text/html'); @@ -256,7 +263,7 @@ function panelHtml(appOrigin) { if (started) return; started = true; status.textContent = "Loading God's Eye View…"; - startApp().catch((error) => { + startApp(url).catch((error) => { status.textContent = "God's Eye View could not load here: " + (error && error.message) + '.'; }); From 64c1b1951f4928cdfedd427c6ed5dcee1d92874c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 12:17:29 -0700 Subject: [PATCH 076/111] Answer panel origins only through the CORS plugin --- build/vite.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/build/vite.js b/build/vite.js index 12915f72f..696c740de 100644 --- a/build/vite.js +++ b/build/vite.js @@ -84,10 +84,6 @@ export function createBrowserViteConfig({ fs: { deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'], }, - // Cross-origin reads: local pages, as Vite allows by default, plus the - // MCP Apps panels that load the app into a conversation (Claude's - // sandbox origins); see panelCorsPlugin. Development and preview only. - cors: { origin: PANEL_CORS_ORIGINS }, // These headers protect the document containing Provider Settings. // Embed-mode documents are framable instead; see embed-framing.js. headers: { From b11d54d8b83702a72effa04cd3ef5d7a6a88ae6e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 12:32:58 -0700 Subject: [PATCH 077/111] Support panel hosts that serve the page from their own scheme Codex serves panels from codex-sandbox:// pages, where origins are opaque: the panel recognizes the app's own addresses by scheme and host, the vessels feed resolves against the document address, and the dev server answers Codex's sandbox origins with CORS. --- build/vite.js | 1 + src/data/aisLiveVessels.js | 6 +++--- src/standalone/layerSources.js | 6 +++--- src/tooling/viteBuild.test.mjs | 5 +++++ src/tools/globePanel.js | 8 ++++---- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/build/vite.js b/build/vite.js index 696c740de..53b7e92e2 100644 --- a/build/vite.js +++ b/build/vite.js @@ -6,6 +6,7 @@ import { embedFramingPlugin } from './embed-framing.js'; export const PANEL_CORS_ORIGINS = Object.freeze([ /^https?:\/\/(?:(?:[^:]+\.)?localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/, /^https:\/\/[a-z0-9]+\.claudemcpcontent\.com$/, + /^(?:https|codex-sandbox):\/\/[a-z0-9-]+\.web-sandbox\.oaiusercontent\.com$/, ]); /** diff --git a/src/data/aisLiveVessels.js b/src/data/aisLiveVessels.js index 320318742..b3800db68 100644 --- a/src/data/aisLiveVessels.js +++ b/src/data/aisLiveVessels.js @@ -5,9 +5,9 @@ import { createAisStreamSource } from '../sources/live/standalone.js'; const aisLiveVesselsLayer = createApplicationVessels({ source: createAisStreamSource({ apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live', - // The app's server, also when a panel page loads the app under . - origin: () => - new URL(globalThis.document?.baseURI ?? 'http://localhost').origin, + // Resolved against the document's address, which a panel host may serve + // from its own scheme. + origin: () => globalThis.document?.baseURI ?? 'http://localhost', }), options: { maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS, diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index e51e64530..c84595483 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -29,9 +29,9 @@ export function createStandaloneLayerSources() { military: createAdsbLolSource(), vessels: createAisStreamSource({ apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live', - // The app's server, also when a panel page loads the app under . - origin: () => - new URL(globalThis.document?.baseURI ?? 'http://localhost').origin, + // Resolved against the document's address, which a panel host may + // serve from its own scheme. + origin: () => globalThis.document?.baseURI ?? 'http://localhost', }), cctv: createCctvSource(), radio: createRadioSource(), diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index f2c0c586a..6550c5235 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -72,6 +72,11 @@ test('panel origins may read every dev server path, other sites may not', () => request('https://abc123.claudemcpcontent.com', 'OPTIONS').ended, true, ); + assert.equal( + request('codex-sandbox://mcp-app-ab12.web-sandbox.oaiusercontent.com') + .headers['Access-Control-Allow-Origin'], + 'codex-sandbox://mcp-app-ab12.web-sandbox.oaiusercontent.com', + ); const other = request('https://example.com'); assert.deepEqual(other.headers, {}); assert.equal(other.passed, true); diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index a3412baaa..7f0a29138 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -99,8 +99,9 @@ function panelHtml(appOrigin) { // The panel page lives on the host's site, which serves none of the app. // Hosts may refuse , so the app's own addresses (relative ones, and // absolute ones on this page's site) are sent to the app's server instead. - // The page's own site; location.origin is "null" in a srcdoc document. - const PAGE_ORIGIN = new URL(document.baseURI).origin; + // The page's own site, by scheme and host: location.origin is "null" in a + // srcdoc document, and a host may serve the page from its own scheme. + const PAGE = new URL(document.baseURI); const toApp = (value) => { let url; try { @@ -108,8 +109,7 @@ function panelHtml(appOrigin) { } catch { return value; } - if (!/^https?:$/.test(url.protocol) || url.origin !== PAGE_ORIGIN) - return value; + if (url.protocol !== PAGE.protocol || url.host !== PAGE.host) return value; return APP_ORIGIN + url.pathname + url.search + url.hash; }; const isRemote = (value) => { From 996b95eb4591e4a406b69b7458f6f38563c9888c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 13:42:06 -0700 Subject: [PATCH 078/111] Load the God's Eye View panel through the MCP server Hosts serve panels from their own sites and may refuse other addresses; Codex refuses any address on the user's machine. The panel now never requests the app's server: it loads the app's panel build and data through panel_request, a tool only the panel may call, which requests the path from the app's server. One path in every host; only map providers load directly. npm run build:panel writes the panel build to dist/panel, served at /panel/: one app script, one stylesheet, Cesium's script, and Cesium's workers as one script Cesium runs from memory. Tools may now be marked app-only. The panel CORS rules are gone. --- build/panel.js | 96 +++++ build/vite.js | 43 +- docs/TOOLS.md | 44 +- package.json | 1 + scripts/build-panel.mjs | 52 +++ scripts/package-boundaries.json | 6 +- server/mcp/server.js | 3 +- src/app/globePanelRuntime.js | 546 ++++++++++++++++++++++++ src/tooling/panelBuild.test.mjs | 82 ++++ src/tooling/viteBuild.test.mjs | 57 +-- src/tools/catalog.js | 36 +- src/tools/globePanel.js | 338 ++------------- src/tools/globePanel.test.mjs | 41 +- src/tools/index.js | 2 + src/tools/mcp/mcp.test.mjs | 38 +- src/tools/mcp/protocol.js | 18 +- src/tools/queries/panelRequest.js | 184 ++++++++ src/tools/queries/panelRequest.test.mjs | 109 +++++ src/tools/services.js | 4 +- src/tools/surfaces.js | 2 + 20 files changed, 1265 insertions(+), 437 deletions(-) create mode 100644 build/panel.js create mode 100644 scripts/build-panel.mjs create mode 100644 src/app/globePanelRuntime.js create mode 100644 src/tooling/panelBuild.test.mjs create mode 100644 src/tools/queries/panelRequest.js create mode 100644 src/tools/queries/panelRequest.test.mjs diff --git a/build/panel.js b/build/panel.js new file mode 100644 index 000000000..4198713f1 --- /dev/null +++ b/build/panel.js @@ -0,0 +1,96 @@ +/** + * The MCP Apps panel build. A panel loads the app through its MCP server, + * one tool call per file (see src/tools/globePanel.js), so the app must be + * few files: one app script, one stylesheet, Cesium's own script, and + * Cesium's workers combined into one script that Cesium runs from memory. + * The build is served at PANEL_BASE next to the app. + */ + +// The panel loads these paths (src/tools/globePanel.js); a test keeps the +// two in step. +export const PANEL_BASE = '/panel/'; +/** Cesium's workers as one script, relative to the panel build. */ +export const PANEL_WORKERS_PATH = 'cesium/workers.js'; +export const PANEL_OUT_DIR = 'dist/panel'; + +/** A browser Vite config changed to produce the panel build. */ +export function panelBuildConfig(config) { + return { + ...config, + base: PANEL_BASE, + build: { + ...config.build, + outDir: PANEL_OUT_DIR, + emptyOutDir: true, + modulePreload: false, + cssCodeSplit: false, + rollupOptions: { + ...config.build?.rollupOptions, + output: { inlineDynamicImports: true }, + }, + }, + }; +} + +/** + * Source for Cesium's `CESIUM_WORKERS` script: every worker module, each + * evaluated only when Cesium starts that worker. `workerNames` are the + * module names in Cesium's Workers directory, without `.js`. + */ +export function workersEntrySource(workerNames, workersDir) { + const entries = workerNames.map( + (name) => + ` ${JSON.stringify(name)}: () => import(${JSON.stringify(`${workersDir}/${name}.js`)}),`, + ); + return `self.CesiumWorkers = {\n${entries.join('\n')}\n};\n`; +} + +const CONTENT_TYPES = { + '.css': 'text/css', + '.geojsonl': 'application/geo+json-seq', + '.glb': 'model/gltf-binary', + '.html': 'text/html', + '.jpg': 'image/jpeg', + '.js': 'text/javascript', + '.json': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.wasm': 'application/wasm', + '.woff2': 'font/woff2', + '.xml': 'application/xml', +}; + +/** + * Vite plugin serving the panel build at PANEL_BASE on the dev server, as + * the preview server and hosted deployments serve it from the build output. + */ +export function panelBuildPlugin({ outDir = PANEL_OUT_DIR } = {}) { + return { + name: 'panel-build', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const { pathname } = new URL(req.url || '/', 'http://localhost'); + if (!pathname.startsWith(PANEL_BASE)) return next(); + const { readFile } = await import('node:fs/promises'); + const { extname, resolve, sep } = await import('node:path'); + const root = resolve(server.config.root, outDir); + const relative = decodeURIComponent(pathname.slice(PANEL_BASE.length)); + const file = resolve(root, relative || 'index.html'); + if (file !== root && !file.startsWith(root + sep)) return next(); + try { + const body = await readFile(file); + res.setHeader( + 'Content-Type', + CONTENT_TYPES[extname(file)] || 'application/octet-stream', + ); + res.end(body); + } catch { + res.statusCode = 404; + res.end( + relative ? 'Not found' : 'No panel build; run npm run build:panel', + ); + } + }); + }, + }; +} diff --git a/build/vite.js b/build/vite.js index 53b7e92e2..bd18aece9 100644 --- a/build/vite.js +++ b/build/vite.js @@ -1,46 +1,7 @@ import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; import { embedFramingPlugin } from './embed-framing.js'; - -/** Origins allowed to read the dev and preview servers' responses. */ -export const PANEL_CORS_ORIGINS = Object.freeze([ - /^https?:\/\/(?:(?:[^:]+\.)?localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/, - /^https:\/\/[a-z0-9]+\.claudemcpcontent\.com$/, - /^(?:https|codex-sandbox):\/\/[a-z0-9-]+\.web-sandbox\.oaiusercontent\.com$/, -]); - -/** - * Vite plugin answering cross-origin reads from PANEL_CORS_ORIGINS on every - * path. It runs ahead of the API middlewares, which Vite's own `cors` option - * would not cover. - */ -function panelCorsPlugin() { - const install = (server) => { - server.middlewares.use((req, res, next) => { - const origin = req.headers.origin; - if ( - !origin || - !PANEL_CORS_ORIGINS.some((allowed) => allowed.test(origin)) - ) - return next(); - res.setHeader('Access-Control-Allow-Origin', origin); - res.setHeader('Vary', 'Origin'); - if (req.method !== 'OPTIONS') return next(); - res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, POST, OPTIONS'); - res.setHeader( - 'Access-Control-Allow-Headers', - req.headers['access-control-request-headers'] || '', - ); - res.statusCode = 204; - res.end(); - }); - }; - return { - name: 'panel-cors', - configureServer: install, - configurePreviewServer: install, - }; -} +import { panelBuildPlugin } from './panel.js'; /** Build browser assets with explicit inputs; never load environment or providers. */ export function createBrowserViteConfig({ @@ -54,11 +15,11 @@ export function createBrowserViteConfig({ } = {}) { return { plugins: [ - panelCorsPlugin(), cesium(), applicationHtmlPlugin(), ...plugins, embedFramingPlugin(), + panelBuildPlugin(), ], ...(publicDir === undefined ? {} : { publicDir }), // A production build must not clean the dependency cache a running dev diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 4aba0a727..8a3f55985 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -108,17 +108,38 @@ into a view. `show_in_gods_eye_view` 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`. +resource from `createGlobePanelResource({ runtime })` in +`src/tools/globePanel.js`, with the panel's script from +`src/app/globePanelRuntime.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 inline +embed mode the first time and posts later views to that same app, so the +globe changes without reloading. Its "Open in God's Eye View" button asks +the host to open the link (`ui/open-link`). + +Hosts serve panels from their own sites and may refuse other addresses; +Codex, for one, refuses any address on the user's machine. So the panel +never requests the app's server itself. It loads everything from the app's +own paths through `panel_request`, a tool only the panel may call +(`_meta.ui.visibility: ["app"]`): the MCP server requests the path from the +app's server and returns the response, compressed and in parts when large. +The same path works for a local server, a hosted one, and every host. Only +map imagery, tiles and fonts load directly, from the providers the +resource's `csp` lists. `panel_request` refuses `/api/setup`, which writes +provider keys. + +The panel loads the app's panel build, which `npm run build:panel` writes to +`dist/panel` and the servers serve at `/panel/`: one app script, one +stylesheet, Cesium's script, and Cesium's workers combined into one script +that Cesium runs from memory (`CESIUM_WORKERS`). Rebuild it after changing +the app. + +Tools declare a UI resource with `defineTool({ ui: { resourceUri } })`, and +an app-only tool with `ui: { visibility: ['app'] }`; +`createMcpServer({ resources })` serves `resources/list` and +`resources/read`. ### Embed mode @@ -251,3 +272,4 @@ public feeds the sources already use. | `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 | | `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live 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 | +| `panel_request` | `app` | Panel only: loads a path from the app's server for the God's Eye View panel | diff --git a/package.json b/package.json index 3b5e18094..dda83c1ec 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "dev:secure": "./scripts/dev-secure.sh", "opensky:import": "./scripts/opensky-import-client.sh", "build": "vite build", + "build:panel": "node scripts/build-panel.mjs", "preview": "vite preview", "test": "node scripts/run-unit-tests.mjs", "test:track": "node scripts/track-regression.mjs", diff --git a/scripts/build-panel.mjs b/scripts/build-panel.mjs new file mode 100644 index 000000000..ad4cc8bd2 --- /dev/null +++ b/scripts/build-panel.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * Build the MCP Apps panel: the app under dist/panel, served at /panel/. + * See build/panel.js. + */ + +import { readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build as esbuild } from 'esbuild'; +import { build } from 'vite'; +import standaloneConfig from '../server/standalone/vite.config.js'; +import { + PANEL_BASE, + PANEL_OUT_DIR, + PANEL_WORKERS_PATH, + panelBuildConfig, + workersEntrySource, +} from '../build/panel.js'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const outDir = join(root, PANEL_OUT_DIR); +const cesiumWorkers = join(root, 'node_modules/cesium/Build/Cesium/Workers'); + +const config = standaloneConfig({ command: 'build', mode: 'production' }); +await build({ + configFile: false, + root, + ...panelBuildConfig(config), +}); + +// Cesium's plugin copies its files under the base path inside outDir. +const nested = join(outDir, PANEL_BASE, 'cesium'); +await rm(join(outDir, 'cesium'), { recursive: true, force: true }); +await rename(nested, join(outDir, 'cesium')); +await rm(join(outDir, PANEL_BASE.split('/')[1]), { recursive: true }); + +const names = (await readdir(cesiumWorkers)) + .filter((file) => file.endsWith('.js') && !file.startsWith('chunk-')) + .map((file) => file.slice(0, -3)); +const entry = join(outDir, 'cesium', 'workers-entry.js'); +await writeFile(entry, workersEntrySource(names, cesiumWorkers)); +await esbuild({ + entryPoints: [entry], + bundle: true, + format: 'iife', + minify: true, + outfile: join(outDir, PANEL_WORKERS_PATH), + logLevel: 'warning', +}); +await rm(entry); +console.log(`Panel build written to ${PANEL_OUT_DIR}`); diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index fa5685bd4..a880f5596 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -31,7 +31,8 @@ "modules": [ "build/vite.js", "build/application-html.js", - "build/embed-framing.js" + "build/embed-framing.js", + "build/panel.js" ], "external": ["vite-plugin-cesium"] }, @@ -3140,7 +3141,8 @@ "src/data/installationFeedback.js", "src/view/index.js", "src/tools/views.js", - "src/tools/globePanel.js" + "src/tools/globePanel.js", + "src/tools/queries/panelRequest.js" ], "external": ["satellite.js"] }, diff --git a/server/mcp/server.js b/server/mcp/server.js index aa11e8ef1..a2acdba27 100644 --- a/server/mcp/server.js +++ b/server/mcp/server.js @@ -6,6 +6,7 @@ import { coreTools, catalogForSurface, } from '../../src/tools/index.js'; +import { panelRuntime } from '../../src/app/globePanelRuntime.js'; import { createGlobePanelResource } from '../../src/tools/globePanel.js'; import { createMcpServer } from '../../src/tools/mcp/index.js'; import { DEFAULT_API_BASE, createLocalToolServices } from './services.js'; @@ -38,6 +39,6 @@ export function createLocalMcpServer({ name: 'gods-eye-view', version, instructions: INSTRUCTIONS, - resources: [createGlobePanelResource({ appUrl: apiBase })], + resources: [createGlobePanelResource({ runtime: panelRuntime })], }); } diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js new file mode 100644 index 000000000..5b150ab3d --- /dev/null +++ b/src/app/globePanelRuntime.js @@ -0,0 +1,546 @@ +/** + * The God's Eye View panel's script, run inside the panel page a host shows. + * It is sent as source text (see src/tools/globePanel.js), so it must not use + * imports or anything outside the function. + * + * The page lives on the host's site, and hosts may refuse other addresses, + * such as a server on the user's machine. So the panel never requests the + * app's server itself: every request for the app's own paths goes to the + * panel's MCP server as a call to `config.toolName`, which requests the path + * from the app's server. The app's built page, scripts, styles, images and + * data all arrive that way; map imagery from providers loads directly. + */ +export function panelRuntime(config) { + const status = document.getElementById('status'); + const open = document.getElementById('open'); + const say = (text) => { + if (status.isConnected) status.textContent = text; + }; + let blocked = null; + document.addEventListener('securitypolicyviolation', (event) => { + blocked = `${event.effectiveDirective} blocked ${event.blockedURI || 'a resource'}`; + }); + + // JSON-RPC with the host. + 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 }); + + // Requests to the app's server, through the MCP server. + const MAX_CALLS = 6; + let calls = 0; + const waiting = []; + async function callTool(args) { + if (calls >= MAX_CALLS) await new Promise((go) => waiting.push(go)); + calls += 1; + try { + const result = await request('tools/call', { + name: config.toolName, + arguments: args, + }); + if (result?.isError || !result?.structuredContent) + throw new Error(result?.content?.[0]?.text || 'The request failed'); + return result.structuredContent; + } finally { + calls -= 1; + waiting.shift()?.(); + } + } + const toBase64 = (bytes) => { + let binary = ''; + for (let start = 0; start < bytes.length; start += 0x8000) + binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000)); + return btoa(binary); + }; + const fromBase64 = (text) => + Uint8Array.from(atob(text), (char) => char.charCodeAt(0)); + async function bodyBytes(body) { + if (body == null) return null; + if (typeof body === 'string') return new TextEncoder().encode(body); + if (body instanceof ArrayBuffer) return new Uint8Array(body); + if (ArrayBuffer.isView(body)) + return new Uint8Array(body.buffer, body.byteOffset, body.byteLength); + return new Uint8Array(await new Response(body).arrayBuffer()); + } + const NULL_BODY = new Set([101, 204, 205, 304]); + const held = new Map(); + + /** Request a path on the app's server; resolves to a Response. */ + function appFetch(path, init = {}) { + const method = (init.method || 'GET').toUpperCase(); + // Built files never change while the panel runs, so each loads once. + const shared = method === 'GET' && path.startsWith(config.panelBase); + if (shared && held.has(path)) return held.get(path).then(respond); + const loading = (async () => { + const headers = {}; + new Headers(init.headers || {}).forEach((value, name) => { + headers[name] = value; + }); + const bytes = await bodyBytes(init.body); + let part = await callTool({ + path, + method, + headers, + ...(bytes ? { body: toBase64(bytes) } : {}), + }); + const parts = [fromBase64(part.body)]; + while (part.nextOffset !== undefined) { + if (init.signal?.aborted) break; + part = await callTool({ id: part.id, offset: part.nextOffset }); + parts.push(fromBase64(part.body)); + } + let body = new Blob(parts); + if (part.encoding === 'gzip') + body = await new Response( + body.stream().pipeThrough(new DecompressionStream('gzip')), + ).blob(); + return { + body: await body.arrayBuffer(), + status: part.status, + statusText: part.statusText, + headers: part.headers, + }; + })(); + if (shared) held.set(path, loading); + const result = loading.then(respond); + if (!init.signal) return result; + return Promise.race([ + result, + new Promise((_, reject) => { + const abort = () => + reject(new DOMException('The request was aborted', 'AbortError')); + if (init.signal.aborted) abort(); + init.signal.addEventListener('abort', abort, { once: true }); + }), + ]); + } + function respond({ body, status, statusText, headers }) { + return new Response(NULL_BODY.has(status) ? null : body.slice(0), { + status, + statusText, + headers, + }); + } + + // The app's own paths: relative ones, and absolute ones on this page's + // site, matched by scheme and host since a host may use its own scheme. + const page = new URL(document.baseURI); + function appPath(value) { + let url; + try { + url = new URL(String(value), document.baseURI); + } catch { + return null; + } + if (url.protocol !== page.protocol || url.host !== page.host) return null; + return url.pathname + url.search; + } + const blobUrls = new Map(); + /** A blob: URL holding an app file, for elements that load by URL. */ + function appObjectUrl(path) { + if (!blobUrls.has(path)) + blobUrls.set( + path, + appFetch(path) + .then((response) => response.blob()) + .then((blob) => URL.createObjectURL(blob)), + ); + return blobUrls.get(path); + } + + function installRequests() { + const nativeFetch = window.fetch.bind(window); + window.fetch = async (input, init = {}) => { + const isRequest = input instanceof Request; + const path = appPath(isRequest ? input.url : input); + if (path === null) return nativeFetch(input, init); + if (!isRequest) return appFetch(path, init); + return appFetch(path, { + method: init.method || input.method, + headers: init.headers || input.headers, + body: + init.body ?? + (['GET', 'HEAD'].includes(input.method) + ? undefined + : await input.arrayBuffer()), + signal: init.signal || input.signal, + }); + }; + + // XMLHttpRequest for app paths: answered from appFetch, with the + // instance's state replaced, and the usual events dispatched. + const xhr = XMLHttpRequest.prototype; + const nativeOpen = xhr.open; + const nativeSend = xhr.send; + const nativeHeader = xhr.setRequestHeader; + const nativeAbort = xhr.abort; + xhr.open = function (method, url, ...rest) { + const path = appPath(url); + if (path === null) { + this.__gev = null; + return nativeOpen.call(this, method, url, ...rest); + } + this.__gev = { method, path, headers: {}, controller: null }; + setState(this, { readyState: 1 }); + this.dispatchEvent(new Event('readystatechange')); + }; + xhr.setRequestHeader = function (name, value) { + if (!this.__gev) return nativeHeader.call(this, name, value); + this.__gev.headers[name] = value; + }; + xhr.abort = function () { + if (!this.__gev) return nativeAbort.call(this); + this.__gev.controller?.abort(); + }; + xhr.send = function (body) { + const state = this.__gev; + if (!state) return nativeSend.call(this, body); + state.controller = new AbortController(); + appFetch(state.path, { + method: state.method, + headers: state.headers, + body, + signal: state.controller.signal, + }) + .then(async (response) => { + const type = this.responseType; + const value = + type === 'arraybuffer' + ? await response.arrayBuffer() + : type === 'blob' + ? await response.blob() + : await response.text(); + const text = typeof value === 'string' ? value : null; + let parsed = value; + if (type === 'json') { + try { + parsed = JSON.parse(text); + } catch { + parsed = null; + } + } else if (type === 'document') + parsed = new DOMParser().parseFromString(text, 'text/html'); + const headers = [...response.headers] + .map(([name, val]) => `${name}: ${val}`) + .join('\r\n'); + setState(this, { + readyState: 4, + status: response.status, + statusText: response.statusText, + response: parsed, + responseText: type === '' || type === 'text' ? text : '', + responseURL: new URL(state.path, document.baseURI).href, + getAllResponseHeaders: () => headers, + getResponseHeader: (name) => response.headers.get(name), + }); + for (const name of ['readystatechange', 'load', 'loadend']) + this.dispatchEvent( + name === 'readystatechange' + ? new Event(name) + : new ProgressEvent(name), + ); + }) + .catch((error) => { + setState(this, { readyState: 4, status: 0 }); + const name = error?.name === 'AbortError' ? 'abort' : 'error'; + for (const event of ['readystatechange', name, 'loadend']) + this.dispatchEvent( + event === 'readystatechange' + ? new Event(event) + : new ProgressEvent(event), + ); + }); + }; + function setState(target, values) { + for (const [name, value] of Object.entries(values)) + Object.defineProperty(target, name, { + configurable: true, + ...(typeof value === 'function' ? { value } : { get: () => value }), + }); + } + + const nativeBeacon = navigator.sendBeacon?.bind(navigator); + if (nativeBeacon) + navigator.sendBeacon = (url, data) => { + const path = appPath(url); + if (path === null) return nativeBeacon(url, data); + appFetch(path, { method: 'POST', body: data }).catch(() => {}); + return true; + }; + + // Images name app files by URL; give them the file as a blob: URL. + const image = Object.getOwnPropertyDescriptor( + HTMLImageElement.prototype, + 'src', + ); + Object.defineProperty(HTMLImageElement.prototype, 'src', { + ...image, + set(value) { + const path = appPath(value); + if (path === null) return image.set.call(this, value); + appObjectUrl(path).then( + (url) => image.set.call(this, url), + () => this.dispatchEvent(new Event('error')), + ); + }, + }); + + // Workers on the app's server start from a small worker here that is + // handed the script once it has loaded; messages wait until then. + const NativeWorker = window.Worker; + const BOOT = + 'const early=[];let ready=false;' + + "addEventListener('message',e=>{if(e.data&&e.data.__gevScript){" + + 'const go=()=>{ready=true;for(const d of early)dispatchEvent(new MessageEvent("message",{data:d}))};' + + 'if(e.data.module)import(e.data.__gevScript).then(go);else{importScripts(e.data.__gevScript);go()}' + + 'e.stopImmediatePropagation();return}' + + 'if(!ready){early.push(e.data);e.stopImmediatePropagation()}});'; + window.Worker = function Worker(url, options) { + const path = appPath(url); + if (path === null || String(url).startsWith('blob:')) + return new NativeWorker(url, options); + const worker = new NativeWorker( + URL.createObjectURL(new Blob([BOOT], { type: 'text/javascript' })), + options, + ); + appObjectUrl(path).then((script) => + worker.postMessage({ + __gevScript: script, + module: options?.type === 'module', + }), + ); + return worker; + }; + window.Worker.prototype = NativeWorker.prototype; + + // Markup and styles the app adds later name app files too. + new MutationObserver((records) => { + for (const record of records) + for (const node of record.addedNodes) { + if (node.nodeType !== 1) continue; + if (node.nodeName === 'STYLE') void inlineStyleUrls(node); + for (const element of [node, ...node.querySelectorAll('img[src]')]) { + if (element.nodeName !== 'IMG') continue; + const path = appPath(element.getAttribute('src')); + if (path !== null) + appObjectUrl(path).then((url) => + element.setAttribute('src', url), + ); + } + } + }).observe(document.documentElement, { childList: true, subtree: true }); + } + + /** Replace url() references to app files in CSS with blob: URLs. */ + async function cssWithFiles(text, baseUrl) { + const found = new Map(); + for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)) { + const reference = match[2]; + if (/^(data|blob):/.test(reference)) continue; + const path = appPath(new URL(reference, baseUrl).href); + if (path !== null && !found.has(reference)) + found.set( + reference, + appObjectUrl(path).catch(() => null), + ); + } + let result = text; + for (const [reference, loading] of found) { + const url = await loading; + if (url) result = result.split(reference).join(url); + } + return result; + } + async function inlineStyleUrls(style) { + if (!/url\(/.test(style.textContent)) return; + style.textContent = await cssWithFiles(style.textContent, document.baseURI); + } + + async function addStylesheet(href) { + const path = appPath(href); + const style = document.createElement('style'); + document.head.appendChild(style); + if (path === null) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + style.replaceWith(link); + return; + } + const text = await (await appFetch(path)).text(); + style.textContent = await cssWithFiles( + text, + new URL(path, document.baseURI).href, + ); + } + + function runScript(text, type) { + return new Promise((resolve) => { + const script = document.createElement('script'); + if (type) script.type = type; + script.textContent = text; + document.body.appendChild(script); + resolve(); + }); + } + + /** + * Load God's Eye View into this page from the app's panel build, in + * inline embed mode, starting at the first view as its link would. + */ + async function startApp(url) { + window.GEV_EMBED_INLINE = true; + try { + history.replaceState( + null, + '', + location.href.split('#')[0] + new URL(url).hash, + ); + } catch { + // The view still arrives once the app is ready. + } + installRequests(); + const response = await appFetch(config.panelBase); + if (!response.ok) + throw new Error( + response.status === 404 + ? 'the app has no panel build (npm run build:panel)' + : `the app answered ${response.status}`, + ); + const built = new DOMParser().parseFromString( + await response.text(), + 'text/html', + ); + window.CESIUM_BASE_URL = `${config.panelBase}cesium/`; + const [workers] = await Promise.all([ + appFetch(config.panelBase + config.workersPath).then((r) => r.text()), + ...[...built.head.querySelectorAll('link[rel="stylesheet"]')].map( + (link) => addStylesheet(link.getAttribute('href')), + ), + ]); + // Cesium starts its workers from this script instead of from files. + window.CESIUM_WORKERS = workers; + // Images in the markup load their files once added; give them the + // files instead of letting them request this page's site. + const images = []; + for (const image of built.body.querySelectorAll('img[src]')) { + const path = appPath(image.getAttribute('src')); + if (path === null) continue; + image.removeAttribute('src'); + images.push([image, path]); + } + for (const node of [...built.body.childNodes]) { + if (node.nodeName !== 'SCRIPT') + document.body.insertBefore(document.adoptNode(node), status); + } + for (const [image, path] of images) + appObjectUrl(path).then((url) => image.setAttribute('src', url)); + const scripts = await Promise.all( + [...built.querySelectorAll('script')].map(async (script) => ({ + type: script.type, + text: script.getAttribute('src') + ? await (await appFetch(appPath(script.getAttribute('src')))).text() + : script.textContent, + })), + ); + for (const { type, text } of scripts) await runScript(text, type); + } + + // Tool results: the first view starts the app, later ones move it. + let started = false; + let ready = false; + let queued = null; + let currentUrl = null; + const postView = (view) => + window.postMessage({ type: 'gev:view', id: nextId++, view }, '*'); + function show(result) { + const data = result?.structuredContent; + const view = data?.view; + const url = data?.url || view?.url; + if (!view || !url) return; + currentUrl = url; + open.hidden = false; + if (ready) return postView(view); + queued = view; + if (started) return; + started = true; + say("Loading God's Eye View…"); + startApp(url).catch((error) => + say(`God's Eye View could not load here: ${error?.message || error}.`), + ); + setTimeout(() => { + if (!ready) + say( + "God's Eye View did not load here" + + (blocked ? ` (${blocked})` : '') + + ". Use Open in God's Eye View above.", + ); + }, config.loadTimeoutMs); + } + + window.addEventListener('message', (event) => { + const message = event.data; + if (event.source === window) { + if (message?.type === 'gev:ready') { + ready = true; + status.remove(); + if (queued) postView(queued); + queued = null; + } + return; + } + if (event.source !== window.parent || 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'), + ); + }); + + // The handshake the MCP Apps SDK's App.connect performs: hosts validate + // these parameters, and keep a view hidden until it is initialized. + request('ui/initialize', { + appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] }, + appInfo: { + name: 'gods-eye-view', + title: "God's Eye View", + version: '1.0.0', + }, + protocolVersion: config.protocolVersion, + }).then( + () => { + send({ method: 'ui/notifications/initialized' }); + notify('ui/notifications/size-changed', { + width: document.body.clientWidth, + height: config.panelHeight, + }); + }, + (error) => + say( + "This client did not accept the God's Eye View panel" + + (error?.message ? `: ${error.message}` : '.'), + ), + ); +} diff --git a/src/tooling/panelBuild.test.mjs b/src/tooling/panelBuild.test.mjs new file mode 100644 index 000000000..a7a2ab15b --- /dev/null +++ b/src/tooling/panelBuild.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import * as PANEL_PATHS from '../tools/globePanel.js'; +import { + PANEL_BASE, + PANEL_WORKERS_PATH, + panelBuildConfig, + panelBuildPlugin, + workersEntrySource, +} from '../../build/panel.js'; + +test('the panel loads the paths the panel build writes', () => { + assert.equal(PANEL_BASE, PANEL_PATHS.PANEL_BASE); + assert.equal(PANEL_WORKERS_PATH, PANEL_PATHS.PANEL_WORKERS_PATH); +}); + +test('the panel build is one script and one stylesheet under /panel/', () => { + const config = panelBuildConfig({ + plugins: [], + build: { chunkSizeWarningLimit: 1500 }, + }); + assert.equal(config.base, PANEL_BASE); + assert.equal(config.build.outDir, 'dist/panel'); + assert.equal(config.build.cssCodeSplit, false); + assert.equal(config.build.rollupOptions.output.inlineDynamicImports, true); + assert.equal(config.build.chunkSizeWarningLimit, 1500); +}); + +test("Cesium's workers load lazily from one script", () => { + const source = workersEntrySource(['createGeometry', 'decodeDraco'], '/w'); + assert.match(source, /^self\.CesiumWorkers = \{/); + assert.match( + source, + /"createGeometry": \(\) => import\("\/w\/createGeometry\.js"\)/, + ); + assert.match( + source, + /"decodeDraco": \(\) => import\("\/w\/decodeDraco\.js"\)/, + ); +}); + +test('the dev server serves the panel build and nothing outside it', async () => { + const root = await mkdtemp(join(tmpdir(), 'panel-build-')); + try { + await mkdir(join(root, 'out', 'assets'), { recursive: true }); + await writeFile(join(root, 'out', 'index.html'), '

panel

'); + await writeFile(join(root, 'out', 'assets', 'a.js'), 'x'); + await writeFile(join(root, 'secret.txt'), 'secret'); + let middleware; + panelBuildPlugin({ outDir: 'out' }).configureServer({ + config: { root }, + middlewares: { use: (fn) => (middleware = fn) }, + }); + const get = (url) => + new Promise((resolve) => { + const res = { + statusCode: 200, + headers: {}, + setHeader(name, value) { + this.headers[name] = value; + }, + end(body) { + resolve({ status: this.statusCode, headers: this.headers, body }); + }, + }; + middleware({ url }, res, () => resolve({ passed: true })); + }); + const page = await get('/panel/'); + assert.equal(String(page.body), '

panel

'); + assert.equal(page.headers['Content-Type'], 'text/html'); + const script = await get('/panel/assets/a.js'); + assert.equal(script.headers['Content-Type'], 'text/javascript'); + assert.equal((await get('/panel/missing.js')).status, 404); + assert.deepEqual(await get('/panel/..%2Fsecret.txt'), { passed: true }); + assert.deepEqual(await get('/other'), { passed: true }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 6550c5235..a7c79e53f 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -12,8 +12,7 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop googleApiKey: 'browser-fixture', cesiumToken: 'ion-fixture', }); - assert.equal(config.plugins[0].name, 'panel-cors'); - assert.equal(config.plugins[3], plugin); + assert.equal(config.plugins[2], plugin); assert.equal(config.server.host, 'localhost'); assert.equal(config.server.port, 4173); assert.deepEqual(config.server.allowedHosts, [ @@ -43,45 +42,6 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop ); }); -test('panel origins may read every dev server path, other sites may not', () => { - let middleware; - createBrowserViteConfig().plugins[0].configureServer({ - middlewares: { use: (fn) => (middleware = fn) }, - }); - const request = (origin, method = 'GET') => { - const headers = {}; - let ended = false; - let passed = false; - middleware( - { method, headers: origin ? { origin } : {} }, - { - setHeader: (name, value) => (headers[name] = value), - end: () => (ended = true), - }, - () => (passed = true), - ); - return { headers, ended, passed }; - }; - const panel = request('https://abc123.claudemcpcontent.com'); - assert.equal( - panel.headers['Access-Control-Allow-Origin'], - 'https://abc123.claudemcpcontent.com', - ); - assert.equal(panel.passed, true); - assert.equal( - request('https://abc123.claudemcpcontent.com', 'OPTIONS').ended, - true, - ); - assert.equal( - request('codex-sandbox://mcp-app-ab12.web-sandbox.oaiusercontent.com') - .headers['Access-Control-Allow-Origin'], - 'codex-sandbox://mcp-app-ab12.web-sandbox.oaiusercontent.com', - ); - const other = request('https://example.com'); - assert.deepEqual(other.headers, {}); - assert.equal(other.passed, true); -}); - test('build helper does not discover environment values or construct local providers', () => { const before = process.env.GOOGLE_MAPS_API_KEY; process.env.GOOGLE_MAPS_API_KEY = 'environment-fixture'; @@ -92,8 +52,8 @@ test('build helper does not discover environment values or construct local provi undefined, ); assert.deepEqual( - config.plugins.slice(3).map((plugin) => plugin.name), - ['embed-framing'], + config.plugins.slice(2).map((plugin) => plugin.name), + ['embed-framing', 'panel-build'], ); } finally { if (before === undefined) delete process.env.GOOGLE_MAPS_API_KEY; @@ -106,14 +66,15 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }); assert.deepEqual( - config.plugins.slice(3, -3).map((plugin) => plugin.name), + config.plugins.slice(2, -4).map((plugin) => plugin.name), providers.localProviderPlugins().map((plugin) => plugin.name), ); - assert.equal(config.plugins.at(-4).name, 'gev-key-setup'); + assert.equal(config.plugins.at(-5).name, 'gev-key-setup'); // The local MCP route follows every provider and precedes the API fallback. - assert.equal(config.plugins.at(-3).name, 'local-mcp'); - assert.equal(config.plugins.at(-2).name, 'api-not-found'); - assert.equal(config.plugins.at(-1).name, 'embed-framing'); + assert.equal(config.plugins.at(-4).name, 'local-mcp'); + assert.equal(config.plugins.at(-3).name, 'api-not-found'); + assert.equal(config.plugins.at(-2).name, 'embed-framing'); + assert.equal(config.plugins.at(-1).name, 'panel-build'); }); test('build export resolves in Node and has no browser fallback', async () => { diff --git a/src/tools/catalog.js b/src/tools/catalog.js index 59cd6f0c9..4d5106b6e 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -11,6 +11,8 @@ import { assertSupportedSchema, validateValue } from './schema.js'; const TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/; const KINDS = new Set(['query', 'action']); +/** Who may call a tool, as MCP Apps names them: the model, the app's panel. */ +const VISIBILITY = new Set(['model', 'app']); /** Error codes a tool may report. Each is safe to show to a model or person. */ export const TOOL_ERROR_CODES = Object.freeze([ @@ -93,11 +95,26 @@ 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`); + if (ui !== null) { + if ( + ui?.resourceUri !== undefined && + !( + typeof ui.resourceUri === 'string' && ui.resourceUri.startsWith('ui://') + ) + ) + throw new TypeError(`${name}.ui.resourceUri must be a ui:// URI`); + if ( + ui?.visibility !== undefined && + !( + Array.isArray(ui.visibility) && + ui.visibility.length > 0 && + ui.visibility.every((who) => VISIBILITY.has(who)) + ) + ) + throw new TypeError(`${name}.ui.visibility must list model and/or app`); + if (ui?.resourceUri === undefined && ui?.visibility === undefined) + throw new TypeError(`${name}.ui needs a resourceUri or a visibility`); + } return Object.freeze({ name, kind, @@ -109,7 +126,14 @@ export function defineTool({ ...annotations, }), requires: Object.freeze([...requires]), - ui: ui ? Object.freeze({ resourceUri: ui.resourceUri }) : null, + ui: ui + ? Object.freeze({ + ...(ui.resourceUri ? { resourceUri: ui.resourceUri } : {}), + ...(ui.visibility + ? { visibility: Object.freeze([...ui.visibility]) } + : {}), + }) + : null, run, }); } diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index 7f0a29138..a9f2cd9a3 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -1,15 +1,21 @@ /** * The God's Eye View panel: an MCP Apps view (`io.modelcontextprotocol/ui`) that shows * God's Eye View inside a conversation. The panel is a small page that runs - * the app inside itself in embed mode and sends it each view the - * show_in_gods_eye_view tool returns; see docs/TOOLS.md. + * the app's panel build inside itself in embed mode, loading it through the + * MCP server, and sends it each view the show_in_gods_eye_view tool returns; + * see docs/TOOLS.md. */ export const GLOBE_PANEL_URI = 'ui://gods-eye-view/globe'; +/** Where the app's server serves its panel build (see build/panel.js). */ +export const PANEL_BASE = '/panel/'; +/** Cesium's workers as one script, relative to the panel build. */ +export const PANEL_WORKERS_PATH = 'cesium/workers.js'; /** * Sites the app's browser code loads from directly: map imagery, 3D tiles, - * terrain and fonts. Everything else goes through the app's own server. + * terrain and fonts. Everything from the app's own server comes through the + * MCP server. */ const PROVIDER_ORIGINS = Object.freeze([ 'https://tile.googleapis.com', @@ -31,19 +37,31 @@ const PROVIDER_ORIGINS = Object.freeze([ 'https://fonts.gstatic.com', ]); -/** The WebSocket origin matching an http(s) origin, for live-reload. */ -const socketOrigin = (origin) => origin.replace(/^http/, 'ws'); export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app'; const MCP_APPS_PROTOCOL_VERSION = '2026-01-26'; const PANEL_HEIGHT_PX = 520; +const LOAD_TIMEOUT_MS = 90_000; +/** The panel-only tool the panel loads the app through. */ +const PANEL_REQUEST_TOOL = 'panel_request'; /** - * The panel page. It completes the MCP Apps handshake with its host, then - * for each tool result that carries a view: loads the app in embed mode the - * first time, and posts the view to the app once it is ready, so later views - * change the globe without reloading. + * The panel page: its status line, the Open in God's Eye View button, and + * the panel's script, `runtime`, which loads the app through the MCP server + * and shows each view a tool returns. */ -function panelHtml(appOrigin) { +function panelHtml(runtime) { + const config = { + loadTimeoutMs: LOAD_TIMEOUT_MS, + panelBase: PANEL_BASE, + panelHeight: PANEL_HEIGHT_PX, + protocolVersion: MCP_APPS_PROTOCOL_VERSION, + toolName: PANEL_REQUEST_TOOL, + workersPath: PANEL_WORKERS_PATH, + }; + const script = `(${runtime})(${JSON.stringify(config)});`.replace( + /<\/script/gi, + '<\\/script', + ); return ` @@ -53,7 +71,7 @@ function panelHtml(appOrigin) { html, body { margin: 0; height: ${PANEL_HEIGHT_PX}px; background: #05070a; color: #b8c4cc; font: 13px/1.4 system-ui, sans-serif; overflow: hidden; } #status { position: absolute; inset: 0; z-index: 10000; display: flex; align-items: center; - justify-content: center; } + justify-content: center; padding: 0 24px; text-align: center; } #open { position: absolute; right: 10px; top: 10px; z-index: 10001; 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; } @@ -62,310 +80,38 @@ function panelHtml(appOrigin) {
Waiting for a view…
- + `; } /** - * The panel as an MCP resource for an app served at `appUrl`. Its security - * policy lets the panel load the app's code from that origin and reach the - * map and imagery providers the app uses directly. + * The panel as an MCP resource. `runtime` is the panel's script, a function + * of its configuration that runs in the panel page; the application supplies + * it (src/app/globePanelRuntime.js), as it is browser code. The security + * policy lets the panel load map imagery, tiles and fonts from their + * providers; everything from the app's own server arrives through the MCP + * server instead. */ -export function createGlobePanelResource({ appUrl }) { - const appOrigin = new URL(appUrl).origin; +export function createGlobePanelResource({ runtime }) { + if (typeof runtime !== 'function') + throw new TypeError('The globe panel needs its runtime script'); return Object.freeze({ uri: GLOBE_PANEL_URI, name: 'globe', title: "God's Eye View globe", description: "Live God's Eye View, showing the view a tool returns.", mimeType: MCP_APP_MIME_TYPE, - text: panelHtml(appOrigin), + text: panelHtml(runtime), _meta: { ui: { csp: { - resourceDomains: [appOrigin, ...PROVIDER_ORIGINS], - connectDomains: [ - appOrigin, - socketOrigin(appOrigin), - ...PROVIDER_ORIGINS, - ], + resourceDomains: [...PROVIDER_ORIGINS], + connectDomains: [...PROVIDER_ORIGINS], }, prefersBorder: true, }, - // The same policy under the key OpenAI's clients read. - 'openai/widgetCSP': { - connect_domains: [ - appOrigin, - socketOrigin(appOrigin), - ...PROVIDER_ORIGINS, - ], - resource_domains: [appOrigin, ...PROVIDER_ORIGINS], - }, }, }); } diff --git a/src/tools/globePanel.test.mjs b/src/tools/globePanel.test.mjs index 350316d38..bc7504081 100644 --- a/src/tools/globePanel.test.mjs +++ b/src/tools/globePanel.test.mjs @@ -5,42 +5,35 @@ import { MCP_APP_MIME_TYPE, createGlobePanelResource, } from './globePanel.js'; +import { panelRuntime } from '../app/globePanelRuntime.js'; import { composeCatalog, coreTools } from './index.js'; -test('the globe panel is an MCP Apps resource that runs the app inside itself', () => { - const resource = createGlobePanelResource({ - appUrl: 'http://localhost:5173/some/path', - }); +test('the globe panel is an MCP Apps resource that loads the app through its server', () => { + const resource = createGlobePanelResource({ runtime: panelRuntime }); 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'); const { csp } = resource._meta.ui; - // Hosts may not let a panel frame other sites, so it declares no frames - // and instead loads the app's code and data from the app's origin. - assert.equal(csp.frameDomains, undefined); - assert.equal(csp.resourceDomains[0], 'http://localhost:5173'); - assert.deepEqual(csp.connectDomains.slice(0, 2), [ - 'http://localhost:5173', - 'ws://localhost:5173', - ]); + // The panel reaches only map and font providers; the app's own files + // and data come through the MCP server, so no app address is declared. + assert.deepEqual(csp.connectDomains, csp.resourceDomains); assert.ok(csp.resourceDomains.includes('https://tile.googleapis.com')); - // Hosts may refuse ; the panel sends the app's requests itself. + assert.ok( + csp.resourceDomains.every((origin) => origin.startsWith('https://')), + ); + assert.equal(csp.frameDomains, undefined); assert.equal(csp.baseUriDomains, undefined); - assert.deepEqual(resource._meta['openai/widgetCSP'], { - connect_domains: csp.connectDomains, - resource_domains: csp.resourceDomains, - }); - assert.doesNotMatch(resource.text, /createElement\('base'\)/); - assert.match(resource.text, /window\.GEV_EMBED_INLINE = true/); - assert.match(resource.text, /fetch\(APP_ORIGIN \+ '\/\?embed=1'\)/); - assert.doesNotMatch(resource.text, /([\s\S]*)<\/script>/)[1]; + assert.doesNotThrow(() => new Function(script)); }); test('show_in_gods_eye_view names the panel and shows a view another answer returned', async () => { diff --git a/src/tools/index.js b/src/tools/index.js index dc6d19b67..0d0802a46 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -40,6 +40,7 @@ import { getTransitVehicles, } from './queries/mobility.js'; import { placesNearby, planRoute, searchPlaces } from './queries/places.js'; +import { panelRequest } from './queries/panelRequest.js'; import { showInGodsEyeView } from './queries/share.js'; import { findAlprCameras } from './queries/surveillance.js'; import { @@ -122,4 +123,5 @@ export const coreTools = Object.freeze([ militaryAwareness, getHudCaption, showInGodsEyeView, + panelRequest, ]); diff --git a/src/tools/mcp/mcp.test.mjs b/src/tools/mcp/mcp.test.mjs index 0df9bb955..23f251f75 100644 --- a/src/tools/mcp/mcp.test.mjs +++ b/src/tools/mcp/mcp.test.mjs @@ -274,6 +274,42 @@ test('resources are listed and read, and tools name their UI resource', async () ui: { resourceUri: 'https://a.example' }, run: async () => ({ summary: '', data: {} }), }), - /ui:\/\/ resourceUri/, + /ui:\/\/ URI/, + ); + assert.throws( + () => + defineTool({ + name: 'bad', + title: 'Bad', + description: 'Visible to nobody.', + inputSchema: { type: 'object' }, + ui: { visibility: ['user'] }, + run: async () => ({ summary: '', data: {} }), + }), + /visibility/, ); }); + +test('a tool only an app may call says so, and answers it without JSON text', async () => { + const loader = defineTool({ + name: 'load', + title: 'Load', + description: 'Loads a file for the view.', + inputSchema: { type: 'object', properties: {} }, + ui: { visibility: ['app'] }, + run: async () => ({ summary: 'loaded', data: { body: 'abc' } }), + }); + const appServer = createMcpServer({ + catalog: composeCatalog({ tools: [loader] }), + name: 'fixture', + version: '1', + }); + const call = async (method, params) => + (await appServer.handle({ jsonrpc: '2.0', id: 1, method, params })).result; + assert.deepEqual((await call('tools/list')).tools[0]._meta, { + ui: { visibility: ['app'] }, + }); + const result = await call('tools/call', { name: 'load', arguments: {} }); + assert.deepEqual(result.content, [{ type: 'text', text: 'loaded' }]); + assert.deepEqual(result.structuredContent, { body: 'abc' }); +}); diff --git a/src/tools/mcp/protocol.js b/src/tools/mcp/protocol.js index 446d01869..ad5c39bf8 100644 --- a/src/tools/mcp/protocol.js +++ b/src/tools/mcp/protocol.js @@ -41,11 +41,13 @@ export function toMcpTools(catalog, { descriptions = {}, decorate } = {}) { annotations: { title, ...tool.annotations }, ...(tool.ui ? { - // The flat key is the earlier form of the same field, which some - // hosts still read; the MCP Apps SDK sends both. _meta: { - ui: { resourceUri: tool.ui.resourceUri }, - 'ui/resourceUri': tool.ui.resourceUri, + ui: { ...tool.ui }, + // The flat key is the earlier form of the same field, which + // some hosts still read; the MCP Apps SDK sends both. + ...(tool.ui.resourceUri + ? { 'ui/resourceUri': tool.ui.resourceUri } + : {}), }, } : {}), @@ -111,12 +113,18 @@ export function createMcpServer({ const result = await catalog.call(params.name, params.arguments ?? {}, { signal, }); + // Only an app reads a tool the model cannot call, and apps read + // structuredContent. + const appOnly = + catalog.get(params.name).ui?.visibility?.includes('model') === false; return { // The data is repeated as JSON text for clients that do not read // structuredContent. content: [ { type: 'text', text: result.summary }, - { type: 'text', text: JSON.stringify(result.data) }, + ...(appOnly + ? [] + : [{ type: 'text', text: JSON.stringify(result.data) }]), ...(result.images || []).map((item) => ({ type: 'image', data: item.data, diff --git a/src/tools/queries/panelRequest.js b/src/tools/queries/panelRequest.js new file mode 100644 index 000000000..3d5b2b94e --- /dev/null +++ b/src/tools/queries/panelRequest.js @@ -0,0 +1,184 @@ +/** + * The God's Eye View panel's requests. A panel cannot reach the app's server + * itself: hosts serve panels from their own sites and may refuse other + * addresses, such as a server on the user's machine. The panel asks this + * tool instead, which only an app may call, and it requests the path from + * the app's server. Large responses come back in parts. + */ + +import { defineTool, ToolError } from '../catalog.js'; + +/** Bytes of response body per call, before base64. */ +export const PANEL_PART_BYTES = 512 * 1024; +const HELD_MS = 2 * 60 * 1000; +const HELD_LIMIT_BYTES = 256 * 1024 * 1024; +const METHODS = new Set(['GET', 'HEAD', 'POST']); +// Provider Settings write the app's keys; only the app's own page may. +const REFUSED_PATHS = [/^\/api\/setup(?:\/|$)/]; +const FORWARDED_REQUEST_HEADERS = ['accept', 'content-type']; +const DROPPED_RESPONSE_HEADERS = new Set([ + 'connection', + 'content-encoding', + 'content-length', + 'keep-alive', + 'set-cookie', + 'transfer-encoding', +]); +// Already compressed, so gzip would only cost time. +const INCOMPRESSIBLE = + /^(?:image\/(?!svg)|video\/|audio\/|font\/woff2)|zip|compressed/; +const COMPRESS_MIN_BYTES = 1024; + +// Bodies too large for one call, kept for the calls that read the rest. +const held = new Map(); +let heldBytes = 0; + +function base64(bytes) { + let binary = ''; + for (let start = 0; start < bytes.length; start += 0x8000) + binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000)); + return btoa(binary); +} + +function fromBase64(text) { + return Uint8Array.from(atob(text), (char) => char.charCodeAt(0)); +} + +async function gzip(bytes) { + const stream = new Blob([bytes]) + .stream() + .pipeThrough(new CompressionStream('gzip')); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +function forget(now = Date.now()) { + for (const [id, entry] of held) { + if (entry.expires > now && heldBytes <= HELD_LIMIT_BYTES) continue; + held.delete(id); + heldBytes -= entry.bytes.length; + } +} + +function part(response, bytes, offset) { + const end = Math.min(bytes.length, offset + PANEL_PART_BYTES); + return { + ...response, + offset, + body: base64(bytes.subarray(offset, end)), + ...(end < bytes.length ? { nextOffset: end } : {}), + }; +} + +function checkedPath(path) { + if ( + typeof path !== 'string' || + !path.startsWith('/') || + path.startsWith('//') + ) + throw new ToolError('invalid_arguments', 'path must start with one /'); + const { pathname } = new URL(path, 'http://app.invalid'); + if (REFUSED_PATHS.some((pattern) => pattern.test(pathname))) + throw new ToolError('invalid_arguments', `${pathname} is not available`); + return path; +} + +export const panelRequest = defineTool({ + name: 'panel_request', + title: "God's Eye View panel request", + description: + "Loads a file or data for the God's Eye View panel from the app's " + + 'server. Only the panel calls this; it does not answer questions.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: "A path on the app's server, starting with /.", + }, + method: { type: 'string', enum: [...METHODS] }, + headers: { + type: 'object', + description: 'Request headers; only Accept and Content-Type are sent.', + }, + body: { type: 'string', description: 'The request body, base64.' }, + id: { + type: 'string', + description: 'Continue a response an earlier call returned in parts.', + }, + offset: { type: 'integer', minimum: 0 }, + }, + additionalProperties: false, + }, + requires: ['app'], + ui: { visibility: ['app'] }, + async run(args, { services, signal }) { + forget(); + if (args.id !== undefined) { + const entry = held.get(args.id); + if (!entry) + throw new ToolError( + 'invalid_arguments', + 'That response is no longer held; request the path again', + ); + return { + summary: `Part of ${entry.path}`, + data: part(entry.response, entry.bytes, args.offset ?? 0), + }; + } + const path = checkedPath(args.path); + const method = args.method ?? 'GET'; + if (!METHODS.has(method)) + throw new ToolError('invalid_arguments', `Unsupported method ${method}`); + const headers = {}; + for (const [name, value] of Object.entries(args.headers ?? {})) { + if (FORWARDED_REQUEST_HEADERS.includes(name.toLowerCase())) + headers[name] = String(value); + } + let answer; + try { + answer = await services.app.fetch(path, { + method, + headers, + ...(args.body !== undefined && method === 'POST' + ? { body: fromBase64(args.body) } + : {}), + signal, + }); + } catch (error) { + if (signal?.aborted) throw error; + throw new ToolError('unavailable', "The app's server did not answer"); + } + let bytes = new Uint8Array(await answer.arrayBuffer()); + const type = answer.headers.get('content-type') || ''; + let encoding = 'identity'; + if (bytes.length >= COMPRESS_MIN_BYTES && !INCOMPRESSIBLE.test(type)) { + bytes = await gzip(bytes); + encoding = 'gzip'; + } + const responseHeaders = {}; + answer.headers.forEach((value, name) => { + if (!DROPPED_RESPONSE_HEADERS.has(name)) responseHeaders[name] = value; + }); + const response = { + status: answer.status, + statusText: answer.statusText, + headers: responseHeaders, + encoding, + totalBytes: bytes.length, + }; + if (bytes.length > PANEL_PART_BYTES) { + response.id = crypto.randomUUID(); + held.set(response.id, { + path, + response, + bytes, + expires: Date.now() + HELD_MS, + }); + heldBytes += bytes.length; + } + return { + summary: `${answer.status} ${method} ${path}`, + data: part(response, bytes, 0), + }; + }, +}); diff --git a/src/tools/queries/panelRequest.test.mjs b/src/tools/queries/panelRequest.test.mjs new file mode 100644 index 000000000..b95613f8e --- /dev/null +++ b/src/tools/queries/panelRequest.test.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; +import { PANEL_PART_BYTES } from './panelRequest.js'; + +const fromBase64 = (text) => Buffer.from(text, 'base64'); +const gunzip = async (bytes) => + new Uint8Array( + await new Response( + new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')), + ).arrayBuffer(), + ); + +function catalogWith(handler) { + const requests = []; + const fetchImpl = async (path, init) => { + requests.push({ path, ...init }); + return handler(path, init); + }; + return { + requests, + catalog: composeCatalog({ + tools: coreTools, + services: { + app: { baseUrl: 'http://localhost:4173/', fetch: fetchImpl }, + }, + }), + }; +} + +test('panel_request is for the panel only', () => { + const { catalog } = catalogWith(() => new Response('x')); + assert.deepEqual(catalog.get('panel_request').ui, { visibility: ['app'] }); +}); + +test('a small response returns whole, compressed when that helps', async () => { + const text = 'body { color: red; }\n'.repeat(200); + const { catalog, requests } = catalogWith( + () => + new Response(text, { + status: 200, + headers: { 'content-type': 'text/css', 'set-cookie': 'a=b' }, + }), + ); + const { data } = await catalog.call('panel_request', { + path: '/panel/assets/style.css', + headers: { Accept: 'text/css', Cookie: 'secret', 'X-Other': '1' }, + }); + assert.deepEqual(requests[0].headers, { Accept: 'text/css' }); + assert.equal(data.status, 200); + assert.equal(data.encoding, 'gzip'); + assert.equal(data.nextOffset, undefined); + assert.equal(data.headers['content-type'], 'text/css'); + assert.equal(data.headers['set-cookie'], undefined); + const body = await gunzip(fromBase64(data.body)); + assert.equal(new TextDecoder().decode(body), text); +}); + +test('a large response comes in parts that join to the original', async () => { + const bytes = new Uint8Array(PANEL_PART_BYTES * 2 + 10); + for (let index = 0; index < bytes.length; index++) + bytes[index] = (index * 7919) % 251; + const { catalog } = catalogWith( + () => new Response(bytes, { headers: { 'content-type': 'image/png' } }), + ); + let { data } = await catalog.call('panel_request', { path: '/a.png' }); + assert.equal(data.encoding, 'identity'); + assert.equal(data.totalBytes, bytes.length); + const parts = [fromBase64(data.body)]; + while (data.nextOffset !== undefined) { + ({ data } = await catalog.call('panel_request', { + id: data.id, + offset: data.nextOffset, + })); + parts.push(fromBase64(data.body)); + } + assert.equal(parts.length, 3); + assert.deepEqual(new Uint8Array(Buffer.concat(parts)), bytes); +}); + +test('a request body and method pass through; settings and other sites do not', async () => { + const { catalog, requests } = catalogWith( + () => new Response('{}', { status: 201 }), + ); + const { data } = await catalog.call('panel_request', { + path: '/api/openai/hud-summary', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: Buffer.from('{"a":1}').toString('base64'), + }); + assert.equal(data.status, 201); + assert.equal(new TextDecoder().decode(requests[0].body), '{"a":1}'); + for (const path of [ + '/api/setup/status', + '/api/setup', + '//evil.example/x', + 'http://evil.example/', + ]) + await assert.rejects( + catalog.call('panel_request', { path }), + (error) => error.code === 'invalid_arguments', + path, + ); + await assert.rejects( + catalog.call('panel_request', { id: 'unknown' }), + (error) => error.code === 'invalid_arguments', + ); + assert.equal(requests.length, 1); +}); diff --git a/src/tools/services.js b/src/tools/services.js index 422207629..43517fb9e 100644 --- a/src/tools/services.js +++ b/src/tools/services.js @@ -3,7 +3,7 @@ * factories and the application request services. `fetchImpl` must resolve * the sources' relative `/api/...` paths; in a browser the page's own fetch * does, and elsewhere a caller supplies a resolving fetch. `appUrl` is the - * address the app is served from. + * address the app is served from; `app.fetch` requests the app's own paths. */ import { createAlprTileSource } from '../layers/alpr/source.js'; @@ -45,7 +45,7 @@ export function createToolServices({ fetchImpl, appUrl }) { throw new TypeError('A fetch implementation is required'); const requests = createApplicationRequestServices({ fetchImpl }); return { - app: { baseUrl: appUrl }, + app: { baseUrl: appUrl, fetch: fetchImpl }, earthquakes: createUsgsEarthquakeSource({ fetchImpl }), fires: createFirmsSource({ fetchImpl }), launches: createLaunchSource({ fetchImpl }), diff --git a/src/tools/surfaces.js b/src/tools/surfaces.js index 4d95ac540..7c8de61bc 100644 --- a/src/tools/surfaces.js +++ b/src/tools/surfaces.js @@ -16,6 +16,8 @@ const mcpOff = Object.freeze({ mcp: false }); export const TOOL_SURFACES = Object.freeze({ // Voice runs inside the app, so a link to it adds nothing. show_in_gods_eye_view: voiceOff, + // Only the God's Eye View panel calls this, to load the app. + panel_request: 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. From 62468c547f8fa3d7e7344a70048da694c632ca82 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 13:51:27 -0700 Subject: [PATCH 079/111] Fit the panel to Codex's and Claude's panel rules Images and stylesheet files load as data: URLs, which hosts allow where some refuse blob: images; the scene-asset source gets an https app address the panel serves, since Codex pages use their own scheme; and a panel whose 3D context the browser takes back says so. --- src/app/globePanelRuntime.js | 67 +++++++++++++++++++++++++++--------- src/standalone/tools.js | 6 +++- src/tools/globePanel.js | 7 ++++ 3 files changed, 63 insertions(+), 17 deletions(-) diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js index 5b150ab3d..708b29e01 100644 --- a/src/app/globePanelRuntime.js +++ b/src/app/globePanelRuntime.js @@ -130,9 +130,12 @@ export function panelRuntime(config) { }); } - // The app's own paths: relative ones, and absolute ones on this page's - // site, matched by scheme and host since a host may use its own scheme. + // The app's own paths: relative ones, absolute ones on this page's site + // (matched by scheme and host, since a host may use its own scheme), and + // ones under the app's address in the page, an https address for code + // that needs one (`config.appBaseUrl`). const page = new URL(document.baseURI); + const appOrigin = new URL(config.appBaseUrl).origin; function appPath(value) { let url; try { @@ -140,20 +143,33 @@ export function panelRuntime(config) { } catch { return null; } - if (url.protocol !== page.protocol || url.host !== page.host) return null; - return url.pathname + url.search; + const own = + url.origin === appOrigin || + (url.protocol === page.protocol && url.host === page.host); + return own ? url.pathname + url.search : null; } - const blobUrls = new Map(); - /** A blob: URL holding an app file, for elements that load by URL. */ + const fileUrls = new Map(); + /** + * A data: URL holding an app file, for elements that load by URL. Hosts + * allow data: images where some refuse blob: ones. + */ function appObjectUrl(path) { - if (!blobUrls.has(path)) - blobUrls.set( + if (!fileUrls.has(path)) + fileUrls.set( path, appFetch(path) .then((response) => response.blob()) - .then((blob) => URL.createObjectURL(blob)), + .then( + (blob) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }), + ), ); - return blobUrls.get(path); + return fileUrls.get(path); } function installRequests() { @@ -311,12 +327,15 @@ export function panelRuntime(config) { URL.createObjectURL(new Blob([BOOT], { type: 'text/javascript' })), options, ); - appObjectUrl(path).then((script) => - worker.postMessage({ - __gevScript: script, - module: options?.type === 'module', - }), - ); + appFetch(path) + .then((response) => response.blob()) + .then((blob) => URL.createObjectURL(blob)) + .then((script) => + worker.postMessage({ + __gevScript: script, + module: options?.type === 'module', + }), + ); return worker; }; window.Worker.prototype = NativeWorker.prototype; @@ -398,6 +417,7 @@ export function panelRuntime(config) { */ async function startApp(url) { window.GEV_EMBED_INLINE = true; + window.GEV_APP_BASE_URL = config.appBaseUrl; try { history.replaceState( null, @@ -452,6 +472,21 @@ export function panelRuntime(config) { })), ); for (const { type, text } of scripts) await runScript(text, type); + // Browsers cap live 3D contexts across a conversation's panels and take + // one back when there are too many; say so rather than show no map. + document.addEventListener( + 'webglcontextlost', + () => { + const notice = document.createElement('div'); + notice.id = 'status'; + notice.textContent = + "This panel lost its 3D graphics, likely because other God's Eye " + + "View panels in this conversation hold them. Use Open in God's " + + 'Eye View above, or show it in a new conversation.'; + document.body.appendChild(notice); + }, + { capture: true, once: true }, + ); } // Tool results: the first view starts the app, later ones move it. diff --git a/src/standalone/tools.js b/src/standalone/tools.js index d2e6043af..fb127d598 100644 --- a/src/standalone/tools.js +++ b/src/standalone/tools.js @@ -8,7 +8,11 @@ export function createStandaloneTools(options) { sceneDataPacks: { sources: { assets: createAssetDirectorySource({ - baseUrl: new URL('/scene-assets/', document.baseURI).href, + // A panel names the app's address; see src/tools/globePanel.js. + baseUrl: new URL( + '/scene-assets/', + globalThis.GEV_APP_BASE_URL ?? document.baseURI, + ).href, }), }, }, diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index a9f2cd9a3..2cdd1f219 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -41,6 +41,12 @@ export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app'; const MCP_APPS_PROTOCOL_VERSION = '2026-01-26'; const PANEL_HEIGHT_PX = 520; const LOAD_TIMEOUT_MS = 90_000; +/** + * The app's address inside the panel, for code that needs an https address + * (the page's own may use a host's scheme). The panel loads its paths + * through the MCP server; the name never resolves. + */ +const PANEL_APP_BASE_URL = 'https://app.gods-eye-view.invalid/'; /** The panel-only tool the panel loads the app through. */ const PANEL_REQUEST_TOOL = 'panel_request'; @@ -51,6 +57,7 @@ const PANEL_REQUEST_TOOL = 'panel_request'; */ function panelHtml(runtime) { const config = { + appBaseUrl: PANEL_APP_BASE_URL, loadTimeoutMs: LOAD_TIMEOUT_MS, panelBase: PANEL_BASE, panelHeight: PANEL_HEIGHT_PX, From 47742f335d365962186d453e561b7ac83dab42d9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 14:20:52 -0700 Subject: [PATCH 080/111] Keep an inline panel drawing when its host reports it hidden Claude reports a panel on screen as hidden, which stopped the app's render loop and the browser's animation frames, so no map was drawn. An inline panel no longer suspends rendering when hidden, and draws from a timer while animation frames stop arriving. --- src/app/embed.js | 41 +++++++++++++++++++++++++++++++++++++++- src/app/embed.test.mjs | 43 +++++++++++++++++++++++++++++++++++++++++- src/app/tools.js | 6 ++++-- 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/app/embed.js b/src/app/embed.js index 71566f54c..4ef83f7fc 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -45,6 +45,39 @@ export function isEmbeddedInline() { return globalThis.GEV_EMBED_INLINE === true; } +// A panel keeps drawing at about this rate when its host stops animation +// frames; see keepPanelRendering. +const PANEL_FRAME_MS = 33; +const MISSED_FRAMES_MS = 250; + +/** + * Keep the globe drawing in an inline panel. Some hosts report a panel on + * screen as hidden, which stops the browser's animation frames and with them + * Cesium's render loop; while frames stop arriving, draw from a timer. + * Returns a function that stops it. + */ +export function keepPanelRendering( + viewer, + { windowRef = globalThis.window, now = () => performance.now() } = {}, +) { + let lastFrame = now(); + let frameRequest = null; + const onFrame = () => { + lastFrame = now(); + frameRequest = windowRef.requestAnimationFrame(onFrame); + }; + frameRequest = windowRef.requestAnimationFrame(onFrame); + const timer = windowRef.setInterval(() => { + if (now() - lastFrame < MISSED_FRAMES_MS || viewer.isDestroyed?.()) return; + viewer.resize(); + viewer.render(); + }, PANEL_FRAME_MS); + return () => { + windowRef.clearInterval(timer); + windowRef.cancelAnimationFrame(frameRequest); + }; +} + const delay = (ms, signal) => new Promise((resolve) => { const timer = setTimeout(resolve, ms); @@ -169,6 +202,9 @@ export function installViews({ windowRef.document.body.classList.add('ui-embed'); shell.setCleanView?.(true); + const stopRendering = isEmbeddedInline() + ? keepPanelRendering(viewer, { windowRef }) + : () => {}; // A framing page talks to the app across frames; an inline panel shares // the page with it and talks through the page's own window. const peer = isEmbeddedInline() ? windowRef : windowRef.parent; @@ -209,5 +245,8 @@ export function installViews({ void ready.then(() => { if (!signal?.aborted) post({ type: EMBED_READY_MESSAGE }); }); - return () => windowRef.removeEventListener('message', onMessage); + return () => { + stopRendering(); + windowRef.removeEventListener('message', onMessage); + }; } diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index b2e9b3c3f..3010fdb56 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -1,6 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { applyView, installViews, isEmbedded } from './embed.js'; +import { + applyView, + installViews, + isEmbedded, + keepPanelRendering, +} from './embed.js'; import { createView } from '../view/index.js'; const fakeViewer = () => { @@ -159,6 +164,12 @@ test('an app loaded inline into a panel page talks through its own window', asyn windowRef.postMessage = (message) => posted.push(message); windowRef.document = { body: { classList: new Set() } }; windowRef.document.body.classList.add = Set.prototype.add; + Object.assign(windowRef, { + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + setInterval: () => 1, + clearInterval() {}, + }); globalThis.GEV_EMBED_INLINE = true; try { assert.equal(isEmbedded({ search: '' }), true); @@ -205,3 +216,33 @@ test('annotations in a link are drawn after it restores, embedded or not', async ['annotate_map', { annotations: [{ type: 'pin', target: 'Austin' }] }], ]); }); + +test('a panel keeps drawing from a timer while animation frames stop', () => { + let clock = 0; + let tick = null; + let frame = null; + const windowRef = { + requestAnimationFrame: (fn) => ((frame = fn), 1), + cancelAnimationFrame: () => (frame = null), + setInterval: (fn) => ((tick = fn), 1), + clearInterval: () => (tick = null), + }; + const drawn = []; + const viewer = { + resize: () => drawn.push('resize'), + render: () => drawn.push('render'), + }; + const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); + // Frames arrive: the render loop draws, so the timer does nothing. + clock = 100; + frame(); + tick(); + assert.deepEqual(drawn, []); + // Frames stop, as in a panel its host reports hidden: the timer draws. + clock = 400; + tick(); + assert.deepEqual(drawn, ['resize', 'render']); + stop(); + assert.equal(tick, null); + assert.equal(frame, null); +}); diff --git a/src/app/tools.js b/src/app/tools.js index 32cc3cf71..25d763e2d 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -4,7 +4,7 @@ import { initDrawTool } from '../annotations/drawTool.js'; import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; -import { installViews } from './embed.js'; +import { installViews, isEmbeddedInline } from './embed.js'; import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; import { installRenderGovernor, @@ -119,7 +119,9 @@ export function createApplicationTools({ // GPU. Holder/data state is untouched, so return is seamless: restore // the loop, refresh the one DOM surface we gated, render a frame. const syncVisibilitySuspension = () => { - const hidden = document.hidden; + // A panel's host may report it hidden while it is on screen; the panel + // keeps drawing itself (see keepPanelRendering in embed.js). + const hidden = document.hidden && !isEmbeddedInline(); viewer.useDefaultRenderLoop = !hidden; cockpitCloudEffects?.setSuspended?.(hidden); if (!hidden) { From 5ad0dd0004f1b9121774b8d155541e3b37c7ccbf Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 14:35:33 -0700 Subject: [PATCH 081/111] Report a panel's render errors in full and restart drawing Cesium stops drawing after a render error and prints worker failures as [object Object]. An inline panel now logs the error's details and starts drawing again, up to three times. --- src/app/embed.js | 42 +++++++++++++++++++++++++++++++++++++++++- src/app/embed.test.mjs | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/app/embed.js b/src/app/embed.js index 4ef83f7fc..0b2fd81a7 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -49,6 +49,19 @@ export function isEmbeddedInline() { // frames; see keepPanelRendering. const PANEL_FRAME_MS = 33; const MISSED_FRAMES_MS = 250; +const RENDER_RESTARTS = 3; +const RENDER_RESTART_MS = 1000; + +/** A render error's details, including the plain objects workers report. */ +function describeError(error) { + if (error instanceof Error) + return `${error.name}: ${error.message}\n${error.stack}`; + try { + return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {})); + } catch { + return String(error); + } +} /** * Keep the globe drawing in an inline panel. Some hosts report a panel on @@ -67,14 +80,41 @@ export function keepPanelRendering( frameRequest = windowRef.requestAnimationFrame(onFrame); }; frameRequest = windowRef.requestAnimationFrame(onFrame); + // Cesium stops drawing after a render error. Report it in full, and + // start again a few times in case it does not repeat. + let failed = false; + let restarts = 0; + let restart = null; + const removeErrorListener = viewer.scene?.renderError?.addEventListener( + (_scene, error) => { + failed = true; + console.error( + "[God's Eye View panel] render error:", + describeError(error), + ); + if (restarts >= RENDER_RESTARTS) return; + restarts += 1; + restart = windowRef.setTimeout(() => { + failed = false; + viewer.useDefaultRenderLoop = true; + }, RENDER_RESTART_MS); + }, + ); const timer = windowRef.setInterval(() => { - if (now() - lastFrame < MISSED_FRAMES_MS || viewer.isDestroyed?.()) return; + if ( + failed || + now() - lastFrame < MISSED_FRAMES_MS || + viewer.isDestroyed?.() + ) + return; viewer.resize(); viewer.render(); }, PANEL_FRAME_MS); return () => { windowRef.clearInterval(timer); + windowRef.clearTimeout?.(restart); windowRef.cancelAnimationFrame(frameRequest); + removeErrorListener?.(); }; } diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index 3010fdb56..f3792e448 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -246,3 +246,44 @@ test('a panel keeps drawing from a timer while animation frames stop', () => { assert.equal(tick, null); assert.equal(frame, null); }); + +test('a panel reports a render error in full and starts drawing again', () => { + let listener = null; + const timeouts = []; + const windowRef = { + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + setInterval: () => 1, + clearInterval() {}, + setTimeout: (fn) => (timeouts.push(fn), timeouts.length), + clearTimeout() {}, + }; + const viewer = { + useDefaultRenderLoop: false, + scene: { + renderError: { + addEventListener: (fn) => ((listener = fn), () => (listener = null)), + }, + }, + }; + const logged = []; + const original = console.error; + console.error = (...args) => logged.push(args.join(' ')); + try { + const stop = keepPanelRendering(viewer, { windowRef, now: () => 0 }); + for (let attempt = 0; attempt < 5; attempt++) + listener(viewer.scene, { + name: 'RuntimeError', + message: 'worker failed', + }); + assert.match(logged[0], /render error: .*"message":"worker failed"/); + // Only a few restarts, so an error that repeats does not spin. + assert.equal(timeouts.length, 3); + timeouts[0](); + assert.equal(viewer.useDefaultRenderLoop, true); + stop(); + assert.equal(listener, null); + } finally { + console.error = original; + } +}); From dbbdef0f7899bad7a011bd6c4e7dc260f2997516 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 14:49:45 -0700 Subject: [PATCH 082/111] Offer fullscreen in the panel, and hold images in inserted HTML Where the host offers fullscreen, an Expand button asks for it; the page fills the room it is given. Images in markup the app inserts as HTML text now wait for their files instead of requesting the panel host's site, which ended the mic.svg and Cesium credit 404s. --- src/app/globePanelRuntime.js | 58 ++++++++++++++++++++++++++++++++++- src/tools/globePanel.js | 14 ++++++--- src/tools/globePanel.test.mjs | 3 ++ 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js index 708b29e01..879630c19 100644 --- a/src/app/globePanelRuntime.js +++ b/src/app/globePanelRuntime.js @@ -13,6 +13,7 @@ export function panelRuntime(config) { const status = document.getElementById('status'); const open = document.getElementById('open'); + const expand = document.getElementById('expand'); const say = (text) => { if (status.isConnected) status.textContent = text; }; @@ -340,6 +341,40 @@ export function panelRuntime(config) { }; window.Worker.prototype = NativeWorker.prototype; + // Markup the app adds as HTML text would request its images from this + // page's site at once; hold their addresses until the files are here. + const htmlWithHeldImages = (html) => + String(html).replace( + /(<(?:img|source)\b[^>]*?\s)src=(["'])([^"']*)\2/gi, + (tag, before, quote, value) => + appPath(value) === null + ? tag + : `${before}data-gev-src=${quote}${value}${quote}`, + ); + const fillHeldImages = (root) => { + for (const element of root?.querySelectorAll?.('[data-gev-src]') ?? []) { + const path = appPath(element.getAttribute('data-gev-src')); + element.removeAttribute('data-gev-src'); + appObjectUrl(path).then((url) => element.setAttribute('src', url)); + } + }; + const innerHtml = Object.getOwnPropertyDescriptor( + Element.prototype, + 'innerHTML', + ); + Object.defineProperty(Element.prototype, 'innerHTML', { + ...innerHtml, + set(value) { + innerHtml.set.call(this, htmlWithHeldImages(value)); + fillHeldImages(this); + }, + }); + const insertHtml = Element.prototype.insertAdjacentHTML; + Element.prototype.insertAdjacentHTML = function (position, html) { + insertHtml.call(this, position, htmlWithHeldImages(html)); + fillHeldImages(this.parentNode ?? this); + }; + // Markup and styles the app adds later name app files too. new MutationObserver((records) => { for (const record of records) @@ -545,6 +580,26 @@ export function panelRuntime(config) { return; } if (message.method === 'ui/notifications/tool-result') show(message.params); + if (message.method === 'ui/notifications/host-context-changed') + showDisplayMode(message.params); + }); + + // Fullscreen, where the host offers it: the globe gets the room, and some + // hosts only keep a panel on screen up to date outside the conversation. + let displayMode = 'inline'; + const showDisplayMode = (context) => { + if (context?.displayMode) displayMode = context.displayMode; + if (context?.availableDisplayModes) + expand.hidden = !context.availableDisplayModes.includes('fullscreen'); + expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand'; + }; + expand.addEventListener('click', () => { + request('ui/request-display-mode', { + mode: displayMode === 'fullscreen' ? 'inline' : 'fullscreen', + }).then( + (result) => showDisplayMode({ displayMode: result?.mode }), + () => {}, + ); }); open.addEventListener('click', () => { @@ -565,7 +620,8 @@ export function panelRuntime(config) { }, protocolVersion: config.protocolVersion, }).then( - () => { + (result) => { + showDisplayMode(result?.hostContext); send({ method: 'ui/notifications/initialized' }); notify('ui/notifications/size-changed', { width: document.body.clientWidth, diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index 2cdd1f219..04badb390 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -75,18 +75,22 @@ function panelHtml(runtime) { God's Eye View
Waiting for a view…
+
+ +
diff --git a/src/tools/globePanel.test.mjs b/src/tools/globePanel.test.mjs index bc7504081..ea67bb6e3 100644 --- a/src/tools/globePanel.test.mjs +++ b/src/tools/globePanel.test.mjs @@ -31,6 +31,9 @@ test('the globe panel is an MCP Apps resource that loads the app through its ser // The MCP Apps SDK's initialize parameters; hosts reject anything else. assert.match(resource.text, /appInfo: \{/); assert.doesNotMatch(resource.text, /clientInfo/); + // Fullscreen where the host offers it. + assert.match(resource.text, /'ui\/request-display-mode'/); + assert.match(resource.text, /