mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(news/parser): extract RSS/Atom description for LLM grounding (U1)
Add description field to ParsedItem, extract from the first non-empty of
description/content:encoded (RSS) or summary/content (Atom), picking the
longest after HTML-strip + entity-decode + whitespace-normalize. Clip to
400 chars. Reject empty, <40 chars after strip, or normalize-equal to the
headline — downstream consumers fall back to the cleaned headline on '',
preserving current behavior for feeds without a description.
CDATA end is anchored to the closing tag so internal ]]> sequences do not
truncate the match. Preserves cached rss:feed:v1 row compatibility during
the 1h TTL bleed since the field is additive.
Part of fix: pipe RSS description end-to-end so LLM surfaces stop
hallucinating named actors (docs/plans/2026-04-24-001-...).
Covers R1, R7.
* feat(news/story-track): persist description on story:track:v1 HSET (U2)
Append description to the story:track:v1 HSET only when non-empty. Additive
— no key version bump. Old rows and rows from feeds without a description
return undefined on HGETALL, letting downstream readers fall back to the
cleaned headline (R6).
Extract buildStoryTrackHsetFields as a pure helper so the inclusion gate is
unit-testable without Redis.
Update the contract comment in cache-keys.ts so the next reader of the
schema sees description as an optional field.
Covers R2, R6.
* feat(proto): NewsItem.snippet + SummarizeArticleRequest.bodies (U3)
Add two additive proto fields so the article description can ride to every
LLM-adjacent consumer without a breaking change:
- NewsItem.snippet (field 12): RSS/Atom description, HTML-stripped,
≤400 chars, empty when unavailable. Wired on toProtoItem.
- SummarizeArticleRequest.bodies (field 8): optional article bodies
paired 1:1 with headlines for prompt grounding. Empty array is today's
headline-only behavior.
Regenerated TS client/server stubs and OpenAPI YAML/JSON via sebuf v0.11.1
(PATH=~/go/bin required — Homebrew's protoc-gen-openapiv3 is an older
pre-bundle-mode build that collides on duplicate emission).
Pre-emptive bodies:[] placeholders at the two existing SummarizeArticle
call sites in src/services/summarization.ts; U6 replaces them with real
article bodies once SummarizeArticle handler reads the field.
Covers R3, R5.
* feat(brief/digest): forward RSS description end-to-end through brief envelope (U4)
Digest accumulator reader (seed-digest-notifications.mjs::buildDigest) now
plumbs the optional `description` field off each story:track:v1 HGETALL into
the digest story object. The brief adapter (brief-compose.mjs::
digestStoryToUpstreamTopStory) prefers the real RSS description over the
cleaned headline; when the upstream row has no description (old rows in the
48h bleed, feeds that don't carry one), we fall back to the cleaned headline
so today behavior is preserved (R6).
This is the upstream half of the description cache path. U5 lands the LLM-
side grounding + cache-prefix bump so Gemini actually sees the article body
instead of hallucinating a named actor from the headline.
Covers R4 (upstream half), R6.
* feat(brief/llm): RSS grounding + sanitisation + 4 cache prefix bumps (U5)
The actual fix for the headline-only named-actor hallucination class:
Gemini 2.5 Flash now receives the real article body as grounding context,
so it paraphrases what the article says instead of filling role-label
headlines from parametric priors ("Iran's new supreme leader" → "Ali
Khamenei" was the 2026-04-24 reproduction; with grounding, it becomes
the actual article-named actor).
Changes:
- buildStoryDescriptionPrompt interpolates a `Context: <body>` line
between the metadata block and the "One editorial sentence" instruction
when description is non-empty AND not normalise-equal to the headline.
Clips to 400 chars as a second belt-and-braces after the U1 parser cap.
No Context line → identical prompt to pre-fix (R6 preserved).
- sanitizeStoryForPrompt extended to cover `description`. Closes the
asymmetry where whyMatters was sanitised and description wasn't —
untrusted RSS bodies now flow through the same injection-marker
neutraliser before prompt interpolation. generateStoryDescription wraps
the story in sanitizeStoryForPrompt before calling the builder,
matching generateWhyMatters.
- Four cache prefixes bumped atomically to evict pre-grounding rows:
scripts/lib/brief-llm.mjs:
brief:llm:description:v1 → v2 (Railway, description path)
brief:llm:whymatters:v2 → v3 (Railway, whyMatters fallback)
api/internal/brief-why-matters.ts:
brief:llm:whymatters:v6 → v7 (edge, primary)
brief:llm:whymatters:shadow:v4 → shadow:v5 (edge, shadow)
hashBriefStory already includes description in the 6-field material
(v5 contract) so identity naturally drifts; the prefix bump is the
belt-and-braces that guarantees a clean cold-start on first tick.
- Tests: 8 new + 2 prefix-match updates on tests/brief-llm.test.mjs.
Covers Context-line injection, empty/dup-of-headline rejection,
400-char clip, sanitisation of adversarial descriptions, v2 write,
and legacy-v1 row dark (forced cold-start).
Covers R4 + new sanitisation requirement.
* feat(news/summarize): accept bodies + bump summary cache v5→v6 (U6)
SummarizeArticle now grounds on per-headline article bodies when callers
supply them, so the dashboard "News summary" path stops hallucinating
across unrelated headlines when the upstream RSS carried context.
Three coordinated changes:
1. SummarizeArticleRequest handler reads req.bodies, sanitises each entry
through sanitizeForPrompt (same trust treatment as geoContext — bodies
are untrusted RSS text), clips to 400 chars, and pads to the headlines
length so pair-wise identity is stable.
2. buildArticlePrompts accepts optional bodies and interleaves a
` Context: <body>` line under each numbered headline that has a
non-empty body. Skipped in translate mode (headline[0]-only) and when
all bodies are empty — yielding a byte-identical prompt to pre-U6
for every current caller (R6 preserved).
3. summary-cache-key bumps CACHE_VERSION v5→v6 so the pre-grounding rows
(produced from headline-only prompts) cold-start cleanly. Extends
canonicalizeSummaryInputs + buildSummaryCacheKey with a pair-wise
bodies segment `:bd<hash>`; the prefix is `:bd` rather than `:b` to
avoid colliding with `:brief:` when pattern-matching keys. Translate
mode is headline[0]-only and intentionally does not shift on bodies.
Dedup reorder preserved: the handler re-pairs bodies to the deduplicated
top-5 via findIndex, so layout matches without breaking cache identity.
New tests: 7 on buildArticlePrompts (bodies interleave, partial fill,
translate-mode skip, clip, short-array tolerance), 8 on
buildSummaryCacheKey (pair-wise sort, cache-bust on body drift, translate
skip). Existing summary-cache-key assertions updated v5→v6.
Covers R3, R4.
* feat(consumers): surface RSS snippet across dashboard, email, relay, MCP + audit (U7)
Thread the RSS description from the ingestion path (U1-U5) into every
user-facing LLM-adjacent surface. Audit the notification producers so
RSS-origin and domain-origin events stay on distinct contracts.
Dashboard (proto snippet → client → panel):
- src/types/index.ts NewsItem.snippet?:string (client-side field).
- src/app/data-loader.ts proto→client mapper propagates p.snippet.
- src/components/NewsPanel.ts renders snippet as a truncated (~200 chars,
word-boundary ellipsis) `.item-snippet` line under each headline.
- NewsPanel.currentBodies tracks per-headline bodies paired 1:1 with
currentHeadlines; passed as options.bodies to generateSummary so the
server-side SummarizeArticle LLM grounds on the article body.
Summary plumbing:
- src/services/summarization.ts threads bodies through SummarizeOptions
→ generateSummary → runApiChain → tryApiProvider; cache key now includes
bodies (via U6's buildSummaryCacheKey signature).
MCP world-brief:
- api/mcp.ts pairs headlines with their RSS snippets and POSTs `bodies`
to /api/news/v1/summarize-article so the MCP tool surface is no longer
starved.
Email digest:
- scripts/seed-digest-notifications.mjs plain-text formatDigest appends
a ~200-char truncated snippet line under each story; HTML formatDigestHtml
renders a dim-grey description div between title and meta. Both gated
on non-empty description (R6 — empty → today's behavior).
Real-time alerts:
- src/services/breaking-news-alerts.ts BreakingAlert gains optional
description; checkBatchForBreakingAlerts reads item.snippet; dispatchAlert
includes `description` in the /api/notify payload when present.
Notification relay:
- scripts/notification-relay.cjs formatMessage gated on
NOTIFY_RELAY_INCLUDE_SNIPPET=1 (default off). When on, RSS-origin
payloads render a `> <snippet>` context line under the title. When off
or payload.description absent, output is byte-identical to pre-U7.
Audit (RSS vs domain):
- tests/notification-relay-payload-audit.test.mjs enforces file-level
@notification-source tags on every producer, rejects `description:` in
domain-origin payload blocks, and verifies the relay codepath gates
snippet rendering under the flag.
- Tag added to ais-relay.cjs (domain), seed-aviation.mjs (domain),
alert-emitter.mjs (domain), breaking-news-alerts.ts (rss).
Deferred (plan explicitly flags): InsightsPanel + cluster-producer
plumbing (bodies default to [] — will unlock gradually once news:insights:v1
producer also carries primarySnippet).
Covers R5, R6.
* docs+test: grounding-path note + bump pinned CACHE_VERSION v5→v6 (U8)
Final verification for the RSS-description-end-to-end fix:
- docs/architecture.mdx — one-paragraph "News Grounding Pipeline"
subsection tracing parser → story:track:v1.description → NewsItem.snippet
→ brief / SummarizeArticle / dashboard / email / relay / MCP, with the
empty-description R6 fallback rule called out explicitly.
- tests/summarize-reasoning.test.mjs — Fix-4 static-analysis pin updated
to match the v6 bump from U6. Without this the summary cache bump silently
regressed CI's pinned-version assertion.
Final sweep (2026-04-24):
- grep -rn 'brief:llm:description:v1' → only in the U5 legacy-row test
simulation (by design: proves the v2 bump forces cold-start).
- grep -rn 'brief:llm:whymatters:v2/v6/shadow:v4' → no live references.
- grep -rn 'summary:v5' → no references.
- CACHE_VERSION = 'v6' in src/utils/summary-cache-key.ts.
- Full tsx --test sweep across all tests/*.test.{mjs,mts}: 6747/6747 pass.
- npm run typecheck + typecheck:api: both clean.
Covers R4, R6, R7.
* fix(rss-description): address /ce:review findings before merge
14 fixes from structured code review across 13 reviewer personas.
Correctness-critical (P1 — fixes that prevent R6/U7 contract violations):
- NewsPanel signature covers currentBodies so view-mode toggles that leave
headlines identical but bodies different now invalidate in-flight summaries.
Without this, switching renderItems → renderClusters mid-summary let a
grounded response arrive under a stale (now-orphaned) cache key.
- summarize-article.ts re-pairs bodies with headlines BEFORE dedup via a
single zip-sanitize-filter-dedup pass. Previously bodies[] was indexed by
position in light-sanitized headlines while findIndex looked up the
full-sanitized array — any headline that sanitizeHeadlines emptied
mispaired every subsequent body, grounding the LLM on the wrong story.
- Client skips the pre-chain cache lookup when bodies are present, since
client builds keys from RAW bodies while server sanitizes first. The
keys diverge on injection content, which would silently miss the
server's authoritative cache every call.
Test + audit hardening:
- Legacy v1 eviction test now uses the real hashBriefStory(story()) suffix
instead of a literal "somehash", so a bug where the reader still queried
the v1 prefix at the real key would actually be caught.
- tests/summary-cache-key.test.mts adds 400-char clip identity coverage so
the canonicalizer's clip and any downstream clip can't silently drift.
- tests/news-rss-description-extract.test.mts renames the well-formed
CDATA test and adds a new test documenting the malformed-]]> fallback
behavior (plain regex captures, article content survives).
Safe_auto cleanups:
- Deleted dead SNIPPET_PUSH_MAX constant in notification-relay.cjs.
- BETA-mode groq warm call now passes bodies, warming the right cache slot.
- seed-digest shares a local normalize-equality helper for description !=
headline comparison, matching the parser's contract.
- Pair-wise sort in summary-cache-key tie-breaks on body so duplicate
headlines produce stable order across runs.
- buildSummaryCacheKey gained JSDoc documenting the client/server contract
and the bodies parameter semantics.
- MCP get_world_brief tool description now mentions RSS article-body
grounding so calling agents see the current contract.
- _shared.ts `opts.bodies![i]!` double-bang replaced with `?? ''`.
- extractRawTagBody regexes cached in module-level Map, mirroring the
existing TAG_REGEX_CACHE pattern.
Deferred to follow-up (tracked for PR description / separate issue):
- Promote shared MAX_BODY constant across the 5 clip sites
- Promote shared truncateForDisplay helper across 4 render sites
- Collapse NewsPanel.{currentHeadlines, currentBodies} → Array<{title, snippet}>
- Promote sanitizeStoryForPrompt to shared/brief-llm-core.js
- Split list-feed-digest.ts parser helpers into sibling -utils.ts
- Strengthen audit test: forward-sweep + behavioral gate test
Tests: 6749/6749 pass. Typecheck clean on both configs.
* fix(summarization): thread bodies through browser T5 path (Codex #2)
Addresses the second of two Codex-raised findings on PR #3370:
The PR threaded bodies through the server-side API provider chain
(Ollama → Groq → OpenRouter → /api/news/v1/summarize-article) but the
local browser T5 path at tryBrowserT5 was still summarising from
headlines alone. In BETA_MODE that ungrounded path runs BEFORE the
grounded server providers; in normal mode it remains the last
fallback. Whenever T5-small won, the dashboard summary surface
regressed to the headline-only path — the exact hallucination class
this PR exists to eliminate.
Fix: tryBrowserT5 accepts an optional `bodies` parameter and
interleaves each body with its paired headline via a `headline —
body` separator in the combined text (clipped to 200 chars per body
to stay within T5-small's ~512-token context window). All three call
sites (BETA warm, BETA cold, normal-mode fallback) now pass the
bodies threaded down from generateSummary options.bodies.
When bodies is empty/omitted, the combined text is byte-identical to
pre-fix (R6 preserved).
On Codex finding #1 (story:track:v1 additive-only HSET keeps a body
from an earlier mention of the same normalized title), declining to
change. The current rule — "if this mention has a body, overwrite;
otherwise leave the prior body alone" — is defensible: a body from
mention A is not falsified by mention B being body-less (a wire
reprint doesn't invalidate the original source's body). A feed that
publishes a corrected headline creates a new normalized-title hash,
so no stale body carries forward. The failure window is narrow (live
story evolving while keeping the same title through hours of
body-less wire reprints) and the 7-day STORY_TTL is the backstop.
Opening a follow-up issue to revisit semantics if real-world evidence
surfaces a stale-grounding case.
* fix(story-track): description always-written to overwrite stale bodies (Codex #1)
Revisiting Codex finding #1 on PR #3370 after re-review. The previous
response declined the fix with reasoning; on reflection the argument
was over-defending the current behavior.
Problem: buildStoryTrackHsetFields previously wrote `description` only
when non-empty. Because story:track:v1 rows are collapsed by
normalized-title hash, an earlier mention's body would persist for up
to STORY_TTL (7 days) on subsequent body-less mentions of the same
story. Consumers reading `track.description` via HGETALL could not
distinguish "this mention's body" from "some mention's body from the
last week," silently grounding brief / whyMatters / SummarizeArticle
LLMs on text the current mention never supplied. That violates the
grounding contract advertised to every downstream surface in this PR.
Fix: HSET `description` unconditionally on every mention — empty
string when the current item has no body, real body when it does. An
empty value overwrites any prior mention's body so the row is always
authoritative for the current cycle. Consumers continue to treat
empty description as "fall back to cleaned headline" (R6 preserved).
The 7-day STORY_TTL and normalized-title hash semantics are unchanged.
Trade-off accepted: a valid body from Feed A (NYT) is wiped when Feed
B (AP body-less wire reprint) arrives for the same normalized title,
even though Feed A's body is factually correct. Rationale: the
alternative — keeping Feed A's body indefinitely — means the user
sees Feed A's body attributed (by proximity) to an AP mention at a
later timestamp, which is at minimum misleading and at worst carries
retracted/corrected details. Honest absence beats unlabeled presence.
Tests: new stale-body overwrite sequence test (T0 body → T1 empty →
T2 new body), existing "writes description when non-empty" preserved,
existing "omits when empty" inverted to "writes empty, overwriting."
cache-keys.ts contract comment updated to mark description as
always-written rather than optional.
964 lines
47 KiB
TypeScript
964 lines
47 KiB
TypeScript
import { Ratelimit } from '@upstash/ratelimit';
|
||
import { Redis } from '@upstash/redis';
|
||
// @ts-expect-error — JS module, no declaration file
|
||
import { getPublicCorsHeaders } from './_cors.js';
|
||
// @ts-expect-error — JS module, no declaration file
|
||
import { jsonResponse } from './_json-response.js';
|
||
// @ts-expect-error — JS module, no declaration file
|
||
import { readJsonFromUpstash } from './_upstash-json.js';
|
||
// @ts-expect-error — JS module, no declaration file
|
||
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';
|
||
// @ts-expect-error — generated JS module, no declaration file
|
||
import MINING_SITES_RAW from '../shared/mining-sites.js';
|
||
|
||
export const config = { runtime: 'edge' };
|
||
|
||
const MCP_PROTOCOL_VERSION = '2025-03-26';
|
||
const SERVER_NAME = 'worldmonitor';
|
||
const SERVER_VERSION = '1.0';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Per-key rate limiter (60 calls/min per PRO API key)
|
||
// ---------------------------------------------------------------------------
|
||
let mcpRatelimit: Ratelimit | null = null;
|
||
|
||
function getMcpRatelimit(): Ratelimit | null {
|
||
if (mcpRatelimit) return mcpRatelimit;
|
||
const url = process.env.UPSTASH_REDIS_REST_URL;
|
||
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
|
||
if (!url || !token) return null;
|
||
mcpRatelimit = new Ratelimit({
|
||
redis: new Redis({ url, token }),
|
||
limiter: Ratelimit.slidingWindow(60, '60 s'),
|
||
prefix: 'rl:mcp',
|
||
analytics: false,
|
||
});
|
||
return mcpRatelimit;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tool registry
|
||
// ---------------------------------------------------------------------------
|
||
interface BaseToolDef {
|
||
name: string;
|
||
description: string;
|
||
inputSchema: { type: string; properties: Record<string, unknown>; required: string[] };
|
||
}
|
||
|
||
interface FreshnessCheck {
|
||
key: string;
|
||
maxStaleMin: number;
|
||
}
|
||
|
||
// Cache-read tool: reads one or more Redis keys and returns them with staleness info.
|
||
interface CacheToolDef extends BaseToolDef {
|
||
_cacheKeys: string[];
|
||
_seedMetaKey: string;
|
||
_maxStaleMin: number;
|
||
_freshnessChecks?: FreshnessCheck[];
|
||
_execute?: never;
|
||
}
|
||
|
||
// AI inference tool: calls an internal RPC endpoint and returns the raw response.
|
||
interface RpcToolDef extends BaseToolDef {
|
||
_cacheKeys?: never;
|
||
_seedMetaKey?: never;
|
||
_maxStaleMin?: never;
|
||
_freshnessChecks?: never;
|
||
_execute: (params: Record<string, unknown>, base: string, apiKey: string) => Promise<unknown>;
|
||
}
|
||
|
||
type ToolDef = CacheToolDef | RpcToolDef;
|
||
|
||
const TOOL_REGISTRY: ToolDef[] = [
|
||
{
|
||
name: 'get_market_data',
|
||
description: 'Real-time equity quotes, commodity prices (including gold futures GC=F), crypto prices, forex FX rates (USD/EUR, USD/JPY etc.), sector performance, ETF flows, and Gulf market quotes from WorldMonitor\'s curated bootstrap cache.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: [
|
||
'market:stocks-bootstrap:v1',
|
||
'market:commodities-bootstrap:v1',
|
||
'market:crypto:v1',
|
||
'market:sectors:v2',
|
||
'market:etf-flows:v1',
|
||
'market:gulf-quotes:v1',
|
||
'market:fear-greed:v1',
|
||
],
|
||
_seedMetaKey: 'seed-meta:market:stocks',
|
||
_maxStaleMin: 30,
|
||
},
|
||
{
|
||
name: 'get_conflict_events',
|
||
description: 'Active armed conflict events (UCDP, Iran), unrest events with geo-coordinates, and country risk scores. Covers ongoing conflicts, protests, and instability indices worldwide.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: [
|
||
'conflict:ucdp-events:v1',
|
||
'conflict:iran-events:v1',
|
||
'unrest:events:v1',
|
||
'risk:scores:sebuf:stale:v1',
|
||
],
|
||
_seedMetaKey: 'seed-meta:conflict:ucdp-events',
|
||
_maxStaleMin: 30,
|
||
},
|
||
{
|
||
name: 'get_aviation_status',
|
||
description: 'Airport delays, NOTAM airspace closures, and tracked military aircraft. Covers FAA delay data and active airspace restrictions.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: ['aviation:delays-bootstrap:v1'],
|
||
_seedMetaKey: 'seed-meta:aviation:faa',
|
||
_maxStaleMin: 90,
|
||
},
|
||
{
|
||
name: 'get_news_intelligence',
|
||
description: 'AI-classified geopolitical threat news summaries, GDELT intelligence signals, cross-source signals, and security advisories from WorldMonitor\'s intelligence layer.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: [
|
||
'news:insights:v1',
|
||
'intelligence:gdelt-intel:v1',
|
||
'intelligence:cross-source-signals:v1',
|
||
'intelligence:advisories-bootstrap:v1',
|
||
],
|
||
_seedMetaKey: 'seed-meta:news:insights',
|
||
_maxStaleMin: 30,
|
||
},
|
||
{
|
||
name: 'get_natural_disasters',
|
||
description: 'Recent earthquakes (USGS), active wildfires (NASA FIRMS), and natural hazard events. Includes magnitude, location, and threat severity.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: [
|
||
'seismology:earthquakes:v1',
|
||
'wildfire:fires:v1',
|
||
'natural:events:v1',
|
||
],
|
||
_seedMetaKey: 'seed-meta:seismology:earthquakes',
|
||
_maxStaleMin: 30,
|
||
},
|
||
{
|
||
name: 'get_military_posture',
|
||
description: 'Theater posture assessment and military risk scores. Reflects aggregated military positioning and escalation signals across global theaters.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: ['theater_posture:sebuf:stale:v1'],
|
||
_seedMetaKey: 'seed-meta:intelligence:risk-scores',
|
||
_maxStaleMin: 120,
|
||
},
|
||
{
|
||
name: 'get_cyber_threats',
|
||
description: 'Active cyber threat intelligence: malware IOCs (URLhaus, Feodotracker), CISA known exploited vulnerabilities, and active command-and-control infrastructure.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: ['cyber:threats-bootstrap:v2'],
|
||
_seedMetaKey: 'seed-meta:cyber:threats',
|
||
_maxStaleMin: 240,
|
||
},
|
||
{
|
||
name: 'get_economic_data',
|
||
description: 'Macro economic indicators: Fed Funds rate (FRED), economic calendar events, fuel prices, ECB FX rates, EU yield curve, earnings calendar, COT positioning, energy storage data, BIS household debt service ratio (DSR, quarterly, leading indicator of household financial stress across ~40 advanced economies), and BIS residential + commercial property price indices (real, quarterly).',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: [
|
||
'economic:fred:v1:FEDFUNDS:0',
|
||
'economic:econ-calendar:v1',
|
||
'economic:fuel-prices:v1',
|
||
'economic:ecb-fx-rates:v1',
|
||
'economic:yield-curve-eu:v1',
|
||
'economic:spending:v1',
|
||
'market:earnings-calendar:v1',
|
||
'market:cot:v1',
|
||
'economic:bis:dsr:v1',
|
||
'economic:bis:property-residential:v1',
|
||
'economic:bis:property-commercial:v1',
|
||
],
|
||
_seedMetaKey: 'seed-meta:economic:econ-calendar',
|
||
_maxStaleMin: 1440,
|
||
_freshnessChecks: [
|
||
{ key: 'seed-meta:economic:econ-calendar', maxStaleMin: 1440 },
|
||
// Per-dataset BIS seed-meta keys — the aggregate
|
||
// `seed-meta:economic:bis-extended` would report "fresh" even if only
|
||
// one of the three datasets (DSR / SPP / CPP) is current, matching the
|
||
// false-freshness bug already fixed for /api/health and resilience.
|
||
{ key: 'seed-meta:economic:bis-dsr', maxStaleMin: 1440 }, // 12h cron × 2
|
||
{ key: 'seed-meta:economic:bis-property-residential', maxStaleMin: 1440 },
|
||
{ key: 'seed-meta:economic:bis-property-commercial', maxStaleMin: 1440 },
|
||
],
|
||
},
|
||
{
|
||
name: 'get_country_macro',
|
||
description: 'Per-country macroeconomic indicators from IMF WEO (~210 countries, monthly cadence). Bundles fiscal/external balance (inflation, current account, gov revenue/expenditure/primary balance, CPI), growth & per-capita (real GDP growth, GDP/capita USD & PPP, savings & investment rates, savings-investment gap), labor & demographics (unemployment, population), and external trade (current account USD, import/export volume % changes). Latest available year per series. Use for country-level economic screening, peer benchmarking, and stagflation/imbalance flags. NOTE: export/import LEVELS in USD (exportsUsd, importsUsd, tradeBalanceUsd) are returned as null — WEO retracted broad coverage for BX/BM indicators in 2026-04; use currentAccountUsd or volume changes (import/exportVolumePctChg) instead.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: [
|
||
'economic:imf:macro:v2',
|
||
'economic:imf:growth:v1',
|
||
'economic:imf:labor:v1',
|
||
'economic:imf:external:v1',
|
||
],
|
||
_seedMetaKey: 'seed-meta:economic:imf-macro',
|
||
_maxStaleMin: 100800, // monthly WEO release; 70d = 2× interval (absorbs one missed run)
|
||
_freshnessChecks: [
|
||
{ key: 'seed-meta:economic:imf-macro', maxStaleMin: 100800 },
|
||
{ key: 'seed-meta:economic:imf-growth', maxStaleMin: 100800 },
|
||
{ key: 'seed-meta:economic:imf-labor', maxStaleMin: 100800 },
|
||
{ key: 'seed-meta:economic:imf-external', maxStaleMin: 100800 },
|
||
],
|
||
},
|
||
{
|
||
name: 'get_eu_housing_cycle',
|
||
description: 'Eurostat annual house price index (prc_hpi_a, base 2015=100) for all 27 EU members plus EA20 and EU27_2020 aggregates. Each country entry includes the latest value, prior value, date, unit, and a 10-year sparkline series. Complements BIS WS_SPP with broader EU coverage for the Housing cycle tile.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: ['economic:eurostat:house-prices:v1'],
|
||
_seedMetaKey: 'seed-meta:economic:eurostat-house-prices',
|
||
_maxStaleMin: 60 * 24 * 50, // weekly cron, annual data
|
||
},
|
||
{
|
||
name: 'get_eu_quarterly_gov_debt',
|
||
description: 'Eurostat quarterly general government gross debt (gov_10q_ggdebt, %GDP) for all 27 EU members plus EA20 and EU27_2020 aggregates. Each country entry includes latest value, prior value, quarter label, and an 8-quarter sparkline series. Provides fresher debt-trajectory signal than annual IMF GGXWDG_NGDP for EU panels.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: ['economic:eurostat:gov-debt-q:v1'],
|
||
_seedMetaKey: 'seed-meta:economic:eurostat-gov-debt-q',
|
||
_maxStaleMin: 60 * 24 * 14, // quarterly data, 2-day cron
|
||
},
|
||
{
|
||
name: 'get_eu_industrial_production',
|
||
description: 'Eurostat monthly industrial production index (sts_inpr_m, NACE B-D industry excl. construction, SCA, base 2021=100) for all 27 EU members plus EA20 and EU27_2020 aggregates. Each country entry includes latest value, prior value, month label, and a 12-month sparkline series. Leading indicator of real-economy activity used by the "Real economy pulse" sparkline.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: ['economic:eurostat:industrial-production:v1'],
|
||
_seedMetaKey: 'seed-meta:economic:eurostat-industrial-production',
|
||
_maxStaleMin: 60 * 24 * 5, // monthly data, daily cron
|
||
},
|
||
{
|
||
name: 'get_prediction_markets',
|
||
description: 'Active Polymarket event contracts with current probabilities. Covers geopolitical, economic, and election prediction markets.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: ['prediction:markets-bootstrap:v1'],
|
||
_seedMetaKey: 'seed-meta:prediction:markets',
|
||
_maxStaleMin: 90,
|
||
},
|
||
{
|
||
name: 'get_sanctions_data',
|
||
description: 'OFAC SDN sanctioned entities list and sanctions pressure scores by country. Useful for compliance screening and geopolitical pressure analysis.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: ['sanctions:entities:v1', 'sanctions:pressure:v1'],
|
||
_seedMetaKey: 'seed-meta:sanctions:entities',
|
||
_maxStaleMin: 1440,
|
||
},
|
||
{
|
||
name: 'get_climate_data',
|
||
description: 'Climate intelligence: temperature/precipitation anomalies (vs 30-year WMO normals), climate-relevant disaster alerts (ReliefWeb/GDACS/FIRMS), atmospheric CO2 trend (NOAA Mauna Loa), air quality (OpenAQ/WAQI PM2.5 stations), Arctic sea ice extent and ocean heat indicators (NSIDC/NOAA), weather alerts, and climate news.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: ['climate:anomalies:v2', 'climate:disasters:v1', 'climate:co2-monitoring:v1', 'climate:air-quality:v1', 'climate:ocean-ice:v1', 'climate:news-intelligence:v1', 'weather:alerts:v1'],
|
||
_seedMetaKey: 'seed-meta:climate:co2-monitoring',
|
||
_maxStaleMin: 2880,
|
||
_freshnessChecks: [
|
||
{ key: 'seed-meta:climate:anomalies', maxStaleMin: 120 },
|
||
{ key: 'seed-meta:climate:disasters', maxStaleMin: 720 },
|
||
{ key: 'seed-meta:climate:co2-monitoring', maxStaleMin: 2880 },
|
||
{ key: 'seed-meta:health:air-quality', maxStaleMin: 180 },
|
||
{ key: 'seed-meta:climate:ocean-ice', maxStaleMin: 1440 },
|
||
{ key: 'seed-meta:climate:news-intelligence', maxStaleMin: 90 },
|
||
{ key: 'seed-meta:weather:alerts', maxStaleMin: 45 },
|
||
],
|
||
},
|
||
{
|
||
name: 'get_infrastructure_status',
|
||
description: 'Internet infrastructure health: Cloudflare Radar outages and service status for major cloud providers and internet services.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: ['infra:outages:v1'],
|
||
_seedMetaKey: 'seed-meta:infra:outages',
|
||
_maxStaleMin: 30,
|
||
},
|
||
{
|
||
name: 'get_supply_chain_data',
|
||
description: 'Dry bulk shipping stress index, customs revenue flows, and COMTRADE bilateral trade data. Tracks global supply chain pressure and trade disruptions.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: [
|
||
'supply_chain:shipping_stress:v1',
|
||
'trade:customs-revenue:v1',
|
||
'comtrade:flows:v1',
|
||
],
|
||
_seedMetaKey: 'seed-meta:trade:customs-revenue',
|
||
_maxStaleMin: 2880,
|
||
},
|
||
{
|
||
name: 'get_positive_events',
|
||
description: 'Positive geopolitical events: diplomatic agreements, humanitarian aid, development milestones, and peace initiatives worldwide.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: ['positive_events:geo-bootstrap:v1'],
|
||
_seedMetaKey: 'seed-meta:positive-events:geo',
|
||
_maxStaleMin: 60,
|
||
},
|
||
{
|
||
name: 'get_radiation_data',
|
||
description: 'Radiation observation levels from global monitoring stations. Flags anomalous readings that may indicate nuclear incidents.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: ['radiation:observations:v1'],
|
||
_seedMetaKey: 'seed-meta:radiation:observations',
|
||
_maxStaleMin: 30,
|
||
},
|
||
{
|
||
name: 'get_research_signals',
|
||
description: 'Tech and research event signals: emerging technology events bootstrap data from curated research feeds.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: ['research:tech-events-bootstrap:v1'],
|
||
_seedMetaKey: 'seed-meta:research:tech-events',
|
||
_maxStaleMin: 480,
|
||
},
|
||
{
|
||
name: 'get_forecast_predictions',
|
||
description: 'AI-generated geopolitical and economic forecasts from WorldMonitor\'s predictive models. Covers upcoming risk events and probability assessments.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: ['forecast:predictions:v2'],
|
||
_seedMetaKey: 'seed-meta:forecast:predictions',
|
||
_maxStaleMin: 90,
|
||
},
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Social velocity — cache read (Reddit signals, seeded by relay)
|
||
// -------------------------------------------------------------------------
|
||
{
|
||
name: 'get_social_velocity',
|
||
description: 'Reddit geopolitical social velocity: top posts from worldnews, geopolitics, and related subreddits with engagement scores and trend signals.',
|
||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||
_cacheKeys: ['intelligence:social:reddit:v1'],
|
||
_seedMetaKey: 'seed-meta:intelligence:social-reddit',
|
||
_maxStaleMin: 30,
|
||
},
|
||
|
||
// -------------------------------------------------------------------------
|
||
// AI inference tools — call LLM endpoints, not cached Redis reads
|
||
// -------------------------------------------------------------------------
|
||
{
|
||
name: 'get_world_brief',
|
||
description: 'AI-generated world intelligence brief. Fetches the latest geopolitical headlines along with their RSS article bodies and produces a grounded LLM-summarized brief. Supply an optional geo_context to focus on a region or topic.',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
geo_context: { type: 'string', description: 'Optional focus context (e.g. "Middle East tensions", "US-China trade war")' },
|
||
},
|
||
required: [],
|
||
},
|
||
_execute: async (params, base, apiKey) => {
|
||
const UA = 'worldmonitor-mcp-edge/1.0';
|
||
// Step 1: fetch current geopolitical headlines (budget: 6 s, leaves ~24 s for LLM)
|
||
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(6_000),
|
||
});
|
||
if (!digestRes.ok) throw new Error(`feed-digest HTTP ${digestRes.status}`);
|
||
type DigestPayload = { categories?: Record<string, { items?: { title?: string; snippet?: string }[] }> };
|
||
const digest = await digestRes.json() as DigestPayload;
|
||
// Pair headlines with their RSS snippets so the LLM grounds per-story
|
||
// on article bodies instead of hallucinating across unrelated titles.
|
||
const pairs = Object.values(digest.categories ?? {})
|
||
.flatMap(cat => cat.items ?? [])
|
||
.map(item => ({ title: item.title ?? '', snippet: item.snippet ?? '' }))
|
||
.filter(p => p.title.length > 0)
|
||
.slice(0, 10);
|
||
const headlines = pairs.map(p => p.title);
|
||
const bodies = pairs.map(p => p.snippet);
|
||
// Step 2: summarize with LLM (budget: 18 s — combined 24 s, well under 30 s edge ceiling)
|
||
const briefRes = await fetch(`${base}/api/news/v1/summarize-article`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-WorldMonitor-Key': apiKey, 'User-Agent': UA },
|
||
body: JSON.stringify({
|
||
provider: 'openrouter',
|
||
headlines,
|
||
bodies,
|
||
mode: 'brief',
|
||
geoContext: String(params.geo_context ?? ''),
|
||
variant: 'geo',
|
||
lang: 'en',
|
||
}),
|
||
signal: AbortSignal.timeout(18_000),
|
||
});
|
||
if (!briefRes.ok) throw new Error(`summarize-article HTTP ${briefRes.status}`);
|
||
return briefRes.json();
|
||
},
|
||
},
|
||
{
|
||
name: 'get_country_brief',
|
||
description: 'AI-generated per-country intelligence brief. Produces an LLM-analyzed geopolitical and economic assessment for the given country. Supports analytical frameworks for structured lenses.',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
country_code: { type: 'string', description: 'ISO 3166-1 alpha-2 country code, e.g. "US", "DE", "CN", "IR"' },
|
||
framework: { type: 'string', description: 'Optional analytical framework instructions to shape the analysis lens (e.g. Ray Dalio debt cycle, PMESII-PT)' },
|
||
},
|
||
required: ['country_code'],
|
||
},
|
||
_execute: async (params, base, apiKey) => {
|
||
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': 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_country_risk',
|
||
description: 'Structured risk intelligence for a specific country: Composite Instability Index (CII) score 0-100, component breakdown (unrest/conflict/security/news), travel advisory level, and OFAC sanctions exposure. Fast Redis read — no LLM. Use for quantitative risk screening or to answer "how risky is X right now?"',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
country_code: { type: 'string', description: 'ISO 3166-1 alpha-2 country code, e.g. "RU", "IR", "CN", "UA"' },
|
||
},
|
||
required: ['country_code'],
|
||
},
|
||
_execute: async (params, base, apiKey) => {
|
||
const code = String(params.country_code ?? '').toUpperCase().slice(0, 2);
|
||
const res = await fetch(
|
||
`${base}/api/intelligence/v1/get-country-risk?country_code=${encodeURIComponent(code)}`,
|
||
{
|
||
headers: { 'X-WorldMonitor-Key': apiKey, 'User-Agent': 'worldmonitor-mcp-edge/1.0' },
|
||
signal: AbortSignal.timeout(8_000),
|
||
},
|
||
);
|
||
if (!res.ok) throw new Error(`get-country-risk 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.',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
query: { type: 'string', description: 'The question or situation to analyze, e.g. "What are the implications of the Taiwan strait escalation for semiconductor supply chains?"' },
|
||
context: { type: 'string', description: 'Optional additional geo-political context to include in the analysis' },
|
||
framework: { type: 'string', description: 'Optional analytical framework instructions to shape the analysis lens (e.g. Ray Dalio debt cycle, PMESII-PT, Porter\'s Five Forces)' },
|
||
},
|
||
required: ['query'],
|
||
},
|
||
_execute: async (params, base, apiKey) => {
|
||
const res = await fetch(`${base}/api/intelligence/v1/deduct-situation`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-WorldMonitor-Key': apiKey, 'User-Agent': 'worldmonitor-mcp-edge/1.0' },
|
||
body: JSON.stringify({ query: String(params.query ?? ''), geoContext: String(params.context ?? ''), framework: String(params.framework ?? '') }),
|
||
signal: AbortSignal.timeout(25_000),
|
||
});
|
||
if (!res.ok) throw new Error(`deduct-situation HTTP ${res.status}`);
|
||
return res.json();
|
||
},
|
||
},
|
||
{
|
||
name: 'generate_forecasts',
|
||
description: 'Generate live AI geopolitical and economic forecasts. Unlike get_forecast_predictions (pre-computed cache), this calls the forecasting model directly for fresh probability estimates. Note: slower than cache tools.',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
domain: { type: 'string', description: 'Forecast domain: "geopolitical", "economic", "military", "climate", or empty for all domains' },
|
||
region: { type: 'string', description: 'Geographic region filter, e.g. "Middle East", "Europe", "Asia Pacific", or empty for global' },
|
||
},
|
||
required: [],
|
||
},
|
||
_execute: async (params, base, apiKey) => {
|
||
// 25 s — stays within Vercel Edge's ~30 s hard ceiling (was 60 s, which exceeded the limit)
|
||
const res = await fetch(`${base}/api/forecast/v1/get-forecasts`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-WorldMonitor-Key': apiKey, 'User-Agent': 'worldmonitor-mcp-edge/1.0' },
|
||
body: JSON.stringify({ domain: String(params.domain ?? ''), region: String(params.region ?? '') }),
|
||
signal: AbortSignal.timeout(25_000),
|
||
});
|
||
if (!res.ok) throw new Error(`get-forecasts HTTP ${res.status}`);
|
||
return res.json();
|
||
},
|
||
},
|
||
{
|
||
name: 'search_flights',
|
||
description: 'Search Google Flights for real-time flight options between two airports on a specific date. Returns available flights with prices, stops, airline, and segment details. Use IATA airport codes (e.g. "JFK", "LHR", "DXB").',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
origin: { type: 'string', description: 'IATA code for the departure airport, e.g. "JFK"' },
|
||
destination: { type: 'string', description: 'IATA code for the arrival airport, e.g. "LHR"' },
|
||
departure_date: { type: 'string', description: 'Departure date in YYYY-MM-DD format' },
|
||
return_date: { type: 'string', description: 'Return date in YYYY-MM-DD format for round trips (optional)' },
|
||
cabin_class: { type: 'string', description: 'Cabin class: "economy", "premium_economy", "business", or "first" (optional, default economy)' },
|
||
max_stops: { type: 'string', description: 'Max stops: "0" or "non_stop" for nonstop, "1" or "one_stop" for max one stop, or omit for any (optional)' },
|
||
passengers: { type: 'number', description: 'Number of passengers (1-9, default 1)' },
|
||
sort_by: { type: 'string', description: 'Sort order: "price" (cheapest), "duration", "departure", or "arrival" (optional)' },
|
||
},
|
||
required: ['origin', 'destination', 'departure_date'],
|
||
},
|
||
_execute: async (params, base, apiKey) => {
|
||
const qs = new URLSearchParams({
|
||
origin: String(params.origin ?? ''),
|
||
destination: String(params.destination ?? ''),
|
||
departure_date: String(params.departure_date ?? ''),
|
||
...(params.return_date ? { return_date: String(params.return_date) } : {}),
|
||
...(params.cabin_class ? { cabin_class: String(params.cabin_class) } : {}),
|
||
...(params.max_stops ? { max_stops: String(params.max_stops) } : {}),
|
||
...(params.sort_by ? { sort_by: String(params.sort_by) } : {}),
|
||
passengers: String(Math.max(1, Math.min(Number(params.passengers ?? 1), 9))),
|
||
});
|
||
const res = await fetch(`${base}/api/aviation/v1/search-google-flights?${qs}`, {
|
||
headers: { 'X-WorldMonitor-Key': apiKey, 'User-Agent': 'worldmonitor-mcp-edge/1.0' },
|
||
signal: AbortSignal.timeout(25_000),
|
||
});
|
||
if (!res.ok) throw new Error(`search-google-flights HTTP ${res.status}`);
|
||
return res.json();
|
||
},
|
||
},
|
||
{
|
||
name: 'search_flight_prices_by_date',
|
||
description: 'Search Google Flights date-grid pricing across a date range. Returns cheapest prices for each departure date between two airports. Useful for finding the cheapest day to fly. Use IATA airport codes.',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
origin: { type: 'string', description: 'IATA code for the departure airport, e.g. "JFK"' },
|
||
destination: { type: 'string', description: 'IATA code for the arrival airport, e.g. "LHR"' },
|
||
start_date: { type: 'string', description: 'Start of the date range in YYYY-MM-DD format' },
|
||
end_date: { type: 'string', description: 'End of the date range in YYYY-MM-DD format' },
|
||
is_round_trip: { type: 'boolean', description: 'Whether to search round-trip prices (default false). Requires trip_duration when true.' },
|
||
trip_duration: { type: 'number', description: 'Trip duration in days — required when is_round_trip is true (e.g. 7 for a one-week trip)' },
|
||
cabin_class: { type: 'string', description: 'Cabin class: "economy", "premium_economy", "business", or "first" (optional)' },
|
||
passengers: { type: 'number', description: 'Number of passengers (1-9, default 1)' },
|
||
sort_by_price: { type: 'boolean', description: 'Sort results by price ascending (default false, sorts by date)' },
|
||
},
|
||
required: ['origin', 'destination', 'start_date', 'end_date'],
|
||
},
|
||
_execute: async (params, base, apiKey) => {
|
||
const qs = new URLSearchParams({
|
||
origin: String(params.origin ?? ''),
|
||
destination: String(params.destination ?? ''),
|
||
start_date: String(params.start_date ?? ''),
|
||
end_date: String(params.end_date ?? ''),
|
||
is_round_trip: String(params.is_round_trip ?? false),
|
||
...(params.trip_duration ? { trip_duration: String(params.trip_duration) } : {}),
|
||
...(params.cabin_class ? { cabin_class: String(params.cabin_class) } : {}),
|
||
sort_by_price: String(params.sort_by_price ?? false),
|
||
passengers: String(Math.max(1, Math.min(Number(params.passengers ?? 1), 9))),
|
||
});
|
||
const res = await fetch(`${base}/api/aviation/v1/search-google-dates?${qs}`, {
|
||
headers: { 'X-WorldMonitor-Key': apiKey, 'User-Agent': 'worldmonitor-mcp-edge/1.0' },
|
||
signal: AbortSignal.timeout(25_000),
|
||
});
|
||
if (!res.ok) throw new Error(`search-google-dates HTTP ${res.status}`);
|
||
return res.json();
|
||
},
|
||
},
|
||
{
|
||
name: 'get_commodity_geo',
|
||
description: 'Global mining sites with coordinates, operator, mineral type, and production status. Covers 71 major mines spanning gold, silver, copper, lithium, uranium, coal, and other minerals worldwide.',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
mineral: { type: 'string', description: 'Filter by mineral type (e.g. "Gold", "Copper", "Lithium")' },
|
||
country: { type: 'string', description: 'Filter by country name (e.g. "Australia", "Chile")' },
|
||
},
|
||
required: [],
|
||
},
|
||
_execute: async (params: Record<string, unknown>) => {
|
||
type MineSite = { id: string; name: string; lat: number; lon: number; mineral: string; country: string; operator: string; status: string; significance: string; annualOutput?: string; productionRank?: number; openPitOrUnderground?: string };
|
||
let sites = MINING_SITES_RAW as MineSite[];
|
||
if (params.mineral) sites = sites.filter((s) => s.mineral === String(params.mineral));
|
||
if (params.country) sites = sites.filter((s) => s.country.toLowerCase().includes(String(params.country).toLowerCase()));
|
||
return { sites, total: sites.length };
|
||
},
|
||
},
|
||
];
|
||
|
||
// Public shape for tools/list (strip internal _-prefixed fields, add MCP annotations)
|
||
const TOOL_LIST_RESPONSE = TOOL_REGISTRY.map(({ name, description, inputSchema }) => ({
|
||
name,
|
||
description,
|
||
inputSchema,
|
||
annotations: { readOnlyHint: true, openWorldHint: true },
|
||
}));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// JSON-RPC helpers
|
||
// ---------------------------------------------------------------------------
|
||
function rpcOk(id: unknown, result: unknown, extraHeaders: Record<string, string> = {}): Response {
|
||
return jsonResponse({ jsonrpc: '2.0', id: id ?? null, result }, 200, extraHeaders);
|
||
}
|
||
|
||
function rpcError(id: unknown, code: number, message: string): Response {
|
||
return jsonResponse({ jsonrpc: '2.0', id: id ?? null, error: { code, message } }, 200);
|
||
}
|
||
|
||
export function evaluateFreshness(checks: FreshnessCheck[], metas: unknown[], now = Date.now()): { cached_at: string | null; stale: boolean } {
|
||
let stale = false;
|
||
let oldestFetchedAt = Number.POSITIVE_INFINITY;
|
||
let hasAnyValidMeta = false;
|
||
let hasAllValidMeta = true;
|
||
|
||
for (const [i, check] of checks.entries()) {
|
||
const meta = metas[i];
|
||
const fetchedAt = meta && typeof meta === 'object' && 'fetchedAt' in meta
|
||
? Number((meta as { fetchedAt: unknown }).fetchedAt)
|
||
: Number.NaN;
|
||
|
||
if (!Number.isFinite(fetchedAt) || fetchedAt <= 0) {
|
||
hasAllValidMeta = false;
|
||
stale = true;
|
||
continue;
|
||
}
|
||
|
||
hasAnyValidMeta = true;
|
||
oldestFetchedAt = Math.min(oldestFetchedAt, fetchedAt);
|
||
stale ||= (now - fetchedAt) / 60_000 > check.maxStaleMin;
|
||
}
|
||
|
||
return {
|
||
cached_at: hasAnyValidMeta && hasAllValidMeta ? new Date(oldestFetchedAt).toISOString() : null,
|
||
stale,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tool execution
|
||
// ---------------------------------------------------------------------------
|
||
async function executeTool(tool: CacheToolDef): Promise<{ cached_at: string | null; stale: boolean; data: Record<string, unknown> }> {
|
||
const reads = tool._cacheKeys.map(k => readJsonFromUpstash(k));
|
||
const freshnessChecks = tool._freshnessChecks?.length
|
||
? tool._freshnessChecks
|
||
: [{ key: tool._seedMetaKey, maxStaleMin: tool._maxStaleMin }];
|
||
const metaReads = freshnessChecks.map((check) => readJsonFromUpstash(check.key));
|
||
const [results, metas] = await Promise.all([Promise.all(reads), Promise.all(metaReads)]);
|
||
const { cached_at, stale } = evaluateFreshness(freshnessChecks, metas);
|
||
|
||
const data: Record<string, unknown> = {};
|
||
// Walk backward through ':'-delimited segments, skipping non-informative suffixes
|
||
// (version tags, bare numbers, internal format names) to produce a readable label.
|
||
const NON_LABEL = /^(v\d+|\d+|stale|sebuf)$/;
|
||
tool._cacheKeys.forEach((key, i) => {
|
||
const parts = key.split(':');
|
||
let label = '';
|
||
for (let idx = parts.length - 1; idx >= 0; idx--) {
|
||
const seg = parts[idx] ?? '';
|
||
if (!NON_LABEL.test(seg)) { label = seg; break; }
|
||
}
|
||
data[label || (parts[0] ?? key)] = results[i];
|
||
});
|
||
|
||
return { cached_at, stale, data };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main handler
|
||
// ---------------------------------------------------------------------------
|
||
export default async function handler(req: Request): Promise<Response> {
|
||
// MCP is a public API endpoint secured by API key — allow all origins (claude.ai, Claude Desktop, custom agents)
|
||
const corsHeaders = getPublicCorsHeaders('POST, OPTIONS');
|
||
|
||
if (req.method === 'OPTIONS') {
|
||
return new Response(null, { status: 204, headers: corsHeaders });
|
||
}
|
||
|
||
// HEAD probe — return 200 with no body (Anthropic submission guide compatibility)
|
||
if (req.method === 'HEAD') {
|
||
return new Response(null, { status: 200, headers: { 'Content-Type': 'application/json', ...corsHeaders } });
|
||
}
|
||
|
||
// MCP Streamable HTTP transport (2025-03-26) uses POST only.
|
||
// Return 405 for GET/other so clients don't mistake JSON error for a valid SSE stream.
|
||
if (req.method !== 'POST') {
|
||
return new Response(null, { status: 405, headers: { Allow: 'POST, HEAD, OPTIONS', ...corsHeaders } });
|
||
}
|
||
|
||
// Origin validation: allow claude.ai/claude.com web clients; allow absent origin (desktop/CLI)
|
||
const origin = req.headers.get('Origin');
|
||
if (origin && origin !== 'https://claude.ai' && origin !== 'https://claude.com') {
|
||
return new Response('Forbidden', { status: 403, headers: corsHeaders });
|
||
}
|
||
// Host-derived resource_metadata pointer: a client probing api.worldmonitor.app/mcp
|
||
// must see a pointer at its own origin, not the apex — otherwise the 401's
|
||
// WWW-Authenticate points at apex metadata whose `resource` field is apex too,
|
||
// and same-origin scanners (isitagentready.com, Cloudflare mcp.cloudflare.com)
|
||
// flag the mismatch. Matches the host-extraction in api/oauth-protected-resource.ts.
|
||
const requestHost = req.headers.get('host') ?? new URL(req.url).host;
|
||
const resourceMetadataUrl = `https://${requestHost}/.well-known/oauth-protected-resource`;
|
||
// Auth chain (in priority order):
|
||
// 1. Authorization: Bearer <oauth_token> — issued by /oauth/token (spec-compliant OAuth 2.0)
|
||
// 2. X-WorldMonitor-Key header — direct API key (curl, custom integrations)
|
||
let apiKey = '';
|
||
const authHeader = req.headers.get('Authorization') ?? '';
|
||
if (authHeader.startsWith('Bearer ')) {
|
||
const token = authHeader.slice(7).trim();
|
||
let bearerApiKey: string | null;
|
||
try {
|
||
bearerApiKey = await resolveApiKeyFromBearer(token);
|
||
} catch {
|
||
// Redis/network error — return 503 so clients know to retry, not re-authenticate
|
||
return new Response(
|
||
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Auth service temporarily unavailable. Try again.' } }),
|
||
{ status: 503, headers: { 'Content-Type': 'application/json', 'Retry-After': '5', ...corsHeaders } }
|
||
);
|
||
}
|
||
if (bearerApiKey) {
|
||
apiKey = bearerApiKey;
|
||
} else {
|
||
// Bearer token present but unresolvable — expired or invalid UUID
|
||
return new Response(
|
||
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Invalid or expired OAuth token. Re-authenticate via /oauth/token.' } }),
|
||
{ status: 401, headers: { 'Content-Type': 'application/json', 'WWW-Authenticate': `Bearer realm="worldmonitor", error="invalid_token", resource_metadata="${resourceMetadataUrl}"`, ...corsHeaders } }
|
||
);
|
||
}
|
||
} else {
|
||
const candidateKey = req.headers.get('X-WorldMonitor-Key') ?? '';
|
||
if (!candidateKey) {
|
||
return new Response(
|
||
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Authentication required. Use OAuth (/oauth/token) or pass your API key via X-WorldMonitor-Key header.' } }),
|
||
{ status: 401, headers: { 'Content-Type': 'application/json', 'WWW-Authenticate': `Bearer realm="worldmonitor", resource_metadata="${resourceMetadataUrl}"`, ...corsHeaders } }
|
||
);
|
||
}
|
||
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean);
|
||
if (!await timingSafeIncludes(candidateKey, validKeys)) {
|
||
return rpcError(null, -32001, 'Invalid API key');
|
||
}
|
||
apiKey = candidateKey;
|
||
}
|
||
|
||
|
||
// Per-key rate limit
|
||
const rl = getMcpRatelimit();
|
||
if (rl) {
|
||
try {
|
||
const { success } = await rl.limit(`key:${apiKey}`);
|
||
if (!success) {
|
||
return rpcError(null, -32029, 'Rate limit exceeded. Max 60 requests per minute per API key.');
|
||
}
|
||
} catch {
|
||
// Upstash unavailable — allow through (graceful degradation)
|
||
}
|
||
}
|
||
|
||
// Parse body
|
||
let body: { jsonrpc?: string; id?: unknown; method?: string; params?: unknown };
|
||
try {
|
||
body = await req.json();
|
||
} catch {
|
||
return rpcError(null, -32600, 'Invalid request: malformed JSON');
|
||
}
|
||
|
||
if (!body || typeof body.method !== 'string') {
|
||
return rpcError(body?.id ?? null, -32600, 'Invalid request: missing method');
|
||
}
|
||
|
||
const { id, method, params } = body;
|
||
|
||
// Dispatch
|
||
switch (method) {
|
||
case 'initialize': {
|
||
const sessionId = crypto.randomUUID();
|
||
return rpcOk(id, {
|
||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||
capabilities: { tools: {} },
|
||
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
||
}, { 'Mcp-Session-Id': sessionId, ...corsHeaders });
|
||
}
|
||
|
||
case 'notifications/initialized':
|
||
return new Response(null, { status: 202, headers: corsHeaders });
|
||
|
||
case 'ping':
|
||
return rpcOk(id, {}, corsHeaders);
|
||
|
||
case 'tools/list':
|
||
return rpcOk(id, { tools: TOOL_LIST_RESPONSE }, corsHeaders);
|
||
|
||
case 'tools/call': {
|
||
const p = params as { name?: string; arguments?: Record<string, unknown> } | null;
|
||
if (!p || typeof p.name !== 'string') {
|
||
return rpcError(id, -32602, 'Invalid params: missing tool name');
|
||
}
|
||
const tool = TOOL_REGISTRY.find(t => t.name === p.name);
|
||
if (!tool) {
|
||
return rpcError(id, -32602, `Unknown tool: ${p.name}`);
|
||
}
|
||
try {
|
||
let result: unknown;
|
||
if (tool._execute) {
|
||
const origin = new URL(req.url).origin;
|
||
result = await tool._execute(p.arguments ?? {}, origin, apiKey);
|
||
} else {
|
||
result = await executeTool(tool);
|
||
}
|
||
return rpcOk(id, {
|
||
content: [{ type: 'text', text: JSON.stringify(result) }],
|
||
}, corsHeaders);
|
||
} catch (err: unknown) {
|
||
console.error('[mcp] tool execution error:', err);
|
||
return rpcError(id, -32603, 'Internal error: data fetch failed');
|
||
}
|
||
}
|
||
|
||
default:
|
||
return rpcError(id, -32601, `Method not found: ${method}`);
|
||
}
|
||
}
|