diff --git a/.husky/pre-push b/.husky/pre-push index 1cb7b1ef8..09354cdc7 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,3 +1,4 @@ +#!/usr/bin/env bash # Ensure dependencies are installed (worktrees start with no node_modules) if [ ! -d node_modules ]; then echo "node_modules missing, running npm install..." @@ -65,14 +66,13 @@ echo "Running architectural boundary check..." npm run lint:boundaries || exit 1 echo "Running edge function bundle check..." -for f in api/*.js; do - case "$(basename "$f")" in _*) continue;; esac +while IFS= read -r f; do npx esbuild "$f" --bundle --format=esm --platform=browser --outfile=/dev/null 2>/dev/null || { echo "ERROR: esbuild failed to bundle $f — this will break Vercel deployment" npx esbuild "$f" --bundle --format=esm --platform=browser --outfile=/dev/null exit 1 } -done +done < <(find api/ -name "*.js" -not -name "_*"; find api/ -maxdepth 1 -name "*.ts" -not -name "_*") echo "Running unit tests..." npm run test:data || exit 1 diff --git a/api/mcp.ts b/api/mcp.ts index 4de3c8d71..6747b2787 100644 --- a/api/mcp.ts +++ b/api/mcp.ts @@ -10,6 +10,7 @@ import { readJsonFromUpstash } from './_upstash-json.js'; import { resolveApiKeyFromBearer } from './_oauth-token.js'; // @ts-expect-error — JS module, no declaration file import { timingSafeIncludes } from './_crypto.js'; +import COUNTRY_BBOXES from '../shared/country-bboxes.js'; export const config = { runtime: 'edge' }; @@ -306,16 +307,187 @@ const TOOL_REGISTRY: ToolDef[] = [ required: ['country_code'], }, _execute: async (params, base, apiKey) => { - const res = await fetch(`${base}/api/intelligence/v1/get-country-intel-brief`, { + const UA = 'worldmonitor-mcp-edge/1.0'; + const countryCode = String(params.country_code ?? '').toUpperCase().slice(0, 2); + + // Fetch current geopolitical headlines to ground the LLM (budget: 2 s — cached endpoint). + // Without context the model hallucinates events — real headlines anchor it. + // 2 s + 22 s brief = 24 s worst-case; 6 s margin before the 30 s Edge kill. + let contextParam = ''; + try { + const digestRes = await fetch(`${base}/api/news/v1/list-feed-digest?variant=geo&lang=en`, { + headers: { 'X-WorldMonitor-Key': apiKey, 'User-Agent': UA }, + signal: AbortSignal.timeout(2_000), + }); + if (digestRes.ok) { + type DigestPayload = { categories?: Record }; + const digest = await digestRes.json() as DigestPayload; + const headlines = Object.values(digest.categories ?? {}) + .flatMap(cat => cat.items ?? []) + .map(item => item.title ?? '') + .filter(Boolean) + .slice(0, 15) + .join('\n'); + if (headlines) contextParam = encodeURIComponent(headlines.slice(0, 4000)); + } + } catch { /* proceed without context — better than failing */ } + + const briefUrl = contextParam + ? `${base}/api/intelligence/v1/get-country-intel-brief?context=${contextParam}` + : `${base}/api/intelligence/v1/get-country-intel-brief`; + + const res = await fetch(briefUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-WorldMonitor-Key': apiKey, 'User-Agent': 'worldmonitor-mcp-edge/1.0' }, - body: JSON.stringify({ country_code: String(params.country_code ?? ''), framework: String(params.framework ?? '') }), - signal: AbortSignal.timeout(25_000), + headers: { 'Content-Type': 'application/json', 'X-WorldMonitor-Key': apiKey, 'User-Agent': UA }, + body: JSON.stringify({ country_code: countryCode, framework: String(params.framework ?? '') }), + signal: AbortSignal.timeout(22_000), }); if (!res.ok) throw new Error(`get-country-intel-brief HTTP ${res.status}`); return res.json(); }, }, + { + name: 'get_airspace', + description: 'Live ADS-B aircraft over a country. Returns civilian flights (OpenSky) and identified military aircraft with callsigns, positions, altitudes, and headings. Answers questions like "how many planes are over the UAE right now?" or "are there military aircraft over Taiwan?"', + inputSchema: { + type: 'object', + properties: { + country_code: { + type: 'string', + description: 'ISO 3166-1 alpha-2 country code (e.g. "AE", "US", "GB", "JP")', + }, + type: { + type: 'string', + enum: ['all', 'civilian', 'military'], + description: 'Filter: all flights (default), civilian only, or military only', + }, + }, + required: ['country_code'], + }, + _execute: async (params, base, apiKey) => { + const code = String(params.country_code ?? '').toUpperCase().slice(0, 2); + const bbox = COUNTRY_BBOXES[code]; + if (!bbox) return { error: `Unknown country code: ${code}. Use ISO 3166-1 alpha-2 (e.g. "AE", "US", "GB").` }; + const [sw_lat, sw_lon, ne_lat, ne_lon] = bbox; + const type = String(params.type ?? 'all'); + const UA = 'worldmonitor-mcp-edge/1.0'; + const headers = { 'X-WorldMonitor-Key': apiKey, 'User-Agent': UA }; + const bboxQ = `sw_lat=${sw_lat}&sw_lon=${sw_lon}&ne_lat=${ne_lat}&ne_lon=${ne_lon}`; + + type CivilianResp = { + positions?: { callsign: string; icao24: string; lat: number; lon: number; altitude_m: number; ground_speed_kts: number; track_deg: number; on_ground: boolean }[]; + source?: string; + updated_at?: number; + }; + type MilResp = { + flights?: { callsign: string; hex_code: string; aircraft_type: string; aircraft_model: string; operator: string; operator_country: string; location?: { latitude: number; longitude: number }; altitude: number; heading: number; speed: number; is_interesting: boolean; note: string }[]; + }; + + const [civResult, milResult] = await Promise.allSettled([ + type === 'military' + ? Promise.resolve(null) + : fetch(`${base}/api/aviation/v1/track-aircraft?${bboxQ}`, { headers, signal: AbortSignal.timeout(8_000) }) + .then(r => r.ok ? r.json() as Promise : Promise.reject(new Error(`HTTP ${r.status}`))), + type === 'civilian' + ? Promise.resolve(null) + : fetch(`${base}/api/military/v1/list-military-flights?${bboxQ}&page_size=100`, { headers, signal: AbortSignal.timeout(8_000) }) + .then(r => r.ok ? r.json() as Promise : Promise.reject(new Error(`HTTP ${r.status}`))), + ]); + + const civOk = type === 'military' || civResult.status === 'fulfilled'; + const milOk = type === 'civilian' || milResult.status === 'fulfilled'; + + // Both sources down — total outage, don't return misleading empty data + if (!civOk && !milOk) throw new Error('Airspace data unavailable: both civilian and military sources failed'); + + const civ = civResult.status === 'fulfilled' ? civResult.value : null; + const mil = milResult.status === 'fulfilled' ? milResult.value : null; + const warnings: string[] = []; + if (!civOk) warnings.push('civilian ADS-B data unavailable'); + if (!milOk) warnings.push('military flight data unavailable'); + + const civilianFlights = (civ?.positions ?? []).slice(0, 100).map(p => ({ + callsign: p.callsign, icao24: p.icao24, + lat: p.lat, lon: p.lon, + altitude_m: p.altitude_m, speed_kts: p.ground_speed_kts, + heading_deg: p.track_deg, on_ground: p.on_ground, + })); + const militaryFlights = (mil?.flights ?? []).slice(0, 100).map(f => ({ + callsign: f.callsign, hex_code: f.hex_code, + aircraft_type: f.aircraft_type, aircraft_model: f.aircraft_model, + operator: f.operator, operator_country: f.operator_country, + lat: f.location?.latitude, lon: f.location?.longitude, + altitude: f.altitude, heading: f.heading, speed: f.speed, + is_interesting: f.is_interesting, ...(f.note ? { note: f.note } : {}), + })); + + return { + country_code: code, + bounding_box: { sw_lat, sw_lon, ne_lat, ne_lon }, + civilian_count: civilianFlights.length, + military_count: militaryFlights.length, + ...(type !== 'military' && { civilian_flights: civilianFlights }), + ...(type !== 'civilian' && { military_flights: militaryFlights }), + ...(warnings.length > 0 && { partial: true, warnings }), + source: civ?.source ?? 'opensky', + updated_at: civ?.updated_at ? new Date(civ.updated_at).toISOString() : new Date().toISOString(), + }; + }, + }, + { + name: 'get_maritime_activity', + description: "Live vessel traffic and maritime disruptions for a country's waters. Returns AIS density zones (ships-per-day, intensity score), dark ship events, and chokepoint congestion from AIS tracking.", + inputSchema: { + type: 'object', + properties: { + country_code: { + type: 'string', + description: 'ISO 3166-1 alpha-2 country code (e.g. "AE", "SA", "JP", "EG")', + }, + }, + required: ['country_code'], + }, + _execute: async (params, base, apiKey) => { + const code = String(params.country_code ?? '').toUpperCase().slice(0, 2); + const bbox = COUNTRY_BBOXES[code]; + if (!bbox) return { error: `Unknown country code: ${code}. Use ISO 3166-1 alpha-2 (e.g. "AE", "SA", "JP").` }; + const [sw_lat, sw_lon, ne_lat, ne_lon] = bbox; + const bboxQ = `sw_lat=${sw_lat}&sw_lon=${sw_lon}&ne_lat=${ne_lat}&ne_lon=${ne_lon}`; + const headers = { 'X-WorldMonitor-Key': apiKey, 'User-Agent': 'worldmonitor-mcp-edge/1.0' }; + + type VesselResp = { + snapshot?: { + snapshot_at?: number; + density_zones?: { name: string; intensity: number; ships_per_day: number; delta_pct: number; note: string }[]; + disruptions?: { name: string; type: string; severity: string; dark_ships: number; vessel_count: number; region: string; description: string }[]; + }; + }; + + const res = await fetch(`${base}/api/maritime/v1/get-vessel-snapshot?${bboxQ}`, { + headers, signal: AbortSignal.timeout(8_000), + }); + if (!res.ok) throw new Error(`get-vessel-snapshot HTTP ${res.status}`); + const data = await res.json() as VesselResp; + const snap = data.snapshot ?? {}; + + return { + country_code: code, + bounding_box: { sw_lat, sw_lon, ne_lat, ne_lon }, + snapshot_at: snap.snapshot_at ? new Date(snap.snapshot_at).toISOString() : new Date().toISOString(), + total_zones: (snap.density_zones ?? []).length, + total_disruptions: (snap.disruptions ?? []).length, + density_zones: (snap.density_zones ?? []).map(z => ({ + name: z.name, intensity: z.intensity, ships_per_day: z.ships_per_day, + delta_pct: z.delta_pct, ...(z.note ? { note: z.note } : {}), + })), + disruptions: (snap.disruptions ?? []).map(d => ({ + name: d.name, type: d.type, severity: d.severity, + dark_ships: d.dark_ships, vessel_count: d.vessel_count, + region: d.region, description: d.description, + })), + }; + }, + }, { name: 'analyze_situation', description: 'AI geopolitical situation analysis (DeductionPanel). Provide a query and optional geo-political context; returns an LLM-powered analytical deduction with confidence and supporting signals.', diff --git a/api/oauth/authorize.js b/api/oauth/authorize.js index 9483db2ef..f310b4785 100644 --- a/api/oauth/authorize.js +++ b/api/oauth/authorize.js @@ -87,9 +87,9 @@ const GLOBE_SVG = 'Error — WorldMonitor + return new Response(`Error — WorldMonitor MCP - +

