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