mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(mcp): live airspace + maritime tools; fix OAuth consent UI (#2442)
* fix(oauth): fix CSS arrow bullets + add MCP branding to consent page
- CSS content:'\2192' (not HTML entity which doesn't work in CSS)
- Rename logo/title to "WorldMonitor MCP" on both consent and error pages
- Inject real news headlines into get_country_brief to prevent hallucination
Fetches list-feed-digest (4s budget), passes top-15 headlines as ?context=
to get-country-intel-brief; brief timeout reduced to 24s to stay under Edge ceiling
* feat(mcp): add get_airspace + get_maritime_activity live query tools
New tools answer real-time positional questions via existing bbox RPCs:
- get_airspace: civilian ADS-B (OpenSky) + military flights over any country
parallel-fetches track-aircraft + list-military-flights, capped at 100 each
- get_maritime_activity: AIS density zones + disruptions for a country's waters
calls get-vessel-snapshot with country bbox
Country → bounding box resolved via shared/country-bboxes.json (167 entries,
generated from public/data/countries.geojson by scripts/generate-country-bboxes.cjs).
Both API calls use 8s AbortSignal.timeout; get_airspace uses Promise.allSettled
so one failure doesn't block the other.
* docs: fix markdown lint in airspace/maritime plan (blank lines around lists)
* fix(oauth): use literal → in CSS content (\2192 is invalid JS octal in ESM)
* fix(hooks): extend bundle check to api/oauth/ subdirectory (was api/*.js, now uses find)
* fix(mcp): address P1 review findings from PR 2442
- JSON import: add 'with { type: json }' so node --test works without tsx loader
- get_airspace: surface upstream failures; partial outage => partial:true+warnings,
total outage => throw (prevents misleading zero-aircraft response)
- pre-push hook: add #!/usr/bin/env bash shebang (was no shebang, ran as /bin/sh
on Linux CI/contributors; process substitution + [[ ]] require bash)
* fix(mcp): replace JSON import attribute with TS module for Vercel compat
Vercel's esbuild bundler does not support `with { type: 'json' }` import
attributes, causing builds to fail with "Expected ';' but found 'with'".
Fix: generate shared/country-bboxes.ts (typed TS module) alongside the
existing JSON file. The TS import has no attributes and bundles cleanly
with all esbuild versions.
Also extend the pre-push bundle check to include api/*.ts root-level files
so this class of error is caught locally before push.
* fix(mcp): reduce get_country_brief timing budget to 24 s (6 s Edge margin)
Digest pre-fetch: 4 s → 2 s (cached endpoint, silent fallback on miss)
Brief call: 24 s → 22 s
Total worst-case: 24 s vs Vercel Edge 30 s hard kill — was 28 s (2 s margin)
* test(mcp): add coverage for get_airspace and get_maritime_activity
9 new tests:
- get_airspace: happy path, unknown code, partial failure (mil down),
total failure (-32603), type=civilian skips military fetch
- get_maritime_activity: happy path, unknown code, API failure (-32603),
empty snapshot handled gracefully
Also fixes import to use .ts extension so Node --test resolver finds the
country-bboxes module (tsx resolves .ts directly; .js alias only works
under moduleResolution:bundler at typecheck time)
* fix(mcp): use .js + .d.ts for country-bboxes — Vercel rejects .ts imports
Vercel edge bundler refuses .ts extension imports even from .ts edge
functions. Plain .js is the only safe runtime import for edge functions.
Pattern: generate shared/country-bboxes.js (pure ESM, no TS syntax) +
shared/country-bboxes.d.ts (type declaration). TypeScript uses the .d.ts
for tuple types at check time; Vercel and Node --test load the .js at
runtime. The previous .ts module is removed.
* test(mcp): update tool count to 26 (main added search_flights + search_flight_prices_by_date)
This commit is contained in:
@@ -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
|
||||
|
||||
180
api/mcp.ts
180
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<string, { items?: { title?: string }[] }> };
|
||||
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<CivilianResp> : 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<MilResp> : 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.',
|
||||
|
||||
@@ -87,9 +87,9 @@ const GLOBE_SVG = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" s
|
||||
const PAGE_HEADERS = { 'Content-Type': 'text/html; charset=utf-8', 'X-Frame-Options': 'DENY', 'Cache-Control': 'no-store', 'Pragma': 'no-cache' };
|
||||
|
||||
function htmlError(title, detail) {
|
||||
return new Response(`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Error — WorldMonitor</title>
|
||||
return new Response(`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Error — WorldMonitor MCP</title>
|
||||
<style>*{box-sizing:border-box;margin:0;padding:0}body{font-family:ui-monospace,'SF Mono','Cascadia Code',monospace;background:#0a0a0a;color:#e8e8e8;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:1.5rem}.wm-logo{display:flex;align-items:center;gap:.5rem;margin-bottom:2rem;text-decoration:none}.wm-logo svg{color:#2d8a6e}.wm-logo-text{font-size:.75rem;color:#555;letter-spacing:.1em;text-transform:uppercase}.card{width:100%;max-width:420px;background:#111;border:1px solid #1e1e1e;padding:2rem}h1{font-size:.95rem;font-weight:600;color:#ef4444;margin-bottom:.75rem;letter-spacing:.02em}p{font-size:.85rem;color:#666;line-height:1.6}.back{display:inline-block;margin-top:1.5rem;font-size:.75rem;color:#444;text-decoration:none;letter-spacing:.03em}.back:hover{color:#888}.footer{margin-top:1.5rem;font-size:.7rem;color:#2a2a2a;text-align:center}.footer a{color:#333;text-decoration:none}.footer a:hover{color:#555}</style></head>
|
||||
<body><a href="https://www.worldmonitor.app" class="wm-logo" target="_blank" rel="noopener">${GLOBE_SVG}<span class="wm-logo-text">WorldMonitor</span></a>
|
||||
<body><a href="https://www.worldmonitor.app" class="wm-logo" target="_blank" rel="noopener">${GLOBE_SVG}<span class="wm-logo-text">WorldMonitor MCP</span></a>
|
||||
<div class="card"><h1>${escapeHtml(title)}</h1><p>${escapeHtml(detail)}</p><a href="javascript:history.back()" class="back">← go back</a></div>
|
||||
<p class="footer"><a href="https://www.worldmonitor.app" target="_blank" rel="noopener">worldmonitor.app</a></p>
|
||||
</body></html>`, { status: 400, headers: PAGE_HEADERS });
|
||||
@@ -100,7 +100,7 @@ function consentPage(params, nonce, errorMsg = '') {
|
||||
const redirectHost = new URL(redirect_uri).hostname;
|
||||
return new Response(`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Authorize — WorldMonitor</title>
|
||||
<title>Authorize — WorldMonitor MCP</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:ui-monospace,'SF Mono','Cascadia Code',monospace;background:#0a0a0a;color:#e8e8e8;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:1.5rem}
|
||||
@@ -115,7 +115,7 @@ hr{border:none;border-top:1px solid #1e1e1e;margin:1.25rem 0}
|
||||
.scope-label{font-size:.65rem;text-transform:uppercase;letter-spacing:.1em;color:#444;margin-bottom:.6rem}
|
||||
.scope-list{list-style:none}
|
||||
.scope-list li{font-size:.8rem;color:#666;padding:.2rem 0;display:flex;align-items:flex-start;gap:.5rem}
|
||||
.scope-list li::before{content:'→';color:#2d8a6e;flex-shrink:0;margin-top:.05em}
|
||||
.scope-list li::before{content:'→';color:#2d8a6e;flex-shrink:0;margin-top:.05em}
|
||||
label{display:block;font-size:.65rem;text-transform:uppercase;letter-spacing:.1em;color:#444;margin-bottom:.4rem}
|
||||
input[type=password]{width:100%;padding:.65rem .75rem;background:#0a0a0a;border:1px solid #2a2a2a;color:#e8e8e8;font-family:inherit;font-size:.9rem;outline:none;border-radius:0}
|
||||
input[type=password]:focus{border-color:#2d8a6e}
|
||||
@@ -131,7 +131,7 @@ button:disabled{opacity:.5;cursor:default}
|
||||
.footer a:hover{color:#555}
|
||||
</style></head>
|
||||
<body>
|
||||
<a href="https://www.worldmonitor.app" class="wm-logo" target="_blank" rel="noopener">${GLOBE_SVG}<span class="wm-logo-text">WorldMonitor</span></a>
|
||||
<a href="https://www.worldmonitor.app" class="wm-logo" target="_blank" rel="noopener">${GLOBE_SVG}<span class="wm-logo-text">WorldMonitor MCP</span></a>
|
||||
<div class="card">
|
||||
<div class="client-hd">
|
||||
<div class="client-name">${escapeHtml(client_name)} wants access</div>
|
||||
|
||||
@@ -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<string, [number,number,number,number]>)[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<CivilianResp> : 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<MilResp> : 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<string, [number,number,number,number]>)[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 <key>" \
|
||||
-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 <key>" \
|
||||
-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)
|
||||
65
scripts/generate-country-bboxes.cjs
Normal file
65
scripts/generate-country-bboxes.cjs
Normal file
@@ -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<string, [number, number, number, number]>;',
|
||||
'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}`);
|
||||
1004
scripts/shared/country-bboxes.json
Normal file
1004
scripts/shared/country-bboxes.json
Normal file
File diff suppressed because it is too large
Load Diff
3
shared/country-bboxes.d.ts
vendored
Normal file
3
shared/country-bboxes.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
// Auto-generated by scripts/generate-country-bboxes.cjs — do not edit manually
|
||||
declare const COUNTRY_BBOXES: Record<string, [number, number, number, number]>;
|
||||
export default COUNTRY_BBOXES;
|
||||
172
shared/country-bboxes.js
Normal file
172
shared/country-bboxes.js
Normal file
@@ -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;
|
||||
1004
shared/country-bboxes.json
Normal file
1004
shared/country-bboxes.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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, []);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user