Skip to content

Commit 380b495

Browse files
authored
feat(mcp): live airspace + maritime tools; fix OAuth consent UI (koala73#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)
1 parent 3e11cc2 commit 380b495

10 files changed

Lines changed: 2943 additions & 13 deletions

.husky/pre-push

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
#!/usr/bin/env bash
12
# Ensure dependencies are installed (worktrees start with no node_modules)
23
if [ ! -d node_modules ]; then
34
echo "node_modules missing, running npm install..."
@@ -65,14 +66,13 @@ echo "Running architectural boundary check..."
6566
npm run lint:boundaries || exit 1
6667

6768
echo "Running edge function bundle check..."
68-
for f in api/*.js; do
69-
case "$(basename "$f")" in _*) continue;; esac
69+
while IFS= read -r f; do
7070
npx esbuild "$f" --bundle --format=esm --platform=browser --outfile=/dev/null 2>/dev/null || {
7171
echo "ERROR: esbuild failed to bundle $f — this will break Vercel deployment"
7272
npx esbuild "$f" --bundle --format=esm --platform=browser --outfile=/dev/null
7373
exit 1
7474
}
75-
done
75+
done < <(find api/ -name "*.js" -not -name "_*"; find api/ -maxdepth 1 -name "*.ts" -not -name "_*")
7676

7777
echo "Running unit tests..."
7878
npm run test:data || exit 1

api/mcp.ts

Lines changed: 176 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { readJsonFromUpstash } from './_upstash-json.js';
1010
import { resolveApiKeyFromBearer } from './_oauth-token.js';
1111
// @ts-expect-error — JS module, no declaration file
1212
import { timingSafeIncludes } from './_crypto.js';
13+
import COUNTRY_BBOXES from '../shared/country-bboxes.js';
1314

1415
export const config = { runtime: 'edge' };
1516

@@ -306,16 +307,187 @@ const TOOL_REGISTRY: ToolDef[] = [
306307
required: ['country_code'],
307308
},
308309
_execute: async (params, base, apiKey) => {
309-
const res = await fetch(`${base}/api/intelligence/v1/get-country-intel-brief`, {
310+
const UA = 'worldmonitor-mcp-edge/1.0';
311+
const countryCode = String(params.country_code ?? '').toUpperCase().slice(0, 2);
312+
313+
// Fetch current geopolitical headlines to ground the LLM (budget: 2 s — cached endpoint).
314+
// Without context the model hallucinates events — real headlines anchor it.
315+
// 2 s + 22 s brief = 24 s worst-case; 6 s margin before the 30 s Edge kill.
316+
let contextParam = '';
317+
try {
318+
const digestRes = await fetch(`${base}/api/news/v1/list-feed-digest?variant=geo&lang=en`, {
319+
headers: { 'X-WorldMonitor-Key': apiKey, 'User-Agent': UA },
320+
signal: AbortSignal.timeout(2_000),
321+
});
322+
if (digestRes.ok) {
323+
type DigestPayload = { categories?: Record<string, { items?: { title?: string }[] }> };
324+
const digest = await digestRes.json() as DigestPayload;
325+
const headlines = Object.values(digest.categories ?? {})
326+
.flatMap(cat => cat.items ?? [])
327+
.map(item => item.title ?? '')
328+
.filter(Boolean)
329+
.slice(0, 15)
330+
.join('\n');
331+
if (headlines) contextParam = encodeURIComponent(headlines.slice(0, 4000));
332+
}
333+
} catch { /* proceed without context — better than failing */ }
334+
335+
const briefUrl = contextParam
336+
? `${base}/api/intelligence/v1/get-country-intel-brief?context=${contextParam}`
337+
: `${base}/api/intelligence/v1/get-country-intel-brief`;
338+
339+
const res = await fetch(briefUrl, {
310340
method: 'POST',
311-
headers: { 'Content-Type': 'application/json', 'X-WorldMonitor-Key': apiKey, 'User-Agent': 'worldmonitor-mcp-edge/1.0' },
312-
body: JSON.stringify({ country_code: String(params.country_code ?? ''), framework: String(params.framework ?? '') }),
313-
signal: AbortSignal.timeout(25_000),
341+
headers: { 'Content-Type': 'application/json', 'X-WorldMonitor-Key': apiKey, 'User-Agent': UA },
342+
body: JSON.stringify({ country_code: countryCode, framework: String(params.framework ?? '') }),
343+
signal: AbortSignal.timeout(22_000),
314344
});
315345
if (!res.ok) throw new Error(`get-country-intel-brief HTTP ${res.status}`);
316346
return res.json();
317347
},
318348
},
349+
{
350+
name: 'get_airspace',
351+
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?"',
352+
inputSchema: {
353+
type: 'object',
354+
properties: {
355+
country_code: {
356+
type: 'string',
357+
description: 'ISO 3166-1 alpha-2 country code (e.g. "AE", "US", "GB", "JP")',
358+
},
359+
type: {
360+
type: 'string',
361+
enum: ['all', 'civilian', 'military'],
362+
description: 'Filter: all flights (default), civilian only, or military only',
363+
},
364+
},
365+
required: ['country_code'],
366+
},
367+
_execute: async (params, base, apiKey) => {
368+
const code = String(params.country_code ?? '').toUpperCase().slice(0, 2);
369+
const bbox = COUNTRY_BBOXES[code];
370+
if (!bbox) return { error: `Unknown country code: ${code}. Use ISO 3166-1 alpha-2 (e.g. "AE", "US", "GB").` };
371+
const [sw_lat, sw_lon, ne_lat, ne_lon] = bbox;
372+
const type = String(params.type ?? 'all');
373+
const UA = 'worldmonitor-mcp-edge/1.0';
374+
const headers = { 'X-WorldMonitor-Key': apiKey, 'User-Agent': UA };
375+
const bboxQ = `sw_lat=${sw_lat}&sw_lon=${sw_lon}&ne_lat=${ne_lat}&ne_lon=${ne_lon}`;
376+
377+
type CivilianResp = {
378+
positions?: { callsign: string; icao24: string; lat: number; lon: number; altitude_m: number; ground_speed_kts: number; track_deg: number; on_ground: boolean }[];
379+
source?: string;
380+
updated_at?: number;
381+
};
382+
type MilResp = {
383+
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 }[];
384+
};
385+
386+
const [civResult, milResult] = await Promise.allSettled([
387+
type === 'military'
388+
? Promise.resolve(null)
389+
: fetch(`${base}/api/aviation/v1/track-aircraft?${bboxQ}`, { headers, signal: AbortSignal.timeout(8_000) })
390+
.then(r => r.ok ? r.json() as Promise<CivilianResp> : Promise.reject(new Error(`HTTP ${r.status}`))),
391+
type === 'civilian'
392+
? Promise.resolve(null)
393+
: fetch(`${base}/api/military/v1/list-military-flights?${bboxQ}&page_size=100`, { headers, signal: AbortSignal.timeout(8_000) })
394+
.then(r => r.ok ? r.json() as Promise<MilResp> : Promise.reject(new Error(`HTTP ${r.status}`))),
395+
]);
396+
397+
const civOk = type === 'military' || civResult.status === 'fulfilled';
398+
const milOk = type === 'civilian' || milResult.status === 'fulfilled';
399+
400+
// Both sources down — total outage, don't return misleading empty data
401+
if (!civOk && !milOk) throw new Error('Airspace data unavailable: both civilian and military sources failed');
402+
403+
const civ = civResult.status === 'fulfilled' ? civResult.value : null;
404+
const mil = milResult.status === 'fulfilled' ? milResult.value : null;
405+
const warnings: string[] = [];
406+
if (!civOk) warnings.push('civilian ADS-B data unavailable');
407+
if (!milOk) warnings.push('military flight data unavailable');
408+
409+
const civilianFlights = (civ?.positions ?? []).slice(0, 100).map(p => ({
410+
callsign: p.callsign, icao24: p.icao24,
411+
lat: p.lat, lon: p.lon,
412+
altitude_m: p.altitude_m, speed_kts: p.ground_speed_kts,
413+
heading_deg: p.track_deg, on_ground: p.on_ground,
414+
}));
415+
const militaryFlights = (mil?.flights ?? []).slice(0, 100).map(f => ({
416+
callsign: f.callsign, hex_code: f.hex_code,
417+
aircraft_type: f.aircraft_type, aircraft_model: f.aircraft_model,
418+
operator: f.operator, operator_country: f.operator_country,
419+
lat: f.location?.latitude, lon: f.location?.longitude,
420+
altitude: f.altitude, heading: f.heading, speed: f.speed,
421+
is_interesting: f.is_interesting, ...(f.note ? { note: f.note } : {}),
422+
}));
423+
424+
return {
425+
country_code: code,
426+
bounding_box: { sw_lat, sw_lon, ne_lat, ne_lon },
427+
civilian_count: civilianFlights.length,
428+
military_count: militaryFlights.length,
429+
...(type !== 'military' && { civilian_flights: civilianFlights }),
430+
...(type !== 'civilian' && { military_flights: militaryFlights }),
431+
...(warnings.length > 0 && { partial: true, warnings }),
432+
source: civ?.source ?? 'opensky',
433+
updated_at: civ?.updated_at ? new Date(civ.updated_at).toISOString() : new Date().toISOString(),
434+
};
435+
},
436+
},
437+
{
438+
name: 'get_maritime_activity',
439+
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.",
440+
inputSchema: {
441+
type: 'object',
442+
properties: {
443+
country_code: {
444+
type: 'string',
445+
description: 'ISO 3166-1 alpha-2 country code (e.g. "AE", "SA", "JP", "EG")',
446+
},
447+
},
448+
required: ['country_code'],
449+
},
450+
_execute: async (params, base, apiKey) => {
451+
const code = String(params.country_code ?? '').toUpperCase().slice(0, 2);
452+
const bbox = COUNTRY_BBOXES[code];
453+
if (!bbox) return { error: `Unknown country code: ${code}. Use ISO 3166-1 alpha-2 (e.g. "AE", "SA", "JP").` };
454+
const [sw_lat, sw_lon, ne_lat, ne_lon] = bbox;
455+
const bboxQ = `sw_lat=${sw_lat}&sw_lon=${sw_lon}&ne_lat=${ne_lat}&ne_lon=${ne_lon}`;
456+
const headers = { 'X-WorldMonitor-Key': apiKey, 'User-Agent': 'worldmonitor-mcp-edge/1.0' };
457+
458+
type VesselResp = {
459+
snapshot?: {
460+
snapshot_at?: number;
461+
density_zones?: { name: string; intensity: number; ships_per_day: number; delta_pct: number; note: string }[];
462+
disruptions?: { name: string; type: string; severity: string; dark_ships: number; vessel_count: number; region: string; description: string }[];
463+
};
464+
};
465+
466+
const res = await fetch(`${base}/api/maritime/v1/get-vessel-snapshot?${bboxQ}`, {
467+
headers, signal: AbortSignal.timeout(8_000),
468+
});
469+
if (!res.ok) throw new Error(`get-vessel-snapshot HTTP ${res.status}`);
470+
const data = await res.json() as VesselResp;
471+
const snap = data.snapshot ?? {};
472+
473+
return {
474+
country_code: code,
475+
bounding_box: { sw_lat, sw_lon, ne_lat, ne_lon },
476+
snapshot_at: snap.snapshot_at ? new Date(snap.snapshot_at).toISOString() : new Date().toISOString(),
477+
total_zones: (snap.density_zones ?? []).length,
478+
total_disruptions: (snap.disruptions ?? []).length,
479+
density_zones: (snap.density_zones ?? []).map(z => ({
480+
name: z.name, intensity: z.intensity, ships_per_day: z.ships_per_day,
481+
delta_pct: z.delta_pct, ...(z.note ? { note: z.note } : {}),
482+
})),
483+
disruptions: (snap.disruptions ?? []).map(d => ({
484+
name: d.name, type: d.type, severity: d.severity,
485+
dark_ships: d.dark_ships, vessel_count: d.vessel_count,
486+
region: d.region, description: d.description,
487+
})),
488+
};
489+
},
490+
},
319491
{
320492
name: 'analyze_situation',
321493
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.',

api/oauth/authorize.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ const GLOBE_SVG = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" s
8787
const PAGE_HEADERS = { 'Content-Type': 'text/html; charset=utf-8', 'X-Frame-Options': 'DENY', 'Cache-Control': 'no-store', 'Pragma': 'no-cache' };
8888

8989
function htmlError(title, detail) {
90-
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 &#x2014; WorldMonitor</title>
90+
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 &#x2014; WorldMonitor MCP</title>
9191
<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>
92-
<body><a href="https://www.worldmonitor.app" class="wm-logo" target="_blank" rel="noopener">${GLOBE_SVG}<span class="wm-logo-text">WorldMonitor</span></a>
92+
<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>
9393
<div class="card"><h1>${escapeHtml(title)}</h1><p>${escapeHtml(detail)}</p><a href="javascript:history.back()" class="back">&#8592; go back</a></div>
9494
<p class="footer"><a href="https://www.worldmonitor.app" target="_blank" rel="noopener">worldmonitor.app</a></p>
9595
</body></html>`, { status: 400, headers: PAGE_HEADERS });
@@ -100,7 +100,7 @@ function consentPage(params, nonce, errorMsg = '') {
100100
const redirectHost = new URL(redirect_uri).hostname;
101101
return new Response(`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
102102
<meta name="viewport" content="width=device-width,initial-scale=1">
103-
<title>Authorize &#x2014; WorldMonitor</title>
103+
<title>Authorize &#x2014; WorldMonitor MCP</title>
104104
<style>
105105
*{box-sizing:border-box;margin:0;padding:0}
106106
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}
115115
.scope-label{font-size:.65rem;text-transform:uppercase;letter-spacing:.1em;color:#444;margin-bottom:.6rem}
116116
.scope-list{list-style:none}
117117
.scope-list li{font-size:.8rem;color:#666;padding:.2rem 0;display:flex;align-items:flex-start;gap:.5rem}
118-
.scope-list li::before{content:'&#x2192;';color:#2d8a6e;flex-shrink:0;margin-top:.05em}
118+
.scope-list li::before{content:'';color:#2d8a6e;flex-shrink:0;margin-top:.05em}
119119
label{display:block;font-size:.65rem;text-transform:uppercase;letter-spacing:.1em;color:#444;margin-bottom:.4rem}
120120
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}
121121
input[type=password]:focus{border-color:#2d8a6e}
@@ -131,7 +131,7 @@ button:disabled{opacity:.5;cursor:default}
131131
.footer a:hover{color:#555}
132132
</style></head>
133133
<body>
134-
<a href="https://www.worldmonitor.app" class="wm-logo" target="_blank" rel="noopener">${GLOBE_SVG}<span class="wm-logo-text">WorldMonitor</span></a>
134+
<a href="https://www.worldmonitor.app" class="wm-logo" target="_blank" rel="noopener">${GLOBE_SVG}<span class="wm-logo-text">WorldMonitor MCP</span></a>
135135
<div class="card">
136136
<div class="client-hd">
137137
<div class="client-name">${escapeHtml(client_name)} wants access</div>

0 commit comments

Comments
 (0)