${escapeHtml(title)}

${escapeHtml(detail)}

← go back
`, { status: 400, headers: PAGE_HEADERS }); @@ -100,7 +100,7 @@ function consentPage(params, nonce, errorMsg = '') { const redirectHost = new URL(redirect_uri).hostname; return new Response(` -Authorize — WorldMonitor +Authorize — WorldMonitor MCP - +
${escapeHtml(client_name)} wants access
diff --git a/docs/plans/2026-03-28-002-feat-mcp-live-airspace-maritime-tools-plan.md b/docs/plans/2026-03-28-002-feat-mcp-live-airspace-maritime-tools-plan.md new file mode 100644 index 000000000..2216afe30 --- /dev/null +++ b/docs/plans/2026-03-28-002-feat-mcp-live-airspace-maritime-tools-plan.md @@ -0,0 +1,325 @@ +--- +title: "feat: MCP live airspace + maritime query tools" +type: feat +status: active +date: 2026-03-28 +--- + +# MCP Live Airspace & Maritime Query Tools + +## Overview + +Add two new tools to the MCP endpoint that let AI assistants query **live** ADS-B flight data and AIS vessel activity for any country. Answering "how many planes are over the UAE right now?" or "what vessels are near the Strait of Hormuz?" will go from impossible to a single tool call. + +## Problem Statement + +The current MCP tool set only returns cached/seeded aggregate data (delays, disruptions, snapshots cached minutes-to-hours ago). There is no way to answer real-time positional queries like: + +- "How many civilian planes are over Saudi Arabia right now?" +- "Are there military aircraft over Taiwan today?" +- "What's the vessel traffic density in the Persian Gulf?" + +The underlying APIs already exist and accept bounding-box queries: + +- `GET /api/aviation/v1/track-aircraft` — live ADS-B positions from OpenSky +- `GET /api/military/v1/list-military-flights` — military aircraft from OpenSky via callsign/hex identification +- `GET /api/maritime/v1/get-vessel-snapshot` — AIS density zones and disruptions + +What's missing: a country-code → bounding-box mapping and MCP tool wrappers. + +## Proposed Solution + +1. **Generate `shared/country-bboxes.json`** — a static 5KB lookup table (169 countries) derived from the existing `public/data/countries.geojson`, keyed by ISO2 code → `[sw_lat, sw_lon, ne_lat, ne_lon]`. +2. **Add `get_airspace` MCP tool** — accepts `country_code` + optional `type` filter (all/civilian/military); calls the two aviation RPCs in parallel; returns merged counts + flight lists. +3. **Add `get_maritime_activity` MCP tool** — accepts `country_code`; calls the vessel snapshot RPC; returns density zones + disruption summary. + +No new server-side code, no new protos, no buf generate step. The existing RPCs already support bbox queries. + +## Files + +### Create + +- `scripts/generate-country-bboxes.cjs` — one-time generator (reads GeoJSON, writes JSON) +- `shared/country-bboxes.json` — generated static bbox map (auto-synced by existing test) +- `scripts/shared/country-bboxes.json` — required sync copy (test `scripts/shared/ stays in sync with shared/`) + +### Modify + +- `api/mcp.ts` — import bbox map, add `get_airspace` and `get_maritime_activity` tools + +## Implementation + +### Step 1 — Generate bbox table + +**`scripts/generate-country-bboxes.cjs`:** +```js +'use strict'; +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const geojson = JSON.parse(fs.readFileSync(path.join(root, 'public/data/countries.geojson'), 'utf8')); + +function coordsFromGeom(geom) { + if (!geom) return []; + if (geom.type === 'Polygon') return geom.coordinates.flat(1); + if (geom.type === 'MultiPolygon') return geom.coordinates.flat(2); + return []; +} + +const result = {}; +for (const f of geojson.features) { + const iso2 = f.properties['ISO3166-1-Alpha-2']; + if (!iso2 || !f.geometry) continue; + const coords = coordsFromGeom(f.geometry); + if (!coords.length) continue; + let minLat=Infinity, maxLat=-Infinity, minLon=Infinity, maxLon=-Infinity; + for (const [lon, lat] of coords) { + if (lat < minLat) minLat = lat; if (lat > maxLat) maxLat = lat; + if (lon < minLon) minLon = lon; if (lon > maxLon) maxLon = lon; + } + // [sw_lat, sw_lon, ne_lat, ne_lon] — rounded to 2dp to keep file compact + result[iso2] = [+(minLat.toFixed(2)), +(minLon.toFixed(2)), +(maxLat.toFixed(2)), +(maxLon.toFixed(2))]; +} + +const out = path.join(root, 'shared', 'country-bboxes.json'); +fs.writeFileSync(out, JSON.stringify(result, null, 2) + '\n'); +console.log(`Wrote ${Object.keys(result).length} entries to ${out}`); +``` + +Run: `node scripts/generate-country-bboxes.cjs` +Then: `cp shared/country-bboxes.json scripts/shared/country-bboxes.json` + +### Step 2 — `get_airspace` tool in `api/mcp.ts` + +Import at top of file: +```typescript +import COUNTRY_BBOXES from '../shared/country-bboxes.json' assert { type: 'json' }; +``` + +Or via `createRequire` if the assert syntax isn't supported in the build target. Check existing imports in mcp.ts. + +Tool definition (add after `get_country_brief`): +```typescript +{ + name: 'get_airspace', + description: 'Live ADS-B aircraft over a country. Returns civilian flights (OpenSky) and identified military aircraft with callsigns, positions, altitudes, headings. Answers questions like "how many planes are over the UAE right now?" or "are there military aircraft over Taiwan?"', + inputSchema: { + type: 'object', + properties: { + country_code: { + type: 'string', + description: 'ISO 3166-1 alpha-2 country code (e.g. "AE", "US", "GB", "JP")', + }, + type: { + type: 'string', + enum: ['all', 'civilian', 'military'], + description: 'Filter: all flights (default), civilian only, or military only', + }, + }, + required: ['country_code'], + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + _execute: async (params, base, apiKey) => { + const code = String(params.country_code ?? '').toUpperCase().slice(0, 2); + const bbox = (COUNTRY_BBOXES as Record)[code]; + if (!bbox) return { error: `Unknown country code: ${code}. Use ISO 3166-1 alpha-2.` }; + const [sw_lat, sw_lon, ne_lat, ne_lon] = bbox; + const type = String(params.type ?? 'all'); + const UA = 'worldmonitor-mcp-edge/1.0'; + const headers = { 'X-WorldMonitor-Key': apiKey, 'User-Agent': UA }; + const bboxQ = `sw_lat=${sw_lat}&sw_lon=${sw_lon}&ne_lat=${ne_lat}&ne_lon=${ne_lon}`; + + type CivilianResp = { positions?: { callsign:string; icao24:string; lat:number; lon:number; altitude_m:number; ground_speed_kts:number; track_deg:number; on_ground:boolean }[]; source?: string; updated_at?: number }; + type MilResp = { flights?: { callsign:string; hex_code:string; aircraft_type:string; aircraft_model:string; operator:string; operator_country:string; location?:{latitude:number;longitude:number}; altitude:number; heading:number; speed:number; is_interesting:boolean; note:string }[]; pagination?:{total_count?:number} }; + + const [civResult, milResult] = await Promise.allSettled([ + type === 'military' ? Promise.resolve(null) : + fetch(`${base}/api/aviation/v1/track-aircraft?${bboxQ}`, { headers, signal: AbortSignal.timeout(8_000) }) + .then(r => r.ok ? r.json() as Promise : null), + type === 'civilian' ? Promise.resolve(null) : + fetch(`${base}/api/military/v1/list-military-flights?${bboxQ}&page_size=100`, { headers, signal: AbortSignal.timeout(8_000) }) + .then(r => r.ok ? r.json() as Promise : null), + ]); + + const civ = civResult.status === 'fulfilled' ? civResult.value : null; + const mil = milResult.status === 'fulfilled' ? milResult.value : null; + + const civilianFlights = (civ?.positions ?? []).slice(0, 100).map(p => ({ + callsign: p.callsign, icao24: p.icao24, + lat: p.lat, lon: p.lon, + altitude_m: p.altitude_m, speed_kts: p.ground_speed_kts, + heading_deg: p.track_deg, on_ground: p.on_ground, + })); + const militaryFlights = (mil?.flights ?? []).slice(0, 100).map(f => ({ + callsign: f.callsign, hex_code: f.hex_code, + aircraft_type: f.aircraft_type, aircraft_model: f.aircraft_model, + operator: f.operator, operator_country: f.operator_country, + lat: f.location?.latitude, lon: f.location?.longitude, + altitude: f.altitude, heading: f.heading, speed: f.speed, + is_interesting: f.is_interesting, note: f.note || undefined, + })); + + return { + country_code: code, + bounding_box: { sw_lat, sw_lon, ne_lat, ne_lon }, + civilian_count: civilianFlights.length, + military_count: militaryFlights.length, + ...(type !== 'military' && { civilian_flights: civilianFlights }), + ...(type !== 'civilian' && { military_flights: militaryFlights }), + source: civ?.source ?? 'opensky', + updated_at: civ?.updated_at ? new Date(civ.updated_at).toISOString() : new Date().toISOString(), + }; + }, +}, +``` + +### Step 3 — `get_maritime_activity` tool + +```typescript +{ + name: 'get_maritime_activity', + description: "Live vessel traffic and maritime disruptions for a country's waters. Returns AIS density zones (ships-per-day, intensity score), dark ship events, and chokepoint congestion. Covers major shipping lanes adjacent to the country.", + inputSchema: { + type: 'object', + properties: { + country_code: { + type: 'string', + description: 'ISO 3166-1 alpha-2 country code (e.g. "AE", "SA", "JP", "EG")', + }, + }, + required: ['country_code'], + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + _execute: async (params, base, apiKey) => { + const code = String(params.country_code ?? '').toUpperCase().slice(0, 2); + const bbox = (COUNTRY_BBOXES as Record)[code]; + if (!bbox) return { error: `Unknown country code: ${code}. Use ISO 3166-1 alpha-2.` }; + const [sw_lat, sw_lon, ne_lat, ne_lon] = bbox; + const bboxQ = `sw_lat=${sw_lat}&sw_lon=${sw_lon}&ne_lat=${ne_lat}&ne_lon=${ne_lon}`; + const headers = { 'X-WorldMonitor-Key': apiKey, 'User-Agent': 'worldmonitor-mcp-edge/1.0' }; + + type VesselResp = { snapshot?: { snapshot_at?: number; density_zones?: {name:string;intensity:number;ships_per_day:number;delta_pct:number;note:string}[]; disruptions?: {name:string;type:string;severity:string;dark_ships:number;vessel_count:number;region:string;description:string}[] } }; + + const res = await fetch(`${base}/api/maritime/v1/get-vessel-snapshot?${bboxQ}`, { + headers, signal: AbortSignal.timeout(8_000), + }); + if (!res.ok) throw new Error(`get-vessel-snapshot HTTP ${res.status}`); + const data = await res.json() as VesselResp; + const snap = data.snapshot ?? {}; + + return { + country_code: code, + bounding_box: { sw_lat, sw_lon, ne_lat, ne_lon }, + snapshot_at: snap.snapshot_at ? new Date(snap.snapshot_at).toISOString() : new Date().toISOString(), + total_zones: (snap.density_zones ?? []).length, + total_disruptions: (snap.disruptions ?? []).length, + density_zones: (snap.density_zones ?? []).map(z => ({ + name: z.name, intensity: z.intensity, ships_per_day: z.ships_per_day, + delta_pct: z.delta_pct, note: z.note || undefined, + })), + disruptions: (snap.disruptions ?? []).map(d => ({ + name: d.name, type: d.type, severity: d.severity, + dark_ships: d.dark_ships, vessel_count: d.vessel_count, + region: d.region, description: d.description, + })), + }; + }, +}, +``` + +### Step 4 — JSON import in mcp.ts + +The MCP file uses `import` ESM syntax. The `country-bboxes.json` import must be compatible with Vercel edge bundling. Check how other JSON is currently imported — if there are no existing JSON imports, use the inline-require pattern to avoid bundler issues: + +```typescript +// At top of _execute, not module-level: +// If JSON import assertions aren't supported, embed as a const inline +// OR import using createRequire pattern +``` + +**Safest pattern for Vercel edge:** +Import the JSON inline as a TypeScript `const`: +```typescript +// shared/country-bboxes.json is committed to the repo +// Import via TS resolveJsonModule +import COUNTRY_BBOXES from '../shared/country-bboxes.json'; +``` +Ensure `tsconfig.api.json` has `"resolveJsonModule": true`. Check and add if missing. + +## Sample Responses + +### get_airspace example + +```json +{ + "country_code": "AE", + "bounding_box": { "sw_lat": 22.62, "sw_lon": 51.57, "ne_lat": 26.06, "ne_lon": 56.38 }, + "civilian_count": 47, + "military_count": 3, + "civilian_flights": [ + { "callsign": "UAE123", "icao24": "894ab2", "lat": 24.5, "lon": 54.3, "altitude_m": 11000, "speed_kts": 480, "heading_deg": 270, "on_ground": false } + ], + "military_flights": [ + { "callsign": "UAE-AF1", "aircraft_type": "TRANSPORT", "lat": 24.4, "lon": 54.6, "altitude": 5000, "heading": 180 } + ], + "source": "opensky", + "updated_at": "2026-03-28T10:15:00Z" +} +``` + +### get_maritime_activity example + +```json +{ + "country_code": "AE", + "bounding_box": { "sw_lat": 22.62, "sw_lon": 51.57, "ne_lat": 26.06, "ne_lon": 56.38 }, + "snapshot_at": "2026-03-28T10:15:00Z", + "total_zones": 2, + "total_disruptions": 1, + "density_zones": [ + { "name": "Strait of Hormuz", "intensity": 82, "ships_per_day": 45, "delta_pct": 3.2 } + ], + "disruptions": [ + { "name": "Gulf AIS Gap", "type": "AIS_DISRUPTION_TYPE_GAP_SPIKE", "severity": "AIS_DISRUPTION_SEVERITY_ELEVATED", "dark_ships": 3, "vessel_count": 12, "region": "Persian Gulf", "description": "..." } + ] +} +``` + +## Edge Cases + +| Case | Handling | +|------|----------| +| Unknown country code | Return `{ error: "Unknown country code: XX" }` | +| API timeout/failure | `get_airspace`: parallel `Promise.allSettled` — partial data returned; `get_maritime_activity`: throw to MCP error handler | +| No flights found | Return empty arrays + count 0 | +| Large countries (RU, US, CA) | bbox spans wide area, results capped at 100 per category | +| Antimeridian crossing (RU, US-AK, NZ) | OpenSky handles this correctly with min/max lon | +| Country code not in bbox map (small territories) | ~89 features have null geometry in GeoJSON; return error with suggestion | + +## Verification + +1. `npm run typecheck:api` — no TS errors +2. `node --test tests/edge-functions.test.mjs` — 130+ tests pass (bbox sync test auto-added) +3. Manual test: + ```bash + # Test airspace + curl "https://api.worldmonitor.app/mcp" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_airspace","arguments":{"country_code":"AE"}}}' + + # Test maritime + curl "https://api.worldmonitor.app/mcp" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_maritime_activity","arguments":{"country_code":"AE"}}}' + ``` +4. Via claude.ai Connectors: ask "How many planes are over the UAE right now?" — should return live count from `get_airspace` + +## What's NOT in scope + +- Vessel position lists (no vessel-by-vessel coords from current snapshot API) +- Historical queries (no time-travel support) +- Port-level maritime detail (handled by dedicated maritime panels in UI) +- Vessels by specific country flag (not filterable by flag state in current API) diff --git a/scripts/generate-country-bboxes.cjs b/scripts/generate-country-bboxes.cjs new file mode 100644 index 000000000..076e1dddc --- /dev/null +++ b/scripts/generate-country-bboxes.cjs @@ -0,0 +1,65 @@ +'use strict'; +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const geojson = JSON.parse(fs.readFileSync(path.join(root, 'public', 'data', 'countries.geojson'), 'utf8')); + +function coordsFromGeom(geom) { + if (!geom) return []; + if (geom.type === 'Polygon') return geom.coordinates.flat(1); + if (geom.type === 'MultiPolygon') return geom.coordinates.flat(2); + return []; +} + +const result = {}; +for (const f of geojson.features) { + const iso2 = f.properties['ISO3166-1-Alpha-2']; + // Skip entries with non-standard ISO codes (e.g. "-99" for disputed/unassigned territories) + if (!iso2 || !f.geometry || !/^[A-Z]{2}$/.test(iso2)) continue; + const coords = coordsFromGeom(f.geometry); + if (!coords.length) continue; + let minLat = Infinity, maxLat = -Infinity, minLon = Infinity, maxLon = -Infinity; + for (const [lon, lat] of coords) { + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + if (lon < minLon) minLon = lon; + if (lon > maxLon) maxLon = lon; + } + // [sw_lat, sw_lon, ne_lat, ne_lon] — 2dp precision keeps file compact + result[iso2] = [+(minLat.toFixed(2)), +(minLon.toFixed(2)), +(maxLat.toFixed(2)), +(maxLon.toFixed(2))]; +} + +// Sort keys for stable diffs +const sorted = Object.fromEntries(Object.entries(result).sort(([a], [b]) => a.localeCompare(b))); + +const out = path.join(root, 'shared', 'country-bboxes.json'); +fs.writeFileSync(out, JSON.stringify(sorted, null, 2) + '\n'); +console.log(`Wrote ${Object.keys(sorted).length} entries to ${out}`); + +// Generate a plain .js ESM module — no TS syntax so Vercel edge bundler accepts it. +// Paired with a .d.ts declaration so api/mcp.ts gets proper tuple types at typecheck. +// Import in api/mcp.ts as '../shared/country-bboxes.js'. +const jsLines = [ + '// Auto-generated by scripts/generate-country-bboxes.cjs — do not edit manually', + '// To regenerate: node scripts/generate-country-bboxes.cjs', + 'const COUNTRY_BBOXES = {', + ...Object.entries(sorted).map(([k, v]) => ` ${JSON.stringify(k)}: [${v.join(', ')}],`), + '};', + 'export default COUNTRY_BBOXES;', + '', +]; +const jsOut = path.join(root, 'shared', 'country-bboxes.js'); +fs.writeFileSync(jsOut, jsLines.join('\n')); +console.log(`Wrote JS module to ${jsOut}`); + +// Companion .d.ts so TypeScript knows the shape when api/mcp.ts imports the .js +const dtsLines = [ + '// Auto-generated by scripts/generate-country-bboxes.cjs — do not edit manually', + 'declare const COUNTRY_BBOXES: Record;', + 'export default COUNTRY_BBOXES;', + '', +]; +const dtsOut = path.join(root, 'shared', 'country-bboxes.d.ts'); +fs.writeFileSync(dtsOut, dtsLines.join('\n')); +console.log(`Wrote .d.ts declaration to ${dtsOut}`); diff --git a/scripts/shared/country-bboxes.json b/scripts/shared/country-bboxes.json new file mode 100644 index 000000000..017778971 --- /dev/null +++ b/scripts/shared/country-bboxes.json @@ -0,0 +1,1004 @@ +{ + "AE": [ + 22.62, + 51.57, + 26.06, + 56.38 + ], + "AF": [ + 29.39, + 60.49, + 38.47, + 74.89 + ], + "AL": [ + 39.69, + 19.31, + 42.55, + 20.97 + ], + "AM": [ + 38.86, + 43.44, + 41.29, + 46.6 + ], + "AO": [ + -18.03, + 11.74, + -4.39, + 24 + ], + "AQ": [ + -90, + -180, + -64.38, + 180 + ], + "AR": [ + -54.91, + -73.47, + -21.79, + -53.67 + ], + "AT": [ + 46.42, + 9.52, + 49.01, + 17.15 + ], + "AU": [ + -43.63, + 113.39, + -10.93, + 153.61 + ], + "AZ": [ + 38.43, + 44.77, + 41.89, + 49.59 + ], + "BA": [ + 42.56, + 15.72, + 45.28, + 19.62 + ], + "BD": [ + 21.06, + 88.02, + 26.42, + 92.58 + ], + "BE": [ + 49.54, + 2.52, + 51.38, + 6.12 + ], + "BF": [ + 9.49, + -5.52, + 15.07, + 2.39 + ], + "BG": [ + 41.24, + 22.35, + 44.23, + 28.58 + ], + "BI": [ + -4.45, + 29.02, + -2.4, + 30.83 + ], + "BJ": [ + 6.21, + 0.77, + 12.4, + 3.84 + ], + "BN": [ + 4.02, + 114, + 4.89, + 114.98 + ], + "BO": [ + -22.83, + -69.58, + -9.83, + -57.55 + ], + "BR": [ + -33.74, + -73.77, + 5.2, + -34.8 + ], + "BT": [ + 26.7, + 88.89, + 28.34, + 92.04 + ], + "BW": [ + -26.86, + 19.98, + -17.79, + 29.35 + ], + "BY": [ + 51.48, + 23.49, + 56.14, + 32.72 + ], + "BZ": [ + 15.89, + -89.24, + 18.48, + -88.09 + ], + "CA": [ + 41.68, + -141.01, + 83.05, + -52.63 + ], + "CD": [ + -13.43, + 12.21, + 5.37, + 31.24 + ], + "CF": [ + 2.24, + 14.52, + 11, + 27.44 + ], + "CG": [ + -5.02, + 11.11, + 3.62, + 18.63 + ], + "CH": [ + 45.91, + 6.06, + 47.8, + 10.45 + ], + "CI": [ + 4.35, + -8.57, + 10.43, + -2.51 + ], + "CL": [ + -55.63, + -75.7, + -17.51, + -67.01 + ], + "CM": [ + 1.91, + 8.59, + 13.08, + 16.2 + ], + "CN": [ + 18.29, + 73.63, + 53.55, + 134.77 + ], + "CO": [ + -4.24, + -78.83, + 12.47, + -66.88 + ], + "CR": [ + 8.03, + -85.86, + 11.08, + -82.57 + ], + "CU": [ + 19.86, + -84.02, + 23.2, + -74.27 + ], + "CY": [ + 34.63, + 32.58, + 35.19, + 34.02 + ], + "CZ": [ + 48.59, + 12.34, + 50.92, + 18.83 + ], + "DE": [ + 47.3, + 5.99, + 54.9, + 14.81 + ], + "DJ": [ + 10.97, + 41.75, + 12.71, + 43.41 + ], + "DK": [ + 54.81, + 8.29, + 57.59, + 12.6 + ], + "DO": [ + 18.04, + -71.91, + 19.94, + -68.74 + ], + "DZ": [ + 18.98, + -8.68, + 37.08, + 11.97 + ], + "EC": [ + -4.96, + -80.85, + 1.43, + -75.28 + ], + "EE": [ + 57.52, + 23.47, + 59.63, + 28.02 + ], + "EG": [ + 21.99, + 24.69, + 31.66, + 36.88 + ], + "EH": [ + 20.77, + -17.06, + 27.66, + -8.68 + ], + "ER": [ + 12.47, + 36.42, + 18, + 43.12 + ], + "ES": [ + 36.14, + -9.22, + 43.71, + 3.18 + ], + "ET": [ + 3.46, + 33.05, + 14.88, + 47.98 + ], + "FI": [ + 59.9, + 20.62, + 70.08, + 31.57 + ], + "FJ": [ + -18.25, + 177.34, + -16.15, + 180 + ], + "FR": [ + 2.11, + -54.62, + 51.09, + 9.55 + ], + "GA": [ + -3.94, + 8.8, + 2.3, + 14.5 + ], + "GB": [ + 50.23, + -8.16, + 58.66, + 1.77 + ], + "GE": [ + 41.11, + 39.99, + 43.54, + 46.43 + ], + "GH": [ + 4.74, + -3.26, + 11.13, + 1.19 + ], + "GL": [ + 60.19, + -72.78, + 83.63, + -11.64 + ], + "GN": [ + 7.25, + -15.02, + 12.67, + -7.69 + ], + "GQ": [ + 1, + 9.41, + 2.34, + 11.34 + ], + "GR": [ + 34.93, + 20, + 41.71, + 26.64 + ], + "GT": [ + 13.73, + -92.25, + 17.81, + -88.22 + ], + "GW": [ + 10.97, + -16.73, + 12.68, + -13.73 + ], + "GY": [ + 1.27, + -61.38, + 8.56, + -56.48 + ], + "HN": [ + 12.98, + -89.36, + 15.98, + -83.13 + ], + "HR": [ + 42.42, + 13.59, + 46.5, + 19.02 + ], + "HT": [ + 18.04, + -72.89, + 19.94, + -71.64 + ], + "HU": [ + 45.74, + 16.09, + 48.53, + 22.88 + ], + "ID": [ + -10.34, + 95.2, + 5.55, + 140.98 + ], + "IE": [ + 51.59, + -10.23, + 55.16, + -6 + ], + "IL": [ + 29.49, + 34.25, + 33.41, + 35.82 + ], + "IN": [ + 8.08, + 68.18, + 35.5, + 97.32 + ], + "IQ": [ + 29.1, + 38.77, + 37.37, + 48.53 + ], + "IR": [ + 25.2, + 44.06, + 39.69, + 62.75 + ], + "IS": [ + 63.4, + -24.2, + 66.47, + -13.53 + ], + "IT": [ + 36.79, + 7.02, + 46.99, + 18.01 + ], + "JM": [ + 17.72, + -78.08, + 18.52, + -76.26 + ], + "JO": [ + 29.19, + 34.95, + 33.37, + 39.15 + ], + "JP": [ + 31.11, + 129.85, + 45.51, + 145.77 + ], + "KE": [ + -4.68, + 33.89, + 4.98, + 41.89 + ], + "KG": [ + 39.33, + 69.29, + 43.22, + 80.21 + ], + "KH": [ + 10.42, + 102.33, + 14.7, + 107.6 + ], + "KP": [ + 37.83, + 124.37, + 42.53, + 130.7 + ], + "KR": [ + 34.44, + 126.27, + 38.62, + 129.45 + ], + "KW": [ + 28.53, + 46.53, + 29.99, + 48.43 + ], + "KZ": [ + 40.58, + 46.48, + 55.35, + 87.32 + ], + "LA": [ + 14.32, + 100.1, + 22.4, + 107.66 + ], + "LB": [ + 33.09, + 35.11, + 34.65, + 36.6 + ], + "LK": [ + 5.92, + 79.76, + 9.5, + 81.88 + ], + "LR": [ + 4.35, + -11.48, + 8.49, + -7.45 + ], + "LS": [ + -30.65, + 27.01, + -28.6, + 29.44 + ], + "LT": [ + 53.94, + 21.05, + 56.42, + 26.59 + ], + "LU": [ + 49.46, + 5.79, + 50.12, + 6.35 + ], + "LV": [ + 55.67, + 21.05, + 58.06, + 28.15 + ], + "LY": [ + 19.5, + 9.4, + 33.18, + 25.15 + ], + "MA": [ + 21.42, + -17.01, + 35.92, + -1.25 + ], + "MD": [ + 45.46, + 26.62, + 48.45, + 29.73 + ], + "ME": [ + 41.85, + 18.44, + 43.53, + 20.35 + ], + "MG": [ + -25.57, + 43.22, + -12.3, + 50.5 + ], + "MK": [ + 40.85, + 20.48, + 42.31, + 22.92 + ], + "ML": [ + 10.16, + -12.26, + 25, + 4.23 + ], + "MM": [ + 10.35, + 92.27, + 28.53, + 101.16 + ], + "MN": [ + 41.6, + 87.82, + 52.11, + 119.7 + ], + "MR": [ + 14.77, + -17.06, + 27.29, + -4.82 + ], + "MW": [ + -16.48, + 32.72, + -9.41, + 35.85 + ], + "MX": [ + 14.55, + -117.13, + 32.53, + -86.74 + ], + "MY": [ + 0.85, + 100.13, + 6.71, + 119.16 + ], + "MZ": [ + -26.85, + 30.21, + -10.47, + 40.84 + ], + "NA": [ + -28.96, + 11.77, + -16.96, + 25.26 + ], + "NC": [ + -22.29, + 164.37, + -20.3, + 167.03 + ], + "NE": [ + 11.7, + 0.15, + 23.52, + 15.95 + ], + "NG": [ + 4.28, + 2.67, + 13.87, + 14.67 + ], + "NI": [ + 10.72, + -87.31, + 15, + -83.13 + ], + "NL": [ + 50.75, + 4.14, + 53.41, + 7.19 + ], + "NO": [ + 58.03, + 4.96, + 80.33, + 31.06 + ], + "NP": [ + 26.35, + 80.04, + 30.41, + 88.12 + ], + "NZ": [ + -46.68, + 166.49, + -35.01, + 178.29 + ], + "OM": [ + 16.64, + 51.98, + 24.98, + 59.79 + ], + "PA": [ + 7.21, + -82.9, + 9.63, + -77.2 + ], + "PE": [ + -18.34, + -81.25, + -0.11, + -68.68 + ], + "PG": [ + -10.35, + 140.97, + -2.6, + 155.93 + ], + "PH": [ + 5.8, + 119.88, + 18.54, + 126.57 + ], + "PK": [ + 23.8, + 60.84, + 37.02, + 77.05 + ], + "PL": [ + 49.07, + 14.2, + 54.82, + 24.11 + ], + "PR": [ + 17.93, + -67.21, + 18.52, + -65.63 + ], + "PS": [ + 31.21, + 34.2, + 32.38, + 35.56 + ], + "PT": [ + 37.12, + -9.49, + 41.97, + -6.21 + ], + "PY": [ + -27.49, + -62.65, + -19.29, + -54.25 + ], + "QA": [ + 24.63, + 50.75, + 26.05, + 51.62 + ], + "RO": [ + 43.65, + 20.24, + 48.26, + 29.66 + ], + "RS": [ + 42.25, + 18.9, + 46.11, + 22.94 + ], + "RU": [ + 41.21, + -180, + 81.29, + 180 + ], + "RW": [ + -2.81, + 28.86, + -1.07, + 30.83 + ], + "SA": [ + 16.37, + 34.63, + 32.12, + 55.64 + ], + "SD": [ + 8.69, + 22.07, + 22, + 38.6 + ], + "SE": [ + 55.4, + 11.22, + 69.04, + 24.16 + ], + "SI": [ + 45.49, + 13.59, + 46.86, + 16.52 + ], + "SK": [ + 47.75, + 16.95, + 49.51, + 22.54 + ], + "SL": [ + 6.92, + -13.3, + 10, + -10.28 + ], + "SN": [ + 12.31, + -17.18, + 16.64, + -11.39 + ], + "SO": [ + -1.7, + 40.97, + 11.99, + 51.14 + ], + "SR": [ + 1.94, + -58.07, + 6.01, + -53.99 + ], + "SS": [ + 3.49, + 24.17, + 11.71, + 35.92 + ], + "SV": [ + 13.25, + -90.1, + 14.42, + -87.82 + ], + "SY": [ + 32.32, + 35.76, + 37.11, + 42.36 + ], + "SZ": [ + -27.32, + 30.79, + -25.91, + 32.11 + ], + "TD": [ + 7.52, + 13.45, + 23.44, + 23.98 + ], + "TG": [ + 6.1, + -0.17, + 11.13, + 1.62 + ], + "TH": [ + 5.64, + 97.77, + 20.32, + 105.65 + ], + "TJ": [ + 36.7, + 67.76, + 40.88, + 75.16 + ], + "TL": [ + -9.49, + 124.92, + -8.31, + 127.01 + ], + "TM": [ + 35.22, + 52.44, + 42.78, + 66.55 + ], + "TN": [ + 30.23, + 7.48, + 37.34, + 11.51 + ], + "TR": [ + 35.92, + 26.04, + 42.09, + 44.81 + ], + "TZ": [ + -11.72, + 29.4, + -1, + 40.44 + ], + "UA": [ + 45.22, + 22.13, + 52.35, + 40.14 + ], + "UG": [ + -1.39, + 29.58, + 4.22, + 34.98 + ], + "US": [ + 19.03, + -168.08, + 71.31, + -66.98 + ], + "UY": [ + -34.97, + -58.39, + -30.1, + -53.13 + ], + "UZ": [ + 37.19, + 55.98, + 45.56, + 72.62 + ], + "VE": [ + 0.76, + -73.01, + 11.85, + -59.82 + ], + "VN": [ + 8.82, + 102.12, + 23.32, + 109.47 + ], + "XK": [ + 41.87, + 20.06, + 43.17, + 21.56 + ], + "YE": [ + 12.62, + 42.7, + 19, + 53.09 + ], + "ZA": [ + -34.81, + 16.49, + -22.19, + 32.89 + ], + "ZM": [ + -17.94, + 21.98, + -8.19, + 33.67 + ], + "ZW": [ + -22.4, + 25.26, + -15.64, + 33.03 + ] +} diff --git a/shared/country-bboxes.d.ts b/shared/country-bboxes.d.ts new file mode 100644 index 000000000..23f466ebf --- /dev/null +++ b/shared/country-bboxes.d.ts @@ -0,0 +1,3 @@ +// Auto-generated by scripts/generate-country-bboxes.cjs — do not edit manually +declare const COUNTRY_BBOXES: Record; +export default COUNTRY_BBOXES; diff --git a/shared/country-bboxes.js b/shared/country-bboxes.js new file mode 100644 index 000000000..81deb5295 --- /dev/null +++ b/shared/country-bboxes.js @@ -0,0 +1,172 @@ +// Auto-generated by scripts/generate-country-bboxes.cjs — do not edit manually +// To regenerate: node scripts/generate-country-bboxes.cjs +const COUNTRY_BBOXES = { + "AE": [22.62, 51.57, 26.06, 56.38], + "AF": [29.39, 60.49, 38.47, 74.89], + "AL": [39.69, 19.31, 42.55, 20.97], + "AM": [38.86, 43.44, 41.29, 46.6], + "AO": [-18.03, 11.74, -4.39, 24], + "AQ": [-90, -180, -64.38, 180], + "AR": [-54.91, -73.47, -21.79, -53.67], + "AT": [46.42, 9.52, 49.01, 17.15], + "AU": [-43.63, 113.39, -10.93, 153.61], + "AZ": [38.43, 44.77, 41.89, 49.59], + "BA": [42.56, 15.72, 45.28, 19.62], + "BD": [21.06, 88.02, 26.42, 92.58], + "BE": [49.54, 2.52, 51.38, 6.12], + "BF": [9.49, -5.52, 15.07, 2.39], + "BG": [41.24, 22.35, 44.23, 28.58], + "BI": [-4.45, 29.02, -2.4, 30.83], + "BJ": [6.21, 0.77, 12.4, 3.84], + "BN": [4.02, 114, 4.89, 114.98], + "BO": [-22.83, -69.58, -9.83, -57.55], + "BR": [-33.74, -73.77, 5.2, -34.8], + "BT": [26.7, 88.89, 28.34, 92.04], + "BW": [-26.86, 19.98, -17.79, 29.35], + "BY": [51.48, 23.49, 56.14, 32.72], + "BZ": [15.89, -89.24, 18.48, -88.09], + "CA": [41.68, -141.01, 83.05, -52.63], + "CD": [-13.43, 12.21, 5.37, 31.24], + "CF": [2.24, 14.52, 11, 27.44], + "CG": [-5.02, 11.11, 3.62, 18.63], + "CH": [45.91, 6.06, 47.8, 10.45], + "CI": [4.35, -8.57, 10.43, -2.51], + "CL": [-55.63, -75.7, -17.51, -67.01], + "CM": [1.91, 8.59, 13.08, 16.2], + "CN": [18.29, 73.63, 53.55, 134.77], + "CO": [-4.24, -78.83, 12.47, -66.88], + "CR": [8.03, -85.86, 11.08, -82.57], + "CU": [19.86, -84.02, 23.2, -74.27], + "CY": [34.63, 32.58, 35.19, 34.02], + "CZ": [48.59, 12.34, 50.92, 18.83], + "DE": [47.3, 5.99, 54.9, 14.81], + "DJ": [10.97, 41.75, 12.71, 43.41], + "DK": [54.81, 8.29, 57.59, 12.6], + "DO": [18.04, -71.91, 19.94, -68.74], + "DZ": [18.98, -8.68, 37.08, 11.97], + "EC": [-4.96, -80.85, 1.43, -75.28], + "EE": [57.52, 23.47, 59.63, 28.02], + "EG": [21.99, 24.69, 31.66, 36.88], + "EH": [20.77, -17.06, 27.66, -8.68], + "ER": [12.47, 36.42, 18, 43.12], + "ES": [36.14, -9.22, 43.71, 3.18], + "ET": [3.46, 33.05, 14.88, 47.98], + "FI": [59.9, 20.62, 70.08, 31.57], + "FJ": [-18.25, 177.34, -16.15, 180], + "FR": [2.11, -54.62, 51.09, 9.55], + "GA": [-3.94, 8.8, 2.3, 14.5], + "GB": [50.23, -8.16, 58.66, 1.77], + "GE": [41.11, 39.99, 43.54, 46.43], + "GH": [4.74, -3.26, 11.13, 1.19], + "GL": [60.19, -72.78, 83.63, -11.64], + "GN": [7.25, -15.02, 12.67, -7.69], + "GQ": [1, 9.41, 2.34, 11.34], + "GR": [34.93, 20, 41.71, 26.64], + "GT": [13.73, -92.25, 17.81, -88.22], + "GW": [10.97, -16.73, 12.68, -13.73], + "GY": [1.27, -61.38, 8.56, -56.48], + "HN": [12.98, -89.36, 15.98, -83.13], + "HR": [42.42, 13.59, 46.5, 19.02], + "HT": [18.04, -72.89, 19.94, -71.64], + "HU": [45.74, 16.09, 48.53, 22.88], + "ID": [-10.34, 95.2, 5.55, 140.98], + "IE": [51.59, -10.23, 55.16, -6], + "IL": [29.49, 34.25, 33.41, 35.82], + "IN": [8.08, 68.18, 35.5, 97.32], + "IQ": [29.1, 38.77, 37.37, 48.53], + "IR": [25.2, 44.06, 39.69, 62.75], + "IS": [63.4, -24.2, 66.47, -13.53], + "IT": [36.79, 7.02, 46.99, 18.01], + "JM": [17.72, -78.08, 18.52, -76.26], + "JO": [29.19, 34.95, 33.37, 39.15], + "JP": [31.11, 129.85, 45.51, 145.77], + "KE": [-4.68, 33.89, 4.98, 41.89], + "KG": [39.33, 69.29, 43.22, 80.21], + "KH": [10.42, 102.33, 14.7, 107.6], + "KP": [37.83, 124.37, 42.53, 130.7], + "KR": [34.44, 126.27, 38.62, 129.45], + "KW": [28.53, 46.53, 29.99, 48.43], + "KZ": [40.58, 46.48, 55.35, 87.32], + "LA": [14.32, 100.1, 22.4, 107.66], + "LB": [33.09, 35.11, 34.65, 36.6], + "LK": [5.92, 79.76, 9.5, 81.88], + "LR": [4.35, -11.48, 8.49, -7.45], + "LS": [-30.65, 27.01, -28.6, 29.44], + "LT": [53.94, 21.05, 56.42, 26.59], + "LU": [49.46, 5.79, 50.12, 6.35], + "LV": [55.67, 21.05, 58.06, 28.15], + "LY": [19.5, 9.4, 33.18, 25.15], + "MA": [21.42, -17.01, 35.92, -1.25], + "MD": [45.46, 26.62, 48.45, 29.73], + "ME": [41.85, 18.44, 43.53, 20.35], + "MG": [-25.57, 43.22, -12.3, 50.5], + "MK": [40.85, 20.48, 42.31, 22.92], + "ML": [10.16, -12.26, 25, 4.23], + "MM": [10.35, 92.27, 28.53, 101.16], + "MN": [41.6, 87.82, 52.11, 119.7], + "MR": [14.77, -17.06, 27.29, -4.82], + "MW": [-16.48, 32.72, -9.41, 35.85], + "MX": [14.55, -117.13, 32.53, -86.74], + "MY": [0.85, 100.13, 6.71, 119.16], + "MZ": [-26.85, 30.21, -10.47, 40.84], + "NA": [-28.96, 11.77, -16.96, 25.26], + "NC": [-22.29, 164.37, -20.3, 167.03], + "NE": [11.7, 0.15, 23.52, 15.95], + "NG": [4.28, 2.67, 13.87, 14.67], + "NI": [10.72, -87.31, 15, -83.13], + "NL": [50.75, 4.14, 53.41, 7.19], + "NO": [58.03, 4.96, 80.33, 31.06], + "NP": [26.35, 80.04, 30.41, 88.12], + "NZ": [-46.68, 166.49, -35.01, 178.29], + "OM": [16.64, 51.98, 24.98, 59.79], + "PA": [7.21, -82.9, 9.63, -77.2], + "PE": [-18.34, -81.25, -0.11, -68.68], + "PG": [-10.35, 140.97, -2.6, 155.93], + "PH": [5.8, 119.88, 18.54, 126.57], + "PK": [23.8, 60.84, 37.02, 77.05], + "PL": [49.07, 14.2, 54.82, 24.11], + "PR": [17.93, -67.21, 18.52, -65.63], + "PS": [31.21, 34.2, 32.38, 35.56], + "PT": [37.12, -9.49, 41.97, -6.21], + "PY": [-27.49, -62.65, -19.29, -54.25], + "QA": [24.63, 50.75, 26.05, 51.62], + "RO": [43.65, 20.24, 48.26, 29.66], + "RS": [42.25, 18.9, 46.11, 22.94], + "RU": [41.21, -180, 81.29, 180], + "RW": [-2.81, 28.86, -1.07, 30.83], + "SA": [16.37, 34.63, 32.12, 55.64], + "SD": [8.69, 22.07, 22, 38.6], + "SE": [55.4, 11.22, 69.04, 24.16], + "SI": [45.49, 13.59, 46.86, 16.52], + "SK": [47.75, 16.95, 49.51, 22.54], + "SL": [6.92, -13.3, 10, -10.28], + "SN": [12.31, -17.18, 16.64, -11.39], + "SO": [-1.7, 40.97, 11.99, 51.14], + "SR": [1.94, -58.07, 6.01, -53.99], + "SS": [3.49, 24.17, 11.71, 35.92], + "SV": [13.25, -90.1, 14.42, -87.82], + "SY": [32.32, 35.76, 37.11, 42.36], + "SZ": [-27.32, 30.79, -25.91, 32.11], + "TD": [7.52, 13.45, 23.44, 23.98], + "TG": [6.1, -0.17, 11.13, 1.62], + "TH": [5.64, 97.77, 20.32, 105.65], + "TJ": [36.7, 67.76, 40.88, 75.16], + "TL": [-9.49, 124.92, -8.31, 127.01], + "TM": [35.22, 52.44, 42.78, 66.55], + "TN": [30.23, 7.48, 37.34, 11.51], + "TR": [35.92, 26.04, 42.09, 44.81], + "TZ": [-11.72, 29.4, -1, 40.44], + "UA": [45.22, 22.13, 52.35, 40.14], + "UG": [-1.39, 29.58, 4.22, 34.98], + "US": [19.03, -168.08, 71.31, -66.98], + "UY": [-34.97, -58.39, -30.1, -53.13], + "UZ": [37.19, 55.98, 45.56, 72.62], + "VE": [0.76, -73.01, 11.85, -59.82], + "VN": [8.82, 102.12, 23.32, 109.47], + "XK": [41.87, 20.06, 43.17, 21.56], + "YE": [12.62, 42.7, 19, 53.09], + "ZA": [-34.81, 16.49, -22.19, 32.89], + "ZM": [-17.94, 21.98, -8.19, 33.67], + "ZW": [-22.4, 25.26, -15.64, 33.03], +}; +export default COUNTRY_BBOXES; diff --git a/shared/country-bboxes.json b/shared/country-bboxes.json new file mode 100644 index 000000000..017778971 --- /dev/null +++ b/shared/country-bboxes.json @@ -0,0 +1,1004 @@ +{ + "AE": [ + 22.62, + 51.57, + 26.06, + 56.38 + ], + "AF": [ + 29.39, + 60.49, + 38.47, + 74.89 + ], + "AL": [ + 39.69, + 19.31, + 42.55, + 20.97 + ], + "AM": [ + 38.86, + 43.44, + 41.29, + 46.6 + ], + "AO": [ + -18.03, + 11.74, + -4.39, + 24 + ], + "AQ": [ + -90, + -180, + -64.38, + 180 + ], + "AR": [ + -54.91, + -73.47, + -21.79, + -53.67 + ], + "AT": [ + 46.42, + 9.52, + 49.01, + 17.15 + ], + "AU": [ + -43.63, + 113.39, + -10.93, + 153.61 + ], + "AZ": [ + 38.43, + 44.77, + 41.89, + 49.59 + ], + "BA": [ + 42.56, + 15.72, + 45.28, + 19.62 + ], + "BD": [ + 21.06, + 88.02, + 26.42, + 92.58 + ], + "BE": [ + 49.54, + 2.52, + 51.38, + 6.12 + ], + "BF": [ + 9.49, + -5.52, + 15.07, + 2.39 + ], + "BG": [ + 41.24, + 22.35, + 44.23, + 28.58 + ], + "BI": [ + -4.45, + 29.02, + -2.4, + 30.83 + ], + "BJ": [ + 6.21, + 0.77, + 12.4, + 3.84 + ], + "BN": [ + 4.02, + 114, + 4.89, + 114.98 + ], + "BO": [ + -22.83, + -69.58, + -9.83, + -57.55 + ], + "BR": [ + -33.74, + -73.77, + 5.2, + -34.8 + ], + "BT": [ + 26.7, + 88.89, + 28.34, + 92.04 + ], + "BW": [ + -26.86, + 19.98, + -17.79, + 29.35 + ], + "BY": [ + 51.48, + 23.49, + 56.14, + 32.72 + ], + "BZ": [ + 15.89, + -89.24, + 18.48, + -88.09 + ], + "CA": [ + 41.68, + -141.01, + 83.05, + -52.63 + ], + "CD": [ + -13.43, + 12.21, + 5.37, + 31.24 + ], + "CF": [ + 2.24, + 14.52, + 11, + 27.44 + ], + "CG": [ + -5.02, + 11.11, + 3.62, + 18.63 + ], + "CH": [ + 45.91, + 6.06, + 47.8, + 10.45 + ], + "CI": [ + 4.35, + -8.57, + 10.43, + -2.51 + ], + "CL": [ + -55.63, + -75.7, + -17.51, + -67.01 + ], + "CM": [ + 1.91, + 8.59, + 13.08, + 16.2 + ], + "CN": [ + 18.29, + 73.63, + 53.55, + 134.77 + ], + "CO": [ + -4.24, + -78.83, + 12.47, + -66.88 + ], + "CR": [ + 8.03, + -85.86, + 11.08, + -82.57 + ], + "CU": [ + 19.86, + -84.02, + 23.2, + -74.27 + ], + "CY": [ + 34.63, + 32.58, + 35.19, + 34.02 + ], + "CZ": [ + 48.59, + 12.34, + 50.92, + 18.83 + ], + "DE": [ + 47.3, + 5.99, + 54.9, + 14.81 + ], + "DJ": [ + 10.97, + 41.75, + 12.71, + 43.41 + ], + "DK": [ + 54.81, + 8.29, + 57.59, + 12.6 + ], + "DO": [ + 18.04, + -71.91, + 19.94, + -68.74 + ], + "DZ": [ + 18.98, + -8.68, + 37.08, + 11.97 + ], + "EC": [ + -4.96, + -80.85, + 1.43, + -75.28 + ], + "EE": [ + 57.52, + 23.47, + 59.63, + 28.02 + ], + "EG": [ + 21.99, + 24.69, + 31.66, + 36.88 + ], + "EH": [ + 20.77, + -17.06, + 27.66, + -8.68 + ], + "ER": [ + 12.47, + 36.42, + 18, + 43.12 + ], + "ES": [ + 36.14, + -9.22, + 43.71, + 3.18 + ], + "ET": [ + 3.46, + 33.05, + 14.88, + 47.98 + ], + "FI": [ + 59.9, + 20.62, + 70.08, + 31.57 + ], + "FJ": [ + -18.25, + 177.34, + -16.15, + 180 + ], + "FR": [ + 2.11, + -54.62, + 51.09, + 9.55 + ], + "GA": [ + -3.94, + 8.8, + 2.3, + 14.5 + ], + "GB": [ + 50.23, + -8.16, + 58.66, + 1.77 + ], + "GE": [ + 41.11, + 39.99, + 43.54, + 46.43 + ], + "GH": [ + 4.74, + -3.26, + 11.13, + 1.19 + ], + "GL": [ + 60.19, + -72.78, + 83.63, + -11.64 + ], + "GN": [ + 7.25, + -15.02, + 12.67, + -7.69 + ], + "GQ": [ + 1, + 9.41, + 2.34, + 11.34 + ], + "GR": [ + 34.93, + 20, + 41.71, + 26.64 + ], + "GT": [ + 13.73, + -92.25, + 17.81, + -88.22 + ], + "GW": [ + 10.97, + -16.73, + 12.68, + -13.73 + ], + "GY": [ + 1.27, + -61.38, + 8.56, + -56.48 + ], + "HN": [ + 12.98, + -89.36, + 15.98, + -83.13 + ], + "HR": [ + 42.42, + 13.59, + 46.5, + 19.02 + ], + "HT": [ + 18.04, + -72.89, + 19.94, + -71.64 + ], + "HU": [ + 45.74, + 16.09, + 48.53, + 22.88 + ], + "ID": [ + -10.34, + 95.2, + 5.55, + 140.98 + ], + "IE": [ + 51.59, + -10.23, + 55.16, + -6 + ], + "IL": [ + 29.49, + 34.25, + 33.41, + 35.82 + ], + "IN": [ + 8.08, + 68.18, + 35.5, + 97.32 + ], + "IQ": [ + 29.1, + 38.77, + 37.37, + 48.53 + ], + "IR": [ + 25.2, + 44.06, + 39.69, + 62.75 + ], + "IS": [ + 63.4, + -24.2, + 66.47, + -13.53 + ], + "IT": [ + 36.79, + 7.02, + 46.99, + 18.01 + ], + "JM": [ + 17.72, + -78.08, + 18.52, + -76.26 + ], + "JO": [ + 29.19, + 34.95, + 33.37, + 39.15 + ], + "JP": [ + 31.11, + 129.85, + 45.51, + 145.77 + ], + "KE": [ + -4.68, + 33.89, + 4.98, + 41.89 + ], + "KG": [ + 39.33, + 69.29, + 43.22, + 80.21 + ], + "KH": [ + 10.42, + 102.33, + 14.7, + 107.6 + ], + "KP": [ + 37.83, + 124.37, + 42.53, + 130.7 + ], + "KR": [ + 34.44, + 126.27, + 38.62, + 129.45 + ], + "KW": [ + 28.53, + 46.53, + 29.99, + 48.43 + ], + "KZ": [ + 40.58, + 46.48, + 55.35, + 87.32 + ], + "LA": [ + 14.32, + 100.1, + 22.4, + 107.66 + ], + "LB": [ + 33.09, + 35.11, + 34.65, + 36.6 + ], + "LK": [ + 5.92, + 79.76, + 9.5, + 81.88 + ], + "LR": [ + 4.35, + -11.48, + 8.49, + -7.45 + ], + "LS": [ + -30.65, + 27.01, + -28.6, + 29.44 + ], + "LT": [ + 53.94, + 21.05, + 56.42, + 26.59 + ], + "LU": [ + 49.46, + 5.79, + 50.12, + 6.35 + ], + "LV": [ + 55.67, + 21.05, + 58.06, + 28.15 + ], + "LY": [ + 19.5, + 9.4, + 33.18, + 25.15 + ], + "MA": [ + 21.42, + -17.01, + 35.92, + -1.25 + ], + "MD": [ + 45.46, + 26.62, + 48.45, + 29.73 + ], + "ME": [ + 41.85, + 18.44, + 43.53, + 20.35 + ], + "MG": [ + -25.57, + 43.22, + -12.3, + 50.5 + ], + "MK": [ + 40.85, + 20.48, + 42.31, + 22.92 + ], + "ML": [ + 10.16, + -12.26, + 25, + 4.23 + ], + "MM": [ + 10.35, + 92.27, + 28.53, + 101.16 + ], + "MN": [ + 41.6, + 87.82, + 52.11, + 119.7 + ], + "MR": [ + 14.77, + -17.06, + 27.29, + -4.82 + ], + "MW": [ + -16.48, + 32.72, + -9.41, + 35.85 + ], + "MX": [ + 14.55, + -117.13, + 32.53, + -86.74 + ], + "MY": [ + 0.85, + 100.13, + 6.71, + 119.16 + ], + "MZ": [ + -26.85, + 30.21, + -10.47, + 40.84 + ], + "NA": [ + -28.96, + 11.77, + -16.96, + 25.26 + ], + "NC": [ + -22.29, + 164.37, + -20.3, + 167.03 + ], + "NE": [ + 11.7, + 0.15, + 23.52, + 15.95 + ], + "NG": [ + 4.28, + 2.67, + 13.87, + 14.67 + ], + "NI": [ + 10.72, + -87.31, + 15, + -83.13 + ], + "NL": [ + 50.75, + 4.14, + 53.41, + 7.19 + ], + "NO": [ + 58.03, + 4.96, + 80.33, + 31.06 + ], + "NP": [ + 26.35, + 80.04, + 30.41, + 88.12 + ], + "NZ": [ + -46.68, + 166.49, + -35.01, + 178.29 + ], + "OM": [ + 16.64, + 51.98, + 24.98, + 59.79 + ], + "PA": [ + 7.21, + -82.9, + 9.63, + -77.2 + ], + "PE": [ + -18.34, + -81.25, + -0.11, + -68.68 + ], + "PG": [ + -10.35, + 140.97, + -2.6, + 155.93 + ], + "PH": [ + 5.8, + 119.88, + 18.54, + 126.57 + ], + "PK": [ + 23.8, + 60.84, + 37.02, + 77.05 + ], + "PL": [ + 49.07, + 14.2, + 54.82, + 24.11 + ], + "PR": [ + 17.93, + -67.21, + 18.52, + -65.63 + ], + "PS": [ + 31.21, + 34.2, + 32.38, + 35.56 + ], + "PT": [ + 37.12, + -9.49, + 41.97, + -6.21 + ], + "PY": [ + -27.49, + -62.65, + -19.29, + -54.25 + ], + "QA": [ + 24.63, + 50.75, + 26.05, + 51.62 + ], + "RO": [ + 43.65, + 20.24, + 48.26, + 29.66 + ], + "RS": [ + 42.25, + 18.9, + 46.11, + 22.94 + ], + "RU": [ + 41.21, + -180, + 81.29, + 180 + ], + "RW": [ + -2.81, + 28.86, + -1.07, + 30.83 + ], + "SA": [ + 16.37, + 34.63, + 32.12, + 55.64 + ], + "SD": [ + 8.69, + 22.07, + 22, + 38.6 + ], + "SE": [ + 55.4, + 11.22, + 69.04, + 24.16 + ], + "SI": [ + 45.49, + 13.59, + 46.86, + 16.52 + ], + "SK": [ + 47.75, + 16.95, + 49.51, + 22.54 + ], + "SL": [ + 6.92, + -13.3, + 10, + -10.28 + ], + "SN": [ + 12.31, + -17.18, + 16.64, + -11.39 + ], + "SO": [ + -1.7, + 40.97, + 11.99, + 51.14 + ], + "SR": [ + 1.94, + -58.07, + 6.01, + -53.99 + ], + "SS": [ + 3.49, + 24.17, + 11.71, + 35.92 + ], + "SV": [ + 13.25, + -90.1, + 14.42, + -87.82 + ], + "SY": [ + 32.32, + 35.76, + 37.11, + 42.36 + ], + "SZ": [ + -27.32, + 30.79, + -25.91, + 32.11 + ], + "TD": [ + 7.52, + 13.45, + 23.44, + 23.98 + ], + "TG": [ + 6.1, + -0.17, + 11.13, + 1.62 + ], + "TH": [ + 5.64, + 97.77, + 20.32, + 105.65 + ], + "TJ": [ + 36.7, + 67.76, + 40.88, + 75.16 + ], + "TL": [ + -9.49, + 124.92, + -8.31, + 127.01 + ], + "TM": [ + 35.22, + 52.44, + 42.78, + 66.55 + ], + "TN": [ + 30.23, + 7.48, + 37.34, + 11.51 + ], + "TR": [ + 35.92, + 26.04, + 42.09, + 44.81 + ], + "TZ": [ + -11.72, + 29.4, + -1, + 40.44 + ], + "UA": [ + 45.22, + 22.13, + 52.35, + 40.14 + ], + "UG": [ + -1.39, + 29.58, + 4.22, + 34.98 + ], + "US": [ + 19.03, + -168.08, + 71.31, + -66.98 + ], + "UY": [ + -34.97, + -58.39, + -30.1, + -53.13 + ], + "UZ": [ + 37.19, + 55.98, + 45.56, + 72.62 + ], + "VE": [ + 0.76, + -73.01, + 11.85, + -59.82 + ], + "VN": [ + 8.82, + 102.12, + 23.32, + 109.47 + ], + "XK": [ + 41.87, + 20.06, + 43.17, + 21.56 + ], + "YE": [ + 12.62, + 42.7, + 19, + 53.09 + ], + "ZA": [ + -34.81, + 16.49, + -22.19, + 32.89 + ], + "ZM": [ + -17.94, + 21.98, + -8.19, + 33.67 + ], + "ZW": [ + -22.4, + 25.26, + -15.64, + 33.03 + ] +} diff --git a/tests/mcp.test.mjs b/tests/mcp.test.mjs index adc1c043d..1d42bc437 100644 --- a/tests/mcp.test.mjs +++ b/tests/mcp.test.mjs @@ -121,7 +121,7 @@ describe('api/mcp.ts — PRO MCP Server', () => { assert.equal(res.status, 200); const body = await res.json(); assert.ok(Array.isArray(body.result?.tools), 'result.tools must be an array'); - assert.equal(body.result.tools.length, 24, `Expected 24 tools, got ${body.result.tools.length}`); + assert.equal(body.result.tools.length, 26, `Expected 26 tools, got ${body.result.tools.length}`); for (const tool of body.result.tools) { assert.ok(tool.name, 'tool.name must be present'); assert.ok(tool.description, 'tool.description must be present'); @@ -208,4 +208,189 @@ describe('api/mcp.ts — PRO MCP Server', () => { const body = await res.json(); assert.equal(body.error?.code, -32603, 'Must return JSON-RPC -32603, not throw'); }); + + // --- get_airspace --- + + it('get_airspace returns counts and flights for valid country code', async () => { + globalThis.fetch = async (url) => { + const u = url.toString(); + if (u.includes('/api/aviation/v1/track-aircraft')) { + return new Response(JSON.stringify({ + positions: [ + { callsign: 'UAE123', icao24: 'abc123', lat: 24.5, lon: 54.3, altitude_m: 11000, ground_speed_kts: 480, track_deg: 270, on_ground: false }, + ], + source: 'opensky', + updated_at: 1711620000000, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (u.includes('/api/military/v1/list-military-flights')) { + return new Response(JSON.stringify({ flights: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + return originalFetch(url); + }; + + const res = await handler(makeReq('POST', { + jsonrpc: '2.0', id: 10, method: 'tools/call', + params: { name: 'get_airspace', arguments: { country_code: 'AE' } }, + })); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(body.result?.content, 'result.content must be present'); + const data = JSON.parse(body.result.content[0].text); + assert.equal(data.country_code, 'AE'); + assert.equal(data.civilian_count, 1); + assert.equal(data.military_count, 0); + assert.ok(Array.isArray(data.civilian_flights), 'civilian_flights must be array'); + assert.ok(Array.isArray(data.military_flights), 'military_flights must be array'); + assert.ok(data.bounding_box?.sw_lat !== undefined, 'bounding_box must be present'); + assert.equal(data.partial, undefined, 'no partial flag when both sources succeed'); + }); + + it('get_airspace returns error for unknown country code', async () => { + const res = await handler(makeReq('POST', { + jsonrpc: '2.0', id: 11, method: 'tools/call', + params: { name: 'get_airspace', arguments: { country_code: 'XX' } }, + })); + assert.equal(res.status, 200); + const body = await res.json(); + const data = JSON.parse(body.result.content[0].text); + assert.ok(data.error?.includes('Unknown country code'), 'must return error for unknown code'); + }); + + it('get_airspace returns partial:true + warning when military source fails', async () => { + globalThis.fetch = async (url) => { + const u = url.toString(); + if (u.includes('/api/aviation/v1/track-aircraft')) { + return new Response(JSON.stringify({ positions: [], source: 'opensky' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (u.includes('/api/military/v1/list-military-flights')) { + return new Response('Service Unavailable', { status: 503 }); + } + return originalFetch(url); + }; + + const res = await handler(makeReq('POST', { + jsonrpc: '2.0', id: 12, method: 'tools/call', + params: { name: 'get_airspace', arguments: { country_code: 'US' } }, + })); + const body = await res.json(); + const data = JSON.parse(body.result.content[0].text); + assert.equal(data.partial, true, 'partial must be true when one source fails'); + assert.ok(data.warnings?.some(w => w.includes('military')), 'warnings must mention military'); + assert.equal(data.civilian_count, 0, 'civilian data still returned'); + }); + + it('get_airspace returns JSON-RPC -32603 when both sources fail', async () => { + globalThis.fetch = async () => new Response('Error', { status: 500 }); + + const res = await handler(makeReq('POST', { + jsonrpc: '2.0', id: 13, method: 'tools/call', + params: { name: 'get_airspace', arguments: { country_code: 'GB' } }, + })); + const body = await res.json(); + assert.equal(body.error?.code, -32603, 'total outage must return -32603'); + }); + + it('get_airspace type=civilian skips military fetch', async () => { + let militaryFetched = false; + globalThis.fetch = async (url) => { + const u = url.toString(); + if (u.includes('/api/military/')) militaryFetched = true; + if (u.includes('/api/aviation/v1/track-aircraft')) { + return new Response(JSON.stringify({ positions: [], source: 'opensky' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + return originalFetch(url); + }; + + const res = await handler(makeReq('POST', { + jsonrpc: '2.0', id: 14, method: 'tools/call', + params: { name: 'get_airspace', arguments: { country_code: 'DE', type: 'civilian' } }, + })); + const body = await res.json(); + const data = JSON.parse(body.result.content[0].text); + assert.equal(militaryFetched, false, 'military endpoint must not be called for type=civilian'); + assert.equal(data.military_flights, undefined, 'military_flights must be absent for type=civilian'); + assert.ok(Array.isArray(data.civilian_flights), 'civilian_flights must be present'); + }); + + // --- get_maritime_activity --- + + it('get_maritime_activity returns zones and disruptions for valid country code', async () => { + globalThis.fetch = async (url) => { + if (url.toString().includes('/api/maritime/v1/get-vessel-snapshot')) { + return new Response(JSON.stringify({ + snapshot: { + snapshot_at: 1711620000000, + density_zones: [ + { name: 'Strait of Hormuz', intensity: 82, ships_per_day: 45, delta_pct: 3.2, note: '' }, + ], + disruptions: [ + { name: 'Gulf AIS Gap', type: 'AIS_DISRUPTION_TYPE_GAP_SPIKE', severity: 'AIS_DISRUPTION_SEVERITY_ELEVATED', dark_ships: 3, vessel_count: 12, region: 'Persian Gulf', description: 'Elevated dark-ship activity' }, + ], + }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + return originalFetch(url); + }; + + const res = await handler(makeReq('POST', { + jsonrpc: '2.0', id: 20, method: 'tools/call', + params: { name: 'get_maritime_activity', arguments: { country_code: 'AE' } }, + })); + assert.equal(res.status, 200); + const body = await res.json(); + const data = JSON.parse(body.result.content[0].text); + assert.equal(data.country_code, 'AE'); + assert.equal(data.total_zones, 1); + assert.equal(data.total_disruptions, 1); + assert.equal(data.density_zones[0].name, 'Strait of Hormuz'); + assert.equal(data.disruptions[0].dark_ships, 3); + assert.ok(data.bounding_box?.sw_lat !== undefined, 'bounding_box must be present'); + }); + + it('get_maritime_activity returns error for unknown country code', async () => { + const res = await handler(makeReq('POST', { + jsonrpc: '2.0', id: 21, method: 'tools/call', + params: { name: 'get_maritime_activity', arguments: { country_code: 'ZZ' } }, + })); + const body = await res.json(); + const data = JSON.parse(body.result.content[0].text); + assert.ok(data.error?.includes('Unknown country code'), 'must return error for unknown code'); + }); + + it('get_maritime_activity returns JSON-RPC -32603 when vessel API fails', async () => { + globalThis.fetch = async (url) => { + if (url.toString().includes('/api/maritime/')) { + return new Response('Service Unavailable', { status: 503 }); + } + return originalFetch(url); + }; + + const res = await handler(makeReq('POST', { + jsonrpc: '2.0', id: 22, method: 'tools/call', + params: { name: 'get_maritime_activity', arguments: { country_code: 'SA' } }, + })); + const body = await res.json(); + assert.equal(body.error?.code, -32603, 'vessel API failure must return -32603'); + }); + + it('get_maritime_activity handles empty snapshot gracefully', async () => { + globalThis.fetch = async (url) => { + if (url.toString().includes('/api/maritime/v1/get-vessel-snapshot')) { + return new Response(JSON.stringify({ snapshot: {} }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + return originalFetch(url); + }; + + const res = await handler(makeReq('POST', { + jsonrpc: '2.0', id: 23, method: 'tools/call', + params: { name: 'get_maritime_activity', arguments: { country_code: 'JP' } }, + })); + const body = await res.json(); + const data = JSON.parse(body.result.content[0].text); + assert.equal(data.total_zones, 0); + assert.equal(data.total_disruptions, 0); + assert.deepEqual(data.density_zones, []); + assert.deepEqual(data.disruptions, []); + }); });