diff --git a/CLAUDE.md b/CLAUDE.md
index a750c07..f965822 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What This Is
-World Intelligence MCP Server — 84 tools across 30+ domains providing real-time global intelligence from free public APIs. Serves three interfaces: MCP stdio (for Claude Code/Cursor), a live Starlette dashboard with SSE, and a Click CLI with Rich output. Python 3.11+, built with hatchling.
+World Intelligence MCP Server — 89 tools across 30+ domains providing real-time global intelligence from free public APIs. Serves three interfaces: MCP stdio (for Claude Code/Cursor), a live Starlette dashboard with SSE, and a Click CLI with Rich output. Python 3.11+, built with hatchling.
## Commands
diff --git a/ROADMAP.md b/ROADMAP.md
index c93fab1..355fd7d 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Benchmark**: [koala73/worldmonitor](https://github.com/koala73/worldmonitor)
**Updated**: 2026-02-26
-**Current tools**: 84 (83 intel + 1 status)
+**Current tools**: 89 (88 intel + 1 status)
---
@@ -19,6 +19,7 @@
## 1. Data Sources — Complete Inventory
### Markets & Economics (13 tools)
+
| Tool | WM Equivalent | Status |
|------|---------------|--------|
| `intel_market_quotes` | `list-market-quotes` | :white_check_mark: |
@@ -90,14 +91,22 @@
| `intel_gdelt_search` | `search-gdelt-documents` | :white_check_mark: |
| `intel_ai_releases` | AI model/paper tracker | :white_check_mark: |
-### Transport (3 tools)
+### Transport & Traffic (6 tools)
| Tool | WM Equivalent | Status |
|------|---------------|--------|
| `intel_prediction_markets` | `list-prediction-markets` | :white_check_mark: |
| `intel_airport_delays` | `list-airport-delays` | :white_check_mark: |
| `intel_shipping_index` | Yahoo Finance shipping ETFs | :white_check_mark: |
+| `intel_traffic_flow` | Real-time congestion in 20 cities (TomTom) | :white_check_mark: |
+| `intel_traffic_incidents` | Major incidents in 5 strategic regions (TomTom) | :white_check_mark: |
+| `intel_aviation_domestic` | Global air traffic snapshot (OpenSky) | :white_check_mark: |
-### Analysis & Intelligence (9 tools)
+### Webcams (1 tool)
+| Tool | WM Equivalent | Status |
+|------|---------------|--------|
+| `intel_webcams` | Public webcam locations worldwide (Windy) | :white_check_mark: |
+
+### Analysis & Intelligence (10 tools)
| Tool | WM Equivalent | Status |
|------|---------------|--------|
| `intel_risk_scores` | `get-risk-scores` | :white_check_mark: |
@@ -110,10 +119,11 @@
| `intel_alert_digest` | Cross-domain alert synthesis | :white_check_mark: |
| `intel_weekly_trends` | Temporal trend analysis | :white_check_mark: |
-### Country & Geopolitical (3 tools)
+### Country & Geopolitical (4 tools)
| Tool | WM Equivalent | Status |
|------|---------------|--------|
| `intel_country_brief` | `get-country-intel-brief` | :white_check_mark: |
+| `intel_country_dossier` | Comprehensive 6-source country analysis | :white_check_mark: |
| `intel_election_calendar` | Election proximity risk | :white_check_mark: |
| `intel_sanctions_search` | OFAC SDN search | :white_check_mark: |
@@ -186,7 +196,7 @@
## 3. RSS Feed Coverage
-Expanded to **100 feeds** across **18 categories** with 4-tier source ranking (wire/major/specialty/aggregator) and propaganda risk labels.
+Expanded to **119 feeds** across **24 categories** with 4-tier source ranking (wire/major/specialty/aggregator) and propaganda risk labels.
| Category | Count | Status |
|----------|-------|--------|
@@ -207,7 +217,13 @@ Expanded to **100 feeds** across **18 categories** with 4-tier source ranking (w
| Crisis/Intl Orgs | 4 | :white_check_mark: |
| Europe | 4 | :white_check_mark: |
| South Asia | 3 | :white_check_mark: |
-| Health | 3 | :white_check_mark: |
+| Health | 4 | :white_check_mark: |
+| Central Asia | 3 | :white_check_mark: |
+| Arctic | 3 | :white_check_mark: |
+| Maritime | 3 | :white_check_mark: |
+| Space | 3 | :white_check_mark: |
+| Nuclear | 3 | :white_check_mark: |
+| Climate | 3 | :white_check_mark: |
---
@@ -280,16 +296,21 @@ Static datasets completed: 34 undersea cables, 48 AI datacenters, 27 spaceports,
BTC technical analysis with SMA-50/200, Mayer Multiple, golden/death cross signals, and ATH distance via CoinGecko historical data. Central bank policy rates for 15 major banks (Fed, ECB, BoE, BoJ, PBoC, RBI, RBA, BoC, SNB, BCB, BoK, CBRT, SARB, Banxico, BI) — live FRED data when API key set, curated fallback otherwise. Static geospatial datasets: 19 maritime trade routes/chokepoints with oil flow and vessel transit data, 28 cloud provider regions (AWS/Azure/GCP), 20 GFCI-ranked financial centers. Trade route markers added to dashboard infrastructure map layer. BTC technicals and central bank rates added to dashboard drawer.
+### Phase 13: Country Dossier, Full Tool Exposure & Feed Expansion (+5 = 89 tools)
+`intel_country_dossier`, `intel_traffic_flow`, `intel_traffic_incidents`, `intel_aviation_domestic`, `intel_webcams`
+
+Comprehensive country intelligence dossier aggregating 6 sources in parallel (economy, markets, elections, sanctions, news, security). Exposed 4 previously hidden source functions: TomTom traffic flow (20 cities) and incidents (5 regions), OpenSky global air traffic snapshot, Windy public webcams. RSS feeds expanded from 100 to 119 across 24 categories (+6 new categories: central_asia, arctic, maritime, space, nuclear, climate). 49 CLI commands, 53 tests.
+
---
## Summary
| Category | Have | Benchmark | Coverage |
|----------|------|-----------|----------|
-| Data source tools | 84 | 42 | **200%** |
-| Analysis engines | 19 | 15 | **127%** |
+| Data source tools | 89 | 42 | **212%** |
+| Analysis engines | 20 | 15 | **133%** |
| Static datasets | 18 | 12 | **150%** |
-| RSS feeds | 100 | 150+ | **67%** |
-| Strategic synthesis | Posture + brief + fleet + exposure + USNI | Dashboard-only | **Exceeds** |
+| RSS feeds | 119 | 150+ | **79%** |
+| Strategic synthesis | Posture + brief + fleet + dossier + exposure + USNI | Dashboard-only | **Exceeds** |
-**Bottom line**: 84 tools across 30+ domains, exactly 2x WorldMonitor benchmark in tool count, 27% more analysis engines, and 50% more static datasets. 100 RSS feeds across 18 categories. All phases 1-12 complete. Live Starlette dashboard with 39 SSE streams, 14 map layers (with trade route markers), and data freshness monitoring.
+**Bottom line**: 89 tools across 30+ domains, over 2x WorldMonitor benchmark in tool count, 33% more analysis engines, and 50% more static datasets. 119 RSS feeds across 24 categories. All phases 1-13 complete. Live Starlette dashboard with 39 SSE streams, 14 map layers (with trade route markers), and data freshness monitoring.
diff --git a/src/world_intel_mcp/analysis/dossier.py b/src/world_intel_mcp/analysis/dossier.py
new file mode 100644
index 0000000..f00976a
--- /dev/null
+++ b/src/world_intel_mcp/analysis/dossier.py
@@ -0,0 +1,232 @@
+"""Comprehensive country intelligence dossier.
+
+Aggregates data from multiple sources into a single country profile:
+country brief (World Bank + ACLED), stock index, election calendar,
+sanctions exposure, news mentions, and associated hotspots/conflict zones.
+
+All sub-queries are run in parallel for speed.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from datetime import datetime, timezone
+
+logger = logging.getLogger("world-intel-mcp.analysis.dossier")
+
+# Minimal ISO-2 <-> ISO-3 mapping for the 22 tier-1 countries + major extras.
+_ISO2_TO_ISO3: dict[str, str] = {
+ "US": "USA", "CN": "CHN", "RU": "RUS", "UA": "UKR", "SY": "SYR",
+ "YE": "YEM", "MM": "MMR", "SD": "SDN", "NG": "NGA", "AF": "AFG",
+ "IQ": "IRQ", "IR": "IRN", "TW": "TWN", "KP": "PRK", "IL": "ISR",
+ "PS": "PSE", "LB": "LBN", "ET": "ETH", "CD": "COD", "PK": "PAK",
+ "IN": "IND", "MX": "MEX", "GB": "GBR", "DE": "DEU", "FR": "FRA",
+ "JP": "JPN", "KR": "KOR", "BR": "BRA", "AU": "AUS", "CA": "CAN",
+ "SA": "SAU", "TR": "TUR", "EG": "EGY", "ZA": "ZAF", "ID": "IDN",
+ "TH": "THA", "VN": "VNM", "PH": "PHL", "PL": "POL", "IT": "ITA",
+ "ES": "ESP", "SE": "SWE", "NO": "NOR", "CH": "CHE", "NL": "NLD",
+ "BE": "BEL", "AR": "ARG", "CL": "CHL", "CO": "COL", "PE": "PER",
+}
+_ISO3_TO_ISO2: dict[str, str] = {v: k for k, v in _ISO2_TO_ISO3.items()}
+
+
+def _normalize_country(code: str) -> tuple[str, str]:
+ """Return (iso2, iso3) from either format. Raises ValueError if unknown."""
+ code = code.upper().strip()
+ if len(code) == 2:
+ iso2 = code
+ iso3 = _ISO2_TO_ISO3.get(code)
+ if iso3 is None:
+ raise ValueError(f"Unknown ISO-2 code: {code}")
+ return iso2, iso3
+ if len(code) == 3:
+ iso3 = code
+ iso2 = _ISO3_TO_ISO2.get(code)
+ if iso2 is None:
+ raise ValueError(f"Unknown ISO-3 code: {code}")
+ return iso2, iso3
+ raise ValueError(f"Country code must be 2 or 3 characters: {code}")
+
+
+async def _safe(coro, label: str) -> dict:
+ """Run a coroutine, catching exceptions."""
+ try:
+ return await coro
+ except Exception as exc:
+ logger.warning("Dossier: %s failed: %s", label, exc)
+ return {"_error": str(exc)}
+
+
+async def fetch_country_dossier(
+ fetcher,
+ country: str = "US",
+) -> dict:
+ """Build a comprehensive country intelligence dossier.
+
+ Pulls from 6 sources in parallel:
+ 1. Country brief (World Bank GDP/inflation + ACLED conflict)
+ 2. Stock market index (Yahoo Finance)
+ 3. Election calendar (curated dataset)
+ 4. Sanctions exposure (OFAC SDN search)
+ 5. Recent news mentions (RSS feeds)
+ 6. Country config (baseline risk, hotspots, conflict zones)
+
+ Args:
+ fetcher: Shared HTTP fetcher with caching and circuit breaking.
+ country: ISO-2 or ISO-3 country code (e.g. "US", "USA", "UA", "UKR").
+
+ Returns:
+ Dict with sections: overview, economy, markets, elections, sanctions,
+ news, security, and metadata.
+ """
+ now = datetime.now(timezone.utc)
+
+ try:
+ iso2, iso3 = _normalize_country(country)
+ except ValueError as exc:
+ return {
+ "error": str(exc),
+ "hint": "Use ISO-2 (US, UA) or ISO-3 (USA, UKR) codes",
+ "source": "country-dossier",
+ "timestamp": now.isoformat(),
+ }
+
+ # Lazy imports to avoid circular deps
+ from ..sources.intelligence import fetch_country_brief
+ from ..sources.markets import fetch_country_stocks
+ from ..sources.elections import fetch_election_calendar
+ from ..sources.sanctions import fetch_sanctions_search
+ from ..sources.news import fetch_news_feed
+ from ..config.countries import TIER1_COUNTRIES, INTEL_HOTSPOTS, CONFLICT_ZONES
+
+ # Run all data fetches in parallel
+ (
+ brief_data,
+ stock_data,
+ election_data,
+ sanctions_data,
+ news_data,
+ ) = await asyncio.gather(
+ _safe(fetch_country_brief(fetcher, country_code=iso2), "country_brief"),
+ _safe(fetch_country_stocks(fetcher, country=iso3), "country_stocks"),
+ _safe(fetch_election_calendar(fetcher, country=iso3), "elections"),
+ _safe(fetch_sanctions_search(fetcher, query=iso3, limit=10), "sanctions"),
+ _safe(fetch_news_feed(fetcher, category="all", limit=200), "news_feed"),
+ )
+
+ # --- Section 1: Overview ---
+ country_config = TIER1_COUNTRIES.get(iso3, {})
+ country_name = country_config.get("name", iso3)
+
+ overview = {
+ "country": country_name,
+ "iso2": iso2,
+ "iso3": iso3,
+ "baseline_risk": country_config.get("baseline_risk"),
+ "event_multiplier": country_config.get("event_multiplier"),
+ }
+
+ # --- Section 2: Economy (from country brief) ---
+ economy = {}
+ if "_error" not in brief_data:
+ supporting = brief_data.get("supporting_data", {})
+ economy = {
+ "gdp": supporting.get("gdp", []),
+ "inflation": supporting.get("inflation", []),
+ "conflict_events_30d": supporting.get("conflict_events_count", 0),
+ "llm_available": brief_data.get("llm_available", False),
+ "brief_text": brief_data.get("brief", ""),
+ }
+ else:
+ economy = {"_error": brief_data["_error"]}
+
+ # --- Section 3: Markets ---
+ markets = {}
+ if "_error" not in stock_data and "error" not in stock_data:
+ markets = {
+ "ticker": stock_data.get("ticker"),
+ "exchange": stock_data.get("exchange"),
+ "quote": stock_data.get("quote"),
+ }
+ else:
+ markets = {"note": stock_data.get("error", stock_data.get("_error", "unavailable"))}
+
+ # --- Section 4: Elections ---
+ elections = {}
+ if "_error" not in election_data:
+ country_elections = election_data.get("elections", [])
+ elections = {
+ "upcoming": [e for e in country_elections if e.get("status") == "upcoming"],
+ "past": [e for e in country_elections if e.get("status") == "past"],
+ "count": len(country_elections),
+ }
+ else:
+ elections = {"_error": election_data["_error"]}
+
+ # --- Section 5: Sanctions ---
+ sanctions = {}
+ if "_error" not in sanctions_data:
+ sanctions = {
+ "matches": sanctions_data.get("results", []),
+ "match_count": sanctions_data.get("count", 0),
+ }
+ else:
+ sanctions = {"_error": sanctions_data["_error"]}
+
+ # --- Section 6: News ---
+ news_mentions = []
+ if "_error" not in news_data:
+ country_keywords = country_config.get("keywords", [country_name.lower()])
+ for article in news_data.get("articles", []):
+ title = (article.get("title") or "").lower()
+ if any(kw in title for kw in country_keywords):
+ news_mentions.append({
+ "title": article.get("title"),
+ "source": article.get("source"),
+ "published": article.get("published"),
+ "link": article.get("link"),
+ "category": article.get("category"),
+ })
+ if len(news_mentions) >= 15:
+ break
+
+ # --- Section 7: Security (hotspots + conflict zones) ---
+ associated_hotspots = []
+ for name, hs in INTEL_HOTSPOTS.items():
+ if iso3 in hs.get("associated_countries", []):
+ associated_hotspots.append({
+ "name": name,
+ "lat": hs["lat"],
+ "lon": hs["lon"],
+ "baseline_escalation": hs["baseline_escalation"],
+ })
+
+ active_conflicts = []
+ for cz in CONFLICT_ZONES:
+ cz_name = cz["name"].lower()
+ if any(kw in cz_name for kw in country_config.get("keywords", [country_name.lower()])):
+ active_conflicts.append(cz)
+
+ security = {
+ "hotspots": associated_hotspots,
+ "hotspot_count": len(associated_hotspots),
+ "active_conflicts": active_conflicts,
+ "conflict_count": len(active_conflicts),
+ }
+
+ return {
+ "overview": overview,
+ "economy": economy,
+ "markets": markets,
+ "elections": elections,
+ "sanctions": sanctions,
+ "news": {
+ "mentions": news_mentions,
+ "mention_count": len(news_mentions),
+ },
+ "security": security,
+ "sections": ["overview", "economy", "markets", "elections", "sanctions", "news", "security"],
+ "source": "country-dossier",
+ "timestamp": now.isoformat(),
+ }
diff --git a/src/world_intel_mcp/cli.py b/src/world_intel_mcp/cli.py
index 8769056..559f032 100644
--- a/src/world_intel_mcp/cli.py
+++ b/src/world_intel_mcp/cli.py
@@ -528,12 +528,13 @@ def climate_cmd(ctx: click.Context) -> None:
@click.option("--category", "-c", default=None,
type=click.Choice(["geopolitics", "security", "technology", "finance", "military", "science",
"think_tanks", "middle_east", "asia_pacific", "africa", "latin_america",
- "multilingual", "energy", "government", "crisis", "europe", "south_asia", "health"]),
+ "multilingual", "energy", "government", "crisis", "europe", "south_asia",
+ "health", "central_asia", "arctic", "maritime", "space", "nuclear", "climate"]),
help="Category filter")
@click.option("--limit", "-n", default=30, help="Max items")
@click.pass_context
def news_cmd(ctx: click.Context, category: str | None, limit: int) -> None:
- """Intelligence news from 100 RSS feeds across 18 categories."""
+ """Intelligence news from 119 RSS feeds across 24 categories."""
f = _get_fetcher()
data = _run(news.fetch_news_feed(f, category=category, limit=limit))
@@ -795,6 +796,70 @@ def brief(ctx: click.Context, country_code: str) -> None:
f"Recent conflict events: {d.get('recent_events', 0)}[/dim]")
+@main.command()
+@click.option("--country", "-c", default="US", help="ISO-2 or ISO-3 country code")
+@click.pass_context
+def dossier(ctx: click.Context, country: str) -> None:
+ """Comprehensive country intelligence dossier."""
+ from .analysis.dossier import fetch_country_dossier
+ f = _get_fetcher()
+ data = _run(fetch_country_dossier(f, country=country))
+
+ if ctx.obj.get("json") or "error" in data:
+ _print_json(data)
+ return
+
+ overview = data.get("overview", {})
+ console.print(f"[bold]Country Dossier: {overview.get('country', country)}[/bold] "
+ f"({overview.get('iso2')}/{overview.get('iso3')})\n")
+
+ # Economy
+ econ = data.get("economy", {})
+ if "_error" not in econ:
+ gdp = econ.get("gdp", [])
+ if gdp:
+ latest = gdp[0]
+ console.print(f"[cyan]Economy:[/cyan] GDP {latest.get('year')}: ${latest.get('value', 0)/1e9:,.1f}B")
+ if econ.get("conflict_events_30d"):
+ console.print(f" Conflict events (30d): {econ['conflict_events_30d']}")
+
+ # Markets
+ mkt = data.get("markets", {})
+ if "ticker" in mkt:
+ q = mkt.get("quote", {})
+ console.print(f"[green]Markets:[/green] {mkt['ticker']} = {q.get('price', 'N/A')} ({q.get('change_pct', 'N/A')}%)")
+
+ # Elections
+ elec = data.get("elections", {})
+ upcoming = elec.get("upcoming", [])
+ if upcoming:
+ next_e = upcoming[0]
+ console.print(f"[yellow]Elections:[/yellow] {next_e.get('election_type')} on {next_e.get('date')} "
+ f"(risk: {next_e.get('risk_score', 0):.0f})")
+
+ # Sanctions
+ sanc = data.get("sanctions", {})
+ if sanc.get("match_count", 0) > 0:
+ console.print(f"[red]Sanctions:[/red] {sanc['match_count']} OFAC matches")
+
+ # News
+ news = data.get("news", {})
+ console.print(f"[blue]News:[/blue] {news.get('mention_count', 0)} recent mentions")
+ for art in news.get("mentions", [])[:3]:
+ console.print(f" - {art.get('title', 'N/A')[:80]}")
+
+ # Security
+ sec = data.get("security", {})
+ if sec.get("hotspot_count", 0):
+ console.print(f"[red]Hotspots:[/red] {sec['hotspot_count']} associated")
+ if sec.get("conflict_count", 0):
+ console.print(f"[red]Conflicts:[/red] {sec['conflict_count']} active")
+
+ br = overview.get("baseline_risk")
+ if br is not None:
+ console.print(f"\n[dim]Baseline risk: {br}/100[/dim]")
+
+
@main.command()
@click.option("--limit", "-n", default=20, help="Top N countries")
@click.pass_context
@@ -1297,6 +1362,129 @@ def exchanges_cmd(ctx: click.Context, tier: str | None, country: str | None) ->
console.print(table)
+# ---------------------------------------------------------------------------
+# Traffic, Aviation, Webcams
+# ---------------------------------------------------------------------------
+
+@main.command()
+@click.pass_context
+def traffic(ctx: click.Context) -> None:
+ """Real-time traffic congestion for 20 major cities (TomTom)."""
+ from .sources.traffic import fetch_traffic_flow
+ f = _get_fetcher()
+ data = _run(fetch_traffic_flow(f))
+
+ if ctx.obj.get("json") or "error" in data:
+ _print_json(data)
+ return
+
+ console.print(f"[bold]Global Traffic[/bold] — {data.get('count', 0)} cities, "
+ f"avg congestion {data.get('global_avg_congestion', 0):.0f}%\n")
+
+ table = Table(box=box.SIMPLE_HEAVY)
+ table.add_column("City", style="bold")
+ table.add_column("Country")
+ table.add_column("Congestion %", justify="right")
+ table.add_column("Speed (km/h)", justify="right")
+
+ for c in data.get("cities", [])[:20]:
+ cong = c.get("congestion_pct", 0)
+ style = "red" if cong > 60 else "yellow" if cong > 30 else "green"
+ table.add_row(c.get("name", ""), c.get("country", ""),
+ f"[{style}]{cong}%[/{style}]",
+ str(c.get("current_speed_kmh", "")))
+ console.print(table)
+
+
+@main.command()
+@click.pass_context
+def incidents(ctx: click.Context) -> None:
+ """Major traffic incidents across strategic regions (TomTom)."""
+ from .sources.traffic import fetch_traffic_incidents
+ f = _get_fetcher()
+ data = _run(fetch_traffic_incidents(f))
+
+ if ctx.obj.get("json") or "error" in data:
+ _print_json(data)
+ return
+
+ console.print(f"[bold]Traffic Incidents[/bold] — {data.get('total_count', 0)} across "
+ f"{data.get('regions_checked', 0)} regions\n")
+
+ table = Table(box=box.SIMPLE_HEAVY)
+ table.add_column("Region")
+ table.add_column("Description", max_width=40)
+ table.add_column("Delay (min)", justify="right")
+ table.add_column("Road")
+
+ for inc in data.get("incidents", [])[:20]:
+ delay_min = round(inc.get("delay_seconds", 0) / 60)
+ table.add_row(
+ inc.get("region", ""),
+ inc.get("description", "")[:40],
+ str(delay_min) if delay_min else "-",
+ inc.get("from_road", "")[:30],
+ )
+ console.print(table)
+
+
+@main.command(name="air-traffic")
+@click.pass_context
+def air_traffic_cmd(ctx: click.Context) -> None:
+ """Global air traffic snapshot (OpenSky)."""
+ f = _get_fetcher()
+ data = _run(aviation.fetch_domestic_flights(f))
+
+ if ctx.obj.get("json") or "error" in data:
+ _print_json(data)
+ return
+
+ console.print(f"[bold]Air Traffic[/bold] — {data.get('total_aircraft', 0)} airborne\n")
+
+ table = Table(box=box.SIMPLE_HEAVY, title="By Region")
+ table.add_column("Region", style="bold")
+ table.add_column("Count", justify="right")
+ table.add_column("Commercial", justify="right")
+ table.add_column("General", justify="right")
+
+ for region, stats in sorted(data.get("by_region", {}).items(), key=lambda x: -x[1]["count"]):
+ table.add_row(region, str(stats["count"]), str(stats["commercial"]), str(stats["general"]))
+ console.print(table)
+
+ if data.get("busiest_origins"):
+ console.print("\n[bold]Busiest Origins:[/bold]")
+ for o in data["busiest_origins"][:10]:
+ console.print(f" {o['country']}: {o['count']}")
+
+
+@main.command()
+@click.option("--category", "-c", default="traffic", help="Webcam category")
+@click.option("--limit", "-n", default=20, help="Max cameras")
+@click.pass_context
+def webcams_cmd(ctx: click.Context, category: str, limit: int) -> None:
+ """Public webcam locations worldwide (Windy)."""
+ from .sources.webcams import fetch_webcams
+ f = _get_fetcher()
+ data = _run(fetch_webcams(f, category=category, limit=limit))
+
+ if ctx.obj.get("json") or "error" in data:
+ _print_json(data)
+ return
+
+ console.print(f"[bold]Webcams[/bold] — {data.get('count', 0)} cameras ({category})\n")
+
+ table = Table(box=box.SIMPLE_HEAVY)
+ table.add_column("Title", style="bold", max_width=30)
+ table.add_column("City")
+ table.add_column("Country")
+ table.add_column("Status")
+
+ for cam in data.get("cameras", []):
+ table.add_row(cam.get("title", "")[:30], cam.get("city", ""),
+ cam.get("country", ""), cam.get("status", ""))
+ console.print(table)
+
+
# ---------------------------------------------------------------------------
# System
# ---------------------------------------------------------------------------
diff --git a/src/world_intel_mcp/server.py b/src/world_intel_mcp/server.py
index a55d5f9..fb6456f 100644
--- a/src/world_intel_mcp/server.py
+++ b/src/world_intel_mcp/server.py
@@ -309,14 +309,17 @@ TOOLS: list[Tool] = [
# --- News (3 tools) ---
Tool(
name="intel_news_feed",
- description="Get aggregated intelligence news from 20+ RSS feeds across 6 categories (geopolitics, security, tech, finance, military, science).",
+ description="Get aggregated intelligence news from 119 RSS feeds across 24 categories. Covers geopolitics, security, tech, finance, military, science, think tanks, regional, energy, space, nuclear, climate, maritime, arctic, and more.",
inputSchema={
"type": "object",
"properties": {
"category": {
"type": "string",
- "description": "Category filter",
- "enum": ["geopolitics", "security", "technology", "finance", "military", "science"],
+ "description": "Category filter (24 categories available)",
+ "enum": ["geopolitics", "security", "technology", "finance", "military", "science",
+ "think_tanks", "middle_east", "asia_pacific", "africa", "latin_america",
+ "multilingual", "energy", "government", "crisis", "europe", "south_asia",
+ "health", "central_asia", "arctic", "maritime", "space", "nuclear", "climate"],
},
"limit": {"type": "integer", "description": "Max items (default 50)", "default": 50},
},
@@ -349,7 +352,7 @@ TOOLS: list[Tool] = [
},
},
),
- # --- Intelligence (12 tools) ---
+ # --- Intelligence (13 tools) ---
Tool(
name="intel_country_brief",
description="Generate a country intelligence brief using Ollama LLM + World Bank + ACLED data. Falls back to data-only if LLM unavailable.",
@@ -360,6 +363,16 @@ TOOLS: list[Tool] = [
},
},
),
+ Tool(
+ name="intel_country_dossier",
+ description="Comprehensive country intelligence dossier: economy (GDP/inflation), stock market, elections, sanctions, news mentions, hotspots, and conflict zones. Aggregates 6 sources in parallel.",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "country": {"type": "string", "description": "ISO-2 or ISO-3 country code (e.g. US, USA, UA, UKR)", "default": "US"},
+ },
+ },
+ ),
Tool(
name="intel_risk_scores",
description="Get country risk scores computed from ACLED conflict data vs historical baselines. Requires ACLED_ACCESS_TOKEN.",
@@ -901,6 +914,35 @@ TOOLS: list[Tool] = [
},
},
),
+ # --- Traffic (2 tools) ---
+ Tool(
+ name="intel_traffic_flow",
+ description="Real-time traffic congestion for 20 major world cities via TomTom API. Congestion percentage, speeds, global average. Requires TOMTOM_API_KEY.",
+ inputSchema={"type": "object", "properties": {}},
+ ),
+ Tool(
+ name="intel_traffic_incidents",
+ description="Major traffic incidents across 5 strategic regions (US East/West, Europe, Middle East, East Asia) via TomTom API. Requires TOMTOM_API_KEY.",
+ inputSchema={"type": "object", "properties": {}},
+ ),
+ # --- Aviation domestic (1 tool) ---
+ Tool(
+ name="intel_aviation_domestic",
+ description="Global air traffic snapshot from OpenSky Network: total airborne aircraft, regional breakdown, busiest origin countries, and sampled positions for mapping.",
+ inputSchema={"type": "object", "properties": {}},
+ ),
+ # --- Webcams (1 tool) ---
+ Tool(
+ name="intel_webcams",
+ description="Public webcam locations and live previews worldwide from Windy Webcams API. Filter by category (traffic, weather, landscape). Requires WINDY_API_KEY.",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "category": {"type": "string", "description": "Webcam category (traffic, weather, landscape, etc.)", "default": "traffic"},
+ "limit": {"type": "integer", "description": "Max cameras to return (default 50)", "default": 50},
+ },
+ },
+ ),
# --- System (1 tool) ---
Tool(
name="intel_status",
@@ -1037,6 +1079,9 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
# Intelligence
case "intel_country_brief":
return await intelligence.fetch_country_brief(fetcher, country_code=arguments.get("country_code", "US"))
+ case "intel_country_dossier":
+ from .analysis.dossier import fetch_country_dossier
+ return await fetch_country_dossier(fetcher, country=arguments.get("country", "US"))
case "intel_risk_scores":
return await intelligence.fetch_risk_scores(fetcher, limit=arguments.get("limit", 20))
case "intel_instability_index":
@@ -1323,6 +1368,27 @@ async def _dispatch(name: str, arguments: dict[str, Any]) -> Any:
z_threshold=arguments.get("z_threshold", 2.0),
)
+ # Traffic
+ case "intel_traffic_flow":
+ from .sources.traffic import fetch_traffic_flow
+ return await fetch_traffic_flow(fetcher)
+ case "intel_traffic_incidents":
+ from .sources.traffic import fetch_traffic_incidents
+ return await fetch_traffic_incidents(fetcher)
+
+ # Aviation domestic
+ case "intel_aviation_domestic":
+ return await aviation.fetch_domestic_flights(fetcher)
+
+ # Webcams
+ case "intel_webcams":
+ from .sources.webcams import fetch_webcams
+ return await fetch_webcams(
+ fetcher,
+ category=arguments.get("category", "traffic"),
+ limit=arguments.get("limit", 50),
+ )
+
# System
case "intel_status":
return {
diff --git a/src/world_intel_mcp/sources/news.py b/src/world_intel_mcp/sources/news.py
index ad99219..0b9a394 100644
--- a/src/world_intel_mcp/sources/news.py
+++ b/src/world_intel_mcp/sources/news.py
@@ -161,6 +161,37 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = {
("STAT News", "https://www.statnews.com/feed/"),
("WHO News", "https://www.who.int/rss-feeds/news-english.xml"),
("Medical Xpress", "https://medicalxpress.com/rss-feed/"),
+ ("The Lancet", "https://www.thelancet.com/rssfeed/lancet_current.xml"),
+ ],
+ "central_asia": [
+ ("Eurasianet", "https://eurasianet.org/feed"),
+ ("The Astana Times", "https://astanatimes.com/feed/"),
+ ("Radio Free Europe", "https://www.rferl.org/api/zyrttemnuq"),
+ ],
+ "arctic": [
+ ("The Barents Observer", "https://thebarentsobserver.com/en/rss.xml"),
+ ("Arctic Today", "https://www.arctictoday.com/feed/"),
+ ("High North News", "https://www.highnorthnews.com/en/rss.xml"),
+ ],
+ "maritime": [
+ ("Maritime Executive", "https://maritime-executive.com/feed"),
+ ("gCaptain", "https://gcaptain.com/feed/"),
+ ("Lloyd's List", "https://lloydslist.maritimeintelligence.informa.com/rss/all"),
+ ],
+ "space": [
+ ("SpaceRef", "https://spaceref.com/feed/"),
+ ("NASASpaceFlight", "https://www.nasaspaceflight.com/feed/"),
+ ("Space.com", "https://www.space.com/feeds/all"),
+ ],
+ "nuclear": [
+ ("World Nuclear News", "https://world-nuclear-news.org/rss"),
+ ("Arms Control Assn", "https://www.armscontrol.org/rss/all"),
+ ("Nuclear Threat Initiative", "https://www.nti.org/feed/"),
+ ],
+ "climate": [
+ ("Climate Home News", "https://www.climatechangenews.com/feed/"),
+ ("InsideClimate News", "https://insideclimatenews.org/feed/"),
+ ("E&E News", "https://www.eenews.net/feed/"),
],
}
@@ -243,6 +274,26 @@ SOURCE_TIERS: dict[str, str] = {
"HRW": "intl_org",
"The Register": "specialty",
"White House": "government",
+ # Phase 16 additions
+ "The Lancet": "major",
+ "Eurasianet": "specialty",
+ "The Astana Times": "specialty",
+ "Radio Free Europe": "government",
+ "The Barents Observer": "specialty",
+ "Arctic Today": "specialty",
+ "High North News": "specialty",
+ "Maritime Executive": "specialty",
+ "gCaptain": "specialty",
+ "Lloyd's List": "specialty",
+ "SpaceRef": "specialty",
+ "NASASpaceFlight": "specialty",
+ "Space.com": "major",
+ "World Nuclear News": "specialty",
+ "Arms Control Assn": "think_tank",
+ "Nuclear Threat Initiative": "think_tank",
+ "Climate Home News": "specialty",
+ "InsideClimate News": "specialty",
+ "E&E News": "specialty",
}
_STOPWORDS: set[str] = {
diff --git a/src/world_intel_mcp/tests/test_sources.py b/src/world_intel_mcp/tests/test_sources.py
index df04874..a077743 100644
--- a/src/world_intel_mcp/tests/test_sources.py
+++ b/src/world_intel_mcp/tests/test_sources.py
@@ -1183,3 +1183,197 @@ async def test_fetch_financial_centers_filter_country() -> None:
assert result["source"] == "static-geospatial"
assert result["count"] > 0
assert all(fc["iso3"] == "USA" for fc in result["centers"])
+
+
+# ---------------------------------------------------------------------------
+# Country Dossier (Phase 16)
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_fetch_country_dossier(fetcher: Fetcher) -> None:
+ """Dossier aggregates country brief, stocks, elections, sanctions, news."""
+ from world_intel_mcp.analysis.dossier import fetch_country_dossier
+
+ # Mock World Bank GDP
+ respx.get("https://api.worldbank.org/v2/country/US/indicator/NY.GDP.MKTP.CD").mock(
+ return_value=httpx.Response(200, json=[
+ {"page": 1},
+ [{"date": "2024", "value": 28000000000000}],
+ ])
+ )
+ # Mock World Bank inflation
+ respx.get("https://api.worldbank.org/v2/country/US/indicator/FP.CPI.TOTL.ZG").mock(
+ return_value=httpx.Response(200, json=[
+ {"page": 1},
+ [{"date": "2024", "value": 3.2}],
+ ])
+ )
+ # Mock ACLED (no key = skip)
+ # Mock Yahoo Finance for country stocks
+ respx.get("https://query1.finance.yahoo.com/v8/finance/chart/%5EGSPC").mock(
+ return_value=httpx.Response(200, json={
+ "chart": {"result": [{"meta": {
+ "regularMarketPrice": 5800,
+ "chartPreviousClose": 5750,
+ "currency": "USD",
+ "exchangeName": "SNP",
+ }}]},
+ })
+ )
+ # Mock OFAC sanctions
+ respx.get("https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/SDN.XML").mock(
+ return_value=httpx.Response(200, text="")
+ )
+ # Mock news feeds — just one category needed
+ respx.route().mock(return_value=httpx.Response(200, text="""
+ Test
+ - US Economy Growshttps://example.com/1
+ - China Tradehttps://example.com/2
+ """))
+
+ result = await fetch_country_dossier(fetcher, country="US")
+
+ assert result["source"] == "country-dossier"
+ assert result["overview"]["iso2"] == "US"
+ assert result["overview"]["iso3"] == "USA"
+ assert "economy" in result
+ assert "markets" in result
+ assert "elections" in result
+ assert "sanctions" in result
+ assert "news" in result
+ assert "security" in result
+ assert len(result["sections"]) == 7
+
+
+@pytest.mark.asyncio
+async def test_fetch_country_dossier_invalid_code(fetcher: Fetcher) -> None:
+ """Dossier returns error for unknown country code."""
+ from world_intel_mcp.analysis.dossier import fetch_country_dossier
+
+ result = await fetch_country_dossier(fetcher, country="XX")
+ assert "error" in result
+ assert "Unknown" in result["error"]
+
+
+# ---------------------------------------------------------------------------
+# Traffic (Phase 16)
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_fetch_traffic_flow_no_key(fetcher: Fetcher) -> None:
+ """Traffic flow returns error when TOMTOM_API_KEY is not set."""
+ from world_intel_mcp.sources.traffic import fetch_traffic_flow
+
+ with patch.dict("os.environ", {}, clear=True):
+ result = await fetch_traffic_flow(fetcher)
+
+ assert "error" in result
+ assert "TOMTOM_API_KEY" in result["error"]
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_fetch_traffic_flow_with_key(fetcher: Fetcher) -> None:
+ """Traffic flow fetches congestion data from TomTom."""
+ from world_intel_mcp.sources.traffic import fetch_traffic_flow
+
+ respx.route().mock(return_value=httpx.Response(200, json={
+ "flowSegmentData": {
+ "currentSpeed": 30.0,
+ "freeFlowSpeed": 60.0,
+ },
+ }))
+
+ with patch.dict("os.environ", {"TOMTOM_API_KEY": "test-key"}):
+ result = await fetch_traffic_flow(fetcher)
+
+ assert result["source"] == "tomtom"
+ assert result["count"] > 0
+ assert result["global_avg_congestion"] == 50.0 # (1 - 30/60) * 100
+ assert result["cities"][0]["congestion_pct"] == 50
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_fetch_traffic_incidents_no_key(fetcher: Fetcher) -> None:
+ """Traffic incidents returns error when no API key."""
+ from world_intel_mcp.sources.traffic import fetch_traffic_incidents
+
+ with patch.dict("os.environ", {}, clear=True):
+ result = await fetch_traffic_incidents(fetcher)
+
+ assert "error" in result
+
+
+# ---------------------------------------------------------------------------
+# Aviation Domestic (Phase 16)
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_fetch_domestic_flights(fetcher: Fetcher) -> None:
+ """Domestic flights buckets OpenSky states by region."""
+ from world_intel_mcp.sources.aviation import fetch_domestic_flights
+
+ # Simulate 3 airborne aircraft at known positions
+ respx.route().mock(return_value=httpx.Response(200, json={
+ "states": [
+ # [icao24, callsign, origin, ..., on_ground=False, lon, lat, ...]
+ ["abc123", "UAL123 ", "United States", None, None, -73.9, 40.7, 10000, False, None, None, None, None, None, None, None],
+ ["def456", "BAW789 ", "United Kingdom", None, None, -0.1, 51.5, 11000, False, None, None, None, None, None, None, None],
+ ["ghi789", "CCA100 ", "China", None, None, 116.4, 39.9, 12000, False, None, None, None, None, None, None, None],
+ ],
+ }))
+
+ result = await fetch_domestic_flights(fetcher)
+
+ assert result["source"] == "opensky-domestic"
+ assert result["total_aircraft"] == 3
+ assert len(result["by_region"]) > 0
+ assert len(result["busiest_origins"]) > 0
+
+
+# ---------------------------------------------------------------------------
+# Webcams (Phase 16)
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+async def test_fetch_webcams_no_key(fetcher: Fetcher) -> None:
+ """Webcams returns error when WINDY_API_KEY is not set."""
+ from world_intel_mcp.sources.webcams import fetch_webcams
+
+ with patch.dict("os.environ", {}, clear=True):
+ result = await fetch_webcams(fetcher)
+
+ assert "error" in result
+ assert "WINDY_API_KEY" in result["error"]
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_fetch_webcams_with_key(fetcher: Fetcher) -> None:
+ """Webcams fetches camera data from Windy API."""
+ from world_intel_mcp.sources.webcams import fetch_webcams
+
+ respx.route().mock(return_value=httpx.Response(200, json={
+ "webcams": [
+ {
+ "webcamId": "cam-1",
+ "title": "Times Square",
+ "location": {"latitude": 40.758, "longitude": -73.985, "city": "New York", "country": "US"},
+ "images": {"current": {"preview": "https://example.com/prev.jpg", "thumbnail": "https://example.com/thumb.jpg"}},
+ "player": {"day": {"embed": "https://example.com/player"}},
+ "status": "active",
+ },
+ ],
+ }))
+
+ with patch.dict("os.environ", {"WINDY_API_KEY": "test-key"}):
+ result = await fetch_webcams(fetcher, category="traffic", limit=10)
+
+ assert result["source"] == "windy-webcams"
+ assert result["count"] == 1
+ assert result["cameras"][0]["title"] == "Times Square"
+ assert result["cameras"][0]["lat"] == 40.758