1 line
594 KiB
JSON
1 line
594 KiB
JSON
{"repo": "andrewctf/velocity", "files": {"apps/api/app/mcp_server.py": {"590305e0f85cf7adb5b88d9b6f848c41c1aa2b45": {"t": 1785746504.536554, "n": 53528, "h": "7366ee9fdfaec810", "c": "\"\"\"OSINT GEOINT — Model Context Protocol server.\n\nExposes the live OSINT console as tools an AI agent can call. Every tool is a\nthin async wrapper over the ``/api/intel/*`` HTTP surface (``app.routes.intel``)\nso the agent shares the SAME warm in-process snapshot, AOI priority loader, and\nfusion engine the globe uses — no second upstream fan-out, no rate-limit blowup.\n\nDesign principles (the MCP brief):\n- **Context-safe.** Tools return distilled JSON (counts, grids, ≤50-item\n samples), never raw feature dumps. An agent can sweep the whole planet for a\n few hundred tokens.\n- **Area-primary.** ``focus_area`` marks a region PRIMARY: a dedicated fresh\n fetch + ongoing priority refresh, independent of global rate limits. Other\n regions keep streaming from the global snapshot.\n- **Think deeper, off-context.** ``deep_analyze`` pulls the relevant intel JSON\n and hands it to a real reasoning model — DeepSeek (``deepseek-reasoner``) when\n configured, else a local Ollama model — so heavy analysis happens off the\n agent's context and only the conclusion returns.\n\nRun:\n python -m app.mcp_server # stdio (Claude Code / Desktop / SDK)\n python -m app.mcp_server --http --port 8765 # streamable-HTTP\n python -m app.mcp_server --list-tools # introspect, for CI / verification\n\nConfig (env or apps/api/.env): API_BASE, API_KEY, DEEPSEEK_API_KEY (or the\nopencode DeepSeek key), OLLAMA_HOST, OLLAMA_MODEL.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport atexit\nimport os\nimport subprocess\nimport sys\nimport time\nfrom typing import Any\nfrom urllib.parse import quote, urlparse\n\nimport httpx\nfrom mcp.server.fastmcp import FastMCP\nfrom mcp.server.streamable_http_manager import StreamableHTTPSessionManager\nfrom mcp.server.transport_security import TransportSecuritySettings\nfrom starlette.routing import Route\n\nfrom app.config import get_settings\nfrom app.intel.shape import normalize_detail, shape\n\nmcp = FastMCP(\n \"osint-geoint\",\n instructions=(\n \"Live geospatial intelligence over open ADS-B (aircraft), AIS (vessels), \"\n \"GPS-jamming (ADS-B NACp/NIC), Sentinel-1 SAR dark vessels, geocoded \"\n \"events, and a cross-domain fusion engine. Workflow: get_situation() to \"\n \"orient -> intel_brief() for ranked, cited cross-domain INCIDENTS (the \"\n \"fused picture, global or scoped) -> focus_area(lat,lon,radius_nm) to \"\n \"load an incident's region PRIMARY -> query_vessels / gps_jamming / \"\n \"query_aircraft to drill into its evidence -> deep_analyze() to have a \"\n \"reasoning model judge it. intel_brief is the headline tool: it chains \"\n \"signals into incidents so you don't correlate raw layers by hand.\\n\\n\"\n \"CONTEXT BUDGET: most tools take detail='short'|'long'. 'short' (the \"\n \"default) is a token-frugal digest — headline counts plus the top few \"\n \"items of each list, with `*_total` giving the true size — ideal for \"\n \"orientation and broad sweeps. Switch to detail='long' only once you've \"\n \"picked an incident/area worth the full, comprehensive bundle.\"\n ),\n)\n\n\n# ── hosted mount (streamable-HTTP at /mcp of the FastAPI app) ──────────────────\n# The agent-facing endpoint. Mounting the MCP into the SAME uvicorn process the\n# globe runs lets every tool share the warm in-process snapshot + fusion engine\n# (the tools still self-call /api/intel/* over localhost, authenticated by the\n# static API_KEY). The Velocity gateway Worker proxies https://<host>/mcp here,\n# forwarding the caller's Supabase token; the backend's ApiKeyMiddleware gates it\n# like any other non-public route, so no MCP-specific auth is needed.\n\n\nclass _MCPASGIApp:\n \"\"\"Raw-ASGI adapter for the streamable-HTTP session manager.\n\n A plain function endpoint would be wrapped by Starlette as a\n request/response handler; a class instance is left as a raw ASGI app, which\n is what the transport needs (it negotiates SSE itself).\n \"\"\"\n\n def __init__(self, manager: StreamableHTTPSessionManager) -> None:\n self._manager = manager\n\n async def __call__(self, scope: Any, receive: Any, send: Any) -> None:\n await self._manager.handle_request(scope, receive, send)\n\n\ndef build_mcp_mount() -> tuple[list[Route], StreamableHTTPSessionManager]:\n \"\"\"Return (routes, session_manager) to serve this server at ``/mcp``.\n\n A FRESH manager per call — ``StreamableHTTPSessionManager.run()`` is one-shot\n (it raises if entered twice), so every FastAPI app instance, including each\n test app, needs its own. Usage::\n\n routes, mgr = build_mcp_mount()\n app.router.routes.extend(routes) # in the app factory\n async with mgr.run(): ... yield # in the app lifespan\n\n Served as exact ``Route``s (``/mcp`` and ``/mcp/``), NOT a ``Mount`` — a\n mount 307-redirects ``/mcp`` → ``/mcp/`` with a Location built from the\n request host, which through the gateway Worker would point a client at the\n bare backend origin. DNS-rebinding protection is disabled: the endpoint is\n reached server-to-server through the Worker (the Host header is the\n deployment origin, not a browser tab), and the Supabase-token check in\n ``app.auth.ApiKeyMiddleware`` is the real gate.\n \"\"\"\n manager = StreamableHTTPSessionManager(\n app=mcp._mcp_server,\n json_response=False,\n stateless=False,\n security_settings=TransportSecuritySettings(\n enable_dns_rebinding_protection=False\n ),\n )\n endpoint = _MCPASGIApp(manager)\n routes = [Route(\"/mcp\", endpoint), Route(\"/mcp/\", endpoint)]\n return routes, manager\n\n\n# ── backend HTTP plumbing ─────────────────────────────────────────────────────\n\n\ndef _api_base() -> str:\n return (os.environ.get(\"API_BASE\") or get_settings().api_base).rstrip(\"/\")\n\n\n# Cache one minted internal token per secret until ~60 s before it expires, so a\n# burst of tool calls re-signs at most once a minute instead of per request.\n_INTERNAL_JWT_TTL_S = 600 # 10 min token lifetime\n_minted_jwt: tuple[str, float] | None = None # (token, wall-clock expiry)\n\n\ndef _mint_internal_jwt(secret: str) -> str | None:\n \"\"\"Mint a short-lived HS256 token the backend's ``app.auth`` accepts.\n\n In prod the gate is Supabase-JWT-only (no static ``API_KEY``); a server-to-\n server hop carries no browser session, so we sign our own. ``_verify_hs256``\n requires an HS256 signature over the JWT secret, ``role==\"authenticated\"``,\n and an unexpired ``exp`` — we also set the standard Supabase ``aud``/``sub``/\n ``iat`` so the token is well-formed. Cached until ~60 s before ``exp``.\n \"\"\"\n global _minted_jwt\n now = time.time()\n if _minted_jwt is not None:\n token, expiry = _minted_jwt\n if expiry - 60 > now:\n return token\n try:\n import jwt # noqa: PLC0415 — pyjwt, the lib app.auth's HS256 check mirrors\n except Exception: # noqa: BLE001 — pyjwt missing → no Authorization header\n return None\n exp = int(now) + _INTERNAL_JWT_TTL_S\n claims = {\n \"role\": \"authenticated\", # the claim app.auth._verify_hs256 demands\n \"aud\": \"authenticated\",\n \"sub\": \"osint-mcp-internal\",\n \"iat\": int(now),\n \"exp\": exp,\n }\n try:\n token = jwt.encode(claims, secret, algorithm=\"HS256\")\n except Exception: # noqa: BLE001 — encode failure → degrade to no auth header\n return None\n _minted_jwt = (token, float(exp))\n return token\n\n\ndef _headers() -> dict[str, str]:\n s = get_settings()\n key = os.environ.get(\"API_KEY\") or s.api_key\n if key:\n return {\"X-API-Key\": key}\n # No static key (prod): if a Supabase JWT secret is configured, mint an\n # internal token so the self-hop to /api/intel/* authenticates. Otherwise\n # (fully open local box) send nothing, as before.\n secret = s.supabase_jwt_secret\n if secret:\n token = _mint_internal_jwt(secret)\n if token:\n return {\"Authorization\": f\"Bearer {token}\"}\n return {}\n\n\n# ── backend auto-start ────────────────────────────────────────────────────────\n# The MCP server is a thin client over the OSINT backend. If that backend is\n# not running, every tool errors and a driving agent can spin in a retry loop.\n# So on the first tool call we make sure a backend exists: reuse one already\n# listening, else spawn uvicorn ourselves (localhost only) and wait — bounded —\n# for it to come up. Set OSINT_MCP_NO_AUTOSTART=1 to disable.\n\n_BACKEND_LOCK = asyncio.Lock()\n_BACKEND_READY = False\n_BACKEND_PROC: subprocess.Popen[bytes] | None = None\n_AUTOSTART_WAIT_S = 60\n\n\ndef _is_localhost(url: str) -> bool:\n host = (urlparse(url).hostname or \"\").lower()\n return host in {\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"::1\"}\n\n\ndef _api_port() -> int:\n return urlparse(_api_base()).port or 8000\n\n\ndef _apps_api_dir() -> str:\n import app # noqa: PLC0415\n\n return os.path.dirname(os.path.dirname(os.path.abspath(app.__file__)))\n\n\nasync def _backend_healthy(timeout_s: float = 2.0) -> bool:\n try:\n async with httpx.AsyncClient(timeout=timeout_s) as c:\n r = await c.get(_api_base() + \"/api/intel/sources\", headers=_headers())\n return r.status_code == 200\n except Exception:\n return False\n\n\ndef _spawn_uvicorn(cmd: list[str]) -> subprocess.Popen[bytes]:\n \"\"\"Sync spawn (Popen returns immediately; isolated from the async path).\"\"\"\n return subprocess.Popen(\n cmd,\n cwd=_apps_api_dir(),\n stdout=subprocess.DEVNULL,\n stderr=subprocess.DEVNULL,\n env=dict(os.environ),\n )\n\n\ndef _terminate_backend() -> None:\n proc = _BACKEND_PROC\n if proc is not None and proc.poll() is None:\n proc.terminate()\n\n\nasync def _prewarm() -> None:\n \"\"\"Kick the global ADS-B snapshot so the first get_situation isn't slow.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=120.0) as c:\n await c.get(_api_base() + \"/api/adsb/global\", headers=_headers())\n except Exception:\n pass\n\n\nasync def _ensure_backend() -> None:\n \"\"\"Idempotent: guarantee a reachable backend before a tool runs.\"\"\"\n global _BACKEND_READY, _BACKEND_PROC\n if _BACKEND_READY:\n return\n async with _BACKEND_LOCK:\n if _BACKEND_READY:\n return\n if await _backend_healthy():\n _BACKEND_READY = True\n return\n # Only auto-start a LOCAL backend, and only if not opted out.\n if os.environ.get(\"OSINT_MCP_NO_AUTOSTART\") or not _is_localhost(_api_base()):\n return\n # Spawn at most ONCE per process. If a prior attempt didn't come up,\n # don't re-spawn (avoids stacking uvicorns / re-waiting every call) —\n # just let later calls re-check health in case it's still warming.\n if _BACKEND_PROC is not None:\n return\n cmd = [\n sys.executable, \"-m\", \"uvicorn\", \"app.main:app\",\n \"--host\", \"127.0.0.1\", \"--port\", str(_api_port()), \"--log-level\", \"warning\",\n ]\n try:\n _BACKEND_PROC = _spawn_uvicorn(cmd)\n except Exception:\n return # can't spawn (e.g. uvicorn missing) — tools report cleanly\n atexit.register(_terminate_backend)\n for _ in range(_AUTOSTART_WAIT_S):\n await asyncio.sleep(1.0)\n if await _backend_healthy():\n _BACKEND_READY = True\n asyncio.create_task(_prewarm()) # warm snapshot off the hot path\n return\n # Did not come up in time; leave _BACKEND_READY False so the next tool\n # call retries once more rather than looping on a dead spawn.\n\n\nasync def _get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"GET an intel endpoint. Returns parsed JSON or a structured error dict —\n never raises, so a tool call always yields something the agent can read.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in (params or {}).items() if v is not None}\n # Generous ceiling: the backend's FIRST global call cold-starts the ADS-B\n # snapshot (firehose fetch + backoff) and can take up to ~75s. After that\n # every call is instant. A short timeout here would mis-report a healthy\n # backend as unreachable on the agent's very first query.\n try:\n async with httpx.AsyncClient(timeout=90.0) as client:\n r = await client.get(url, params=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\n \"error\": \"backend_unreachable\",\n \"detail\": str(exc),\n \"url\": url,\n \"hint\": \"Backend auto-start did not come up in time. It may still be \"\n \"warming — retry once. Or start it manually: uv run --project \"\n f\"{_apps_api_dir()} uvicorn app.main:app --port {_api_port()}\",\n }\n if r.status_code != 200:\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\nasync def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:\n \"\"\"POST an intel endpoint. Same non-raising, structured-error contract as\n ``_get`` (None fields dropped so the route's own Pydantic defaults apply,\n a non-2xx status becomes a structured error dict, never HTML/an exception).\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in body.items() if v is not None}\n try:\n async with httpx.AsyncClient(timeout=30.0) as client:\n r = await client.post(url, json=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\"error\": \"backend_unreachable\", \"detail\": str(exc), \"url\": url}\n if r.status_code not in (200, 201):\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\nasync def _delete(path: str) -> dict[str, Any]:\n \"\"\"DELETE an intel endpoint. Same non-raising, structured-error contract\n as ``_get``/``_post``. A 204 (no body) is the success case.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n try:\n async with httpx.AsyncClient(timeout=30.0) as client:\n r = await client.delete(url, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\"error\": \"backend_unreachable\", \"detail\": str(exc), \"url\": url}\n if r.status_code not in (200, 204):\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n return {\"ok\": True}\n\n\n# ── tools ─────────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def get_situation(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Global situational snapshot — the cheap first call to orient.\n\n Returns total aircraft (airborne/ground, by category), GNSS-degraded count,\n active emergency squawks, the worst GPS-jamming cells worldwide, tracked\n vessel counts by category, and recent fusion-alert counts. A few hundred\n tokens describing the whole planet.\n\n detail='short' (default) trims the jamming/vessel/alert samples to their\n top few; detail='long' returns the full situation bundle.\"\"\"\n return shape(await _get(\"/api/intel/situation\"), detail)\n\n\n@mcp.tool()\nasync def focus_area(\n lat: float,\n lon: float,\n radius_nm: float = 200.0,\n label: str | None = None,\n cell_deg: float = 1.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Load a region PRIMARY and return a full intel bundle for it in one call.\n\n This is the headline tool. It triggers a DEDICATED, always-fresh fetch for\n just this area (bypassing global rate limits) and registers it for ongoing\n priority refresh. The bundle contains: fresh aircraft summary + sample,\n aircraft-density grid, GPS-jamming assessment, vessels, and fused anomalies\n (emergencies, jamming hotspots, dark vessels, alerts) with a threat level.\n\n Args:\n lat, lon: centre of the area of interest.\n radius_nm: radius in nautical miles (1–250; default 200).\n label: optional human name for the AOI (e.g. \"Kaliningrad\").\n cell_deg: density grid cell size in degrees (0.1–10; default 1.0).\n \"\"\"\n data = await _get(\n \"/api/intel/area\",\n {\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"label\": label,\n \"primary\": True,\n \"cell_deg\": cell_deg,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def aircraft_density(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n cell_deg: float = 1.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Aircraft density over an area as a grid of cells (count, by category,\n GNSS-degraded per cell) plus totals, peak cell, and in-area vessel count.\n\n Give either a centre (lat, lon [, radius_nm]) or an explicit bbox\n (min_lon, min_lat, max_lon, max_lat). cell_deg sets grid resolution.\"\"\"\n data = await _get(\n \"/api/intel/density\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat, \"cell_deg\": cell_deg,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def gps_jamming(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"GPS/GNSS jamming assessment (GPSJam method: ADS-B NACp<8 / NIC<7 binned\n into 1° cells). Returns flagged cells ranked by severity, counts of\n high/medium cells, and a sample of affected aircraft. Omit all coordinates\n for a global view; otherwise pass a centre or a bbox to scope it.\"\"\"\n data = await _get(\n \"/api/intel/jamming\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def query_aircraft(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n category: str | None = None,\n squawk: str | None = None,\n callsign_contains: str | None = None,\n min_alt_m: float | None = None,\n max_alt_m: float | None = None,\n emergency: bool | None = None,\n gnss_degraded: bool | None = None,\n on_ground: bool | None = None,\n limit: int = 50,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Filtered aircraft query against the live snapshot. Returns matched_total\n + a capped list of compact records.\n\n category ∈ airliner|private|helicopter|glider|military|emergency.\n Combine with area (centre or bbox), squawk, callsign_contains, altitude\n band (metres), emergency / gnss_degraded / on_ground booleans. limit ≤200.\"\"\"\n data = await _get(\n \"/api/intel/aircraft\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"category\": category, \"squawk\": squawk,\n \"callsign_contains\": callsign_contains,\n \"min_alt_m\": min_alt_m, \"max_alt_m\": max_alt_m,\n \"emergency\": emergency, \"gnss_degraded\": gnss_degraded,\n \"on_ground\": on_ground, \"limit\": limit,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def lookup_aircraft(ident: str) -> dict[str, Any]:\n \"\"\"Look up one aircraft by ICAO24 hex (exact) or callsign (substring).\n Returns its current state, registration, an integrity/threat assessment,\n and the latest server-held fix.\"\"\"\n # quote(): ident is agent-supplied free text going into a URL path —\n # escape it so \"x/../y\" style values cannot re-route the request.\n return await _get(f\"/api/intel/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def query_vessels(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n dark_only: bool = False,\n limit: int = 50,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Vessels (AIS) in an area, classified (cargo/tanker/fishing/passenger/\n military/sailing/pleasure/tug). dark_only=True returns only dark-vessel\n candidates (moving with no static identity). Scoped by centre or bbox.\"\"\"\n data = await _get(\n \"/api/intel/vessels\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"dark_only\": dark_only, \"limit\": limit,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def anomalies(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Fused anomaly report for an area (or global if no coords): emergency\n aircraft, GPS-jamming hotspots, dark-vessel candidates, recent fusion\n alerts, plus a triage threat_level (low|elevated|high) and score.\"\"\"\n data = await _get(\n \"/api/intel/anomalies\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def intel_brief(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n link_km: float = 50.0,\n window_hours: float = 6.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Cross-domain INCIDENT brief — the headline analytic tool.\n\n Fuses the raw layers into ranked, cited INCIDENTS instead of returning\n signals you have to correlate yourself. An incident is a CONVERGENCE: signals\n within ``link_km`` across >=2 domains (GPS-jamming, dark/AIS-off vessels,\n military air, AIS gaps, emergencies, geocoded events, quakes) — or a single\n critical/high signal. Each incident carries a rule-based narrative, a\n threat_level, the contributing evidence (with IDs), and recommended follow-up\n queries. Omit coordinates for a global brief; pass a centre+radius or bbox to\n scope it. Start here, then drill into an incident's centroid with\n query_vessels / gps_jamming / deep_analyze.\n \"\"\"\n data = await _get(\n \"/api/intel/brief\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"link_km\": link_km, \"window_hours\": window_hours,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def detect_deception(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Denial & deception — \"am I being fed?\". Flags MANIPULATED tracks distinct\n from jamming: AIS duplicate-MMSI (one identity, two hulls) and impossible\n teleports; ADS-B GPS spoofing (many aircraft snapped to one false position)\n and kinematic position-injection. Run before trusting a feed in a contested\n area. Omit coords for global.\"\"\"\n data = await _get(\n \"/api/intel/deception\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def locate_emitter(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Estimate a GPS jammer/spoofer LOCATION from the degraded-ADS-B footprint\n (severity-weighted centroid + CEP + confidence). Turns \"jamming somewhere\n here\" into \"emitter ~here ±N km\". Footprint-centroid estimate (~tens of km),\n not RF direction-finding — stated in the response. Scope with lat/lon.\"\"\"\n data = await _get(\n \"/api/intel/emitter\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def area_baseline(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Is this normal? Current vessel / dark-vessel / jamming / military counts\n z-scored against a rolling baseline, with anomalies called out (e.g. \"dark\n vessels +5σ\", \"traffic -3σ\"). Global uses the background sampler; polling an\n AOI repeatedly builds that area's baseline. Distinguishes a real shift from\n a normal day.\"\"\"\n data = await _get(\n \"/api/intel/baseline\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def whats_changed(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Standing watch — what CHANGED since the last check, not the full picture.\n\n Returns incidents that are NEW, ESCALATED, DE-ESCALATED, or RESOLVED. Global\n (no coords) reflects the background watch loop (recomputed ~every 60s); an\n AOI (lat/lon) diffs against YOUR previous whats_changed call for that area,\n so you can poll one region and be told only what moved. Use this to monitor\n instead of re-reading the whole brief each time.\n \"\"\"\n data = await _get(\n \"/api/intel/watch\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\ndef _compact_points(points: list[dict[str, Any]], max_points: int) -> list[dict[str, Any]]:\n \"\"\"Downsample one incident's observation series to its meaningful shape.\n\n Keeps the first + last point and every point whose threat ``level`` differs\n from its predecessor (a flat run of identical (level, score) is noise on a\n timeline). If transitions alone still exceed ``max_points`` it strides them\n down. Each kept point is shrunk to ``[t, level, score]`` (a list, not a\n verbose dict) so the wire form is a fraction of the size.\"\"\"\n if not points:\n return []\n keep: list[dict[str, Any]] = [points[0]]\n prev = points[0].get(\"level\")\n for p in points[1:-1]:\n if p.get(\"level\") != prev:\n keep.append(p)\n prev = p.get(\"level\")\n if len(points) > 1:\n keep.append(points[-1])\n if len(keep) > max_points:\n # Stride the kept transitions, but always retain the endpoints.\n step = (len(keep) - 1) / (max_points - 1)\n idx = sorted({0, *(round(i * step) for i in range(max_points)), len(keep) - 1})\n keep = [keep[i] for i in idx][:max_points]\n return [[p.get(\"t\"), p.get(\"level\"), p.get(\"score\")] for p in keep]\n\n\ndef _compact_history(data: dict[str, Any], limit: int, max_points: int) -> dict[str, Any]:\n \"\"\"Cap + condense the incident-history payload so the default call fits well\n under the MCP response token cap.\n\n The backend route returns EVERY incident in the window, each with its full\n per-snapshot ``points`` series and full ``narrative`` — ~89 KB at the\n default 6 h, which overflows the cap and hard-errors the tool. We keep the\n most-active incidents (the route already sorts by point-count desc), compact\n each one's series, trim the narrative, and report an honest \"showing N of M\".\n \"\"\"\n incidents = data.get(\"incidents\")\n if not isinstance(incidents, list):\n return data # error payload or unexpected shape — pass through untouched\n total = len(incidents)\n kept = incidents[:limit]\n compact: list[dict[str, Any]] = []\n for inc in kept:\n narrative = inc.get(\"narrative\")\n if isinstance(narrative, str) and len(narrative) > 240:\n narrative = narrative[:237] + \"…\"\n compact.append(\n {\n \"key\": inc.get(\"key\"),\n \"domains\": inc.get(\"domains\"),\n \"centroid\": inc.get(\"centroid\"),\n \"narrative\": narrative,\n # series is [[t, level, score], …] — compact list form\n \"series\": _compact_points(inc.get(\"points\") or [], max_points),\n }\n )\n out = {\n \"scope\": data.get(\"scope\"),\n \"window_hours\": data.get(\"window_hours\"),\n \"snapshots\": data.get(\"snapshots\"),\n \"incident_count\": total,\n \"returned\": len(compact),\n \"truncated\": total > len(compact),\n \"incidents\": compact,\n }\n if out[\"truncated\"]:\n out[\"note\"] = (\n f\"showing {len(compact)} of {total} incidents (most-active first); \"\n \"raise `limit` or scope with lat/lon/hours for more\"\n )\n return out\n\n\n@mcp.tool()\nasync def incident_history(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n hours: float = 6.0,\n limit: int = 25,\n max_incidents: int | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Timeline of how each incident built up over the recent window — per\n incident, a compact ``series`` of ``[time, threat_level, score]`` points.\n Reveals sequence (e.g. jamming first, then dark vessels, then a reported\n event). Global uses the background watch history; an AOI uses your prior\n watch calls.\n\n The full window can hold many incidents; this returns the ``limit`` most\n active ones (default 25, alias ``max_incidents``) with each series\n downsampled to its threat-level transitions, and reports\n ``incident_count`` / ``returned`` / ``truncated`` so you know if more exist.\n Raise ``limit`` or narrow with lat/lon/hours to drill in.\n \"\"\"\n cap = max_incidents if max_incidents is not None else limit\n cap = max(1, min(int(cap), 200))\n data = await _get(\n \"/api/intel/incident-history\",\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm, \"hours\": hours},\n )\n # detail='long' keeps a denser timeline (more transition points) per incident.\n max_points = 40 if normalize_detail(detail) == \"long\" else 12\n return _compact_history(data, cap, max_points=max_points)\n\n\n@mcp.tool()\nasync def vessel_dossier(mmsi: int | str, detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one vessel (MMSI): recent track, AIS gaps,\n derived speed profile (loiter / transit / loiter-then-dash), area covered,\n which live incidents it appears in, and a behaviour assessment. The track is\n the store's ~1h retention window.\"\"\"\n # MMSI is numeric; agents pass it as an int or a string — accept both.\n return shape(await _get(f\"/api/intel/dossier/vessel/{quote(str(mmsi), safe='')}\"), detail)\n\n\n@mcp.tool()\nasync def aircraft_dossier(ident: str, detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one aircraft (ICAO24 hex or callsign): recent\n track, gaps, derived speed profile, GNSS-integrity, emergency/military flags,\n and which live incidents it appears in.\"\"\"\n return shape(await _get(f\"/api/intel/dossier/aircraft/{quote(ident, safe='')}\"), detail)\n\n\n@mcp.tool()\nasync def list_focus_areas() -> dict[str, Any]:\n \"\"\"List the priority areas currently loaded PRIMARY (with fetch stats and\n whether each is served by a dedicated fetch or the degraded snapshot\n fallback).\"\"\"\n return await _get(\"/api/intel/aois\")\n\n\n@mcp.tool()\nasync def data_sources() -> dict[str, Any]:\n \"\"\"Which feeds are always-on vs key-gated, and the configured Ollama\n host/model. Use to explain coverage gaps (e.g. fires need a FIRMS key).\"\"\"\n return await _get(\"/api/intel/sources\")\n\n\n# ── 2026-07-14 data-layers wave: 12 keyless feeds ────────────────────────────\n# Each is a GeoJSON FeatureCollection whose Feature ids are `<kind>:<rawid>`, so\n# an agent can pivot from any hit to /api/entity/<id> and /api/correlations/<id>.\n\n\n@mcp.tool()\nasync def disaster_alerts(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"GDACS severity-scored global disasters (earthquake/cyclone/flood/volcano/\n drought/wildfire), each Green/Orange/Red by modelled impact.\"\"\"\n return shape(await _get(\"/api/hazards/gdacs\"), detail)\n\n\n@mcp.tool()\nasync def fire_perimeters(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Active wildfire burn-area polygons (NIFC/WFIGS) — the AREA behind FIRMS\n hotspot points, with incident name + size in acres.\"\"\"\n return shape(await _get(\"/api/hazards/fire-perimeters\"), detail)\n\n\n@mcp.tool()\nasync def tropical_cyclones(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Active tropical cyclones (NHC) with center fix, classification, intensity\n and pressure.\"\"\"\n return shape(await _get(\"/api/hazards/cyclones\"), detail)\n\n\n@mcp.tool()\nasync def volcanoes(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Smithsonian GVP Holocene volcanoes — location, type, elevation, last\n eruption year.\"\"\"\n return shape(await _get(\"/api/hazards/volcanoes\"), detail)\n\n\n@mcp.tool()\nasync def radiation(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Recent Safecast crowd radiation measurements (value + unit + location) —\n a nuclear-incident detection layer.\"\"\"\n return shape(await _get(\"/api/hazards/radiation\"), detail)\n\n\n@mcp.tool()\nasync def relief_disasters(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"ReliefWeb active humanitarian disasters, geocoded to the affected country.\"\"\"\n return shape(await _get(\"/api/hazards/reliefweb\"), detail)\n\n\n@mcp.tool()\nasync def air_quality(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Air quality (US AQI + PM2.5/PM10) sampled across major world cities —\n a proxy for industrial activity, smoke drift, and chemical release.\"\"\"\n return shape(await _get(\"/api/env/air-quality\"), detail)\n\n\n@mcp.tool()\nasync def marine_buoys(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"NDBC marine buoy observations — wave height, wind, pressure, water temp.\"\"\"\n return shape(await _get(\"/api/maritime/buoys\"), detail)\n\n\n@mcp.tool()\nasync def maritime_chokepoints(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Vessel congestion at named straits (Hormuz/Suez/Malacca/Bosphorus/…),\n derived live from the AIS union: vessel + stationary counts per chokepoint.\"\"\"\n return shape(await _get(\"/api/maritime/chokepoints\"), detail)\n\n\n@mcp.tool()\nasync def space_weather(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"SWPC space weather — solar X-ray flares, active alerts, and the auroral\n oval. The causal layer under GPS/HF degradation.\"\"\"\n return shape(await _get(\"/api/weather/swpc/space\"), detail)\n\n\n@mcp.tool()\nasync def power_plants(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"WRI global power plants (≥200 MW) — name, fuel, capacity, country. Static\n energy-infra targets to correlate strikes/outages against.\"\"\"\n return shape(await _get(\"/api/infra/powerplants\"), detail)\n\n\n@mcp.tool()\nasync def aviation_sigmet(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"AIRMET/SIGMET hazard polygons (turbulence, icing, IFR, volcanic ash,\n convective) constraining the aircraft layer.\"\"\"\n return shape(await _get(\"/api/aviation/sigmet\"), detail)\n\n\n# ── 2026-07-21 context+markets wave ──────────────────────────────────────────\n\n\n@mcp.tool()\nasync def travel_advisories(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Official country-level travel advisories pooled from US State, UK FCDO,\n and Australia Smartraveller — each country's highest reported level.\"\"\"\n return shape(await _get(\"/api/advisories\"), detail)\n\n\n@mcp.tool()\nasync def displacement(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"UN OCHA HAPI country-level IDP and refugee figures, latest reporting\n period per country.\"\"\"\n return shape(await _get(\"/api/displacement\"), detail)\n\n\n@mcp.tool()\nasync def nas_status(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"FAA National Airspace System ground stops, ground delays, arrival/\n departure delays, and airport closures, as points at the affected airport.\"\"\"\n return shape(await _get(\"/api/airspace/nas-status\"), detail)\n\n\n@mcp.tool()\nasync def climate_anomalies(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Temperature/precipitation anomalies (vs. a 5-year same-window baseline)\n over conflict-dense country centroids.\"\"\"\n return shape(await _get(\"/api/climate/anomalies\"), detail)\n\n\n@mcp.tool()\nasync def markets_snapshot(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Batched keyless market quote snapshot: indices, commodities, fx, crypto.\"\"\"\n return shape(await _get(\"/api/markets/snapshot\"), detail)\n\n\n@mcp.tool()\nasync def market_stress(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Composite market-stress score from equity drawdown, gold/oil moves, USD\n flight-to-safety, and BTC drawdown.\"\"\"\n return shape(await _get(\"/api/markets/stress\"), detail)\n\n\n# ── 2026-07-24 REST-parity wave: eq geo-filter, history, watch rules ────────\n# These three capabilities (geo-filtered /api/eq, identity-scoped\n# /api/history/track, per-identity watch-rule CRUD) shipped REST-only in the\n# prior wave with no MCP wrapper — an MCP-restricted agent had no way to reach\n# them. Thin wrappers only; the routes already do the validation.\n\n\n@mcp.tool()\nasync def quakes_near(\n lat: float, lon: float, radius_km: float, range: str = \"day\", detail: str = \"short\"\n) -> dict[str, Any]:\n \"\"\"Earthquakes (USGS) within radius_km of a point (GET /api/eq).\n\n lat, lon, and radius_km must ALL be given together — the route 422s on a\n partial set rather than silently falling back to the unfiltered global\n feed (agent-safe: pass all three or none). range is 'hour'|'day'\n (default)|'week'|'month'. For a severity-scored global disaster view\n instead, use disaster_alerts().\"\"\"\n data = await _get(\n \"/api/eq\", {\"lat\": lat, \"lon\": lon, \"radius_km\": radius_km, \"range\": range}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def track_history(\n id: str,\n from_ts: float | None = None,\n to_ts: float | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Historical position track for ONE entity (GET /api/history/track).\n\n id is 'aircraft:<icao24hex>' / 'vessel:<mmsi>', or a bare id whose shape is\n unambiguous (6-char ICAO24 hex or 9-digit MMSI) — the route infers the\n kind from it. from_ts/to_ts are unix seconds (default: the last hour). A\n id the route can't resolve returns its 422 message as-is (e.g. ambiguous\n shape — pass a 'kind:id' prefix).\"\"\"\n data = await _get(\"/api/history/track\", {\"id\": id, \"from_ts\": from_ts, \"to_ts\": to_ts})\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def create_watch_rule(\n label: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float | None = None,\n kinds: list[str] | None = None,\n min_severity: int = 1,\n channel: str = \"inapp\",\n sink_url: str | None = None,\n enabled: bool = True,\n icao24: str | None = None,\n mmsi: str | None = None,\n callsign: str | None = None,\n) -> dict[str, Any]:\n \"\"\"Create a standing watch rule (POST /api/alerts/rules).\n\n A rule needs a gate: either an identity pin (icao24/mmsi/callsign — follows\n that one entity globally, no AOI required) or a complete AOI (lat, lon;\n radius_nm defaults to 50 nm if omitted). kinds filters which signal kinds\n fire it — jamming/dark_vessel/military_air/military_vessel/incident/quake/\n fire/ais_gap/rendezvous/loiter (omit for all). channel is\n 'inapp'|'discord'|'webhook' ('discord'/'webhook' require sink_url). The\n route validates and 400/422s on a bad gate, channel, sink_url, or kind —\n this wrapper does not re-validate, it passes the error through.\"\"\"\n body = {\n \"label\": label,\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"kinds\": kinds,\n \"min_severity\": min_severity,\n \"channel\": channel,\n \"sink_url\": sink_url,\n \"enabled\": enabled,\n \"icao24\": icao24,\n \"mmsi\": mmsi,\n \"callsign\": callsign,\n }\n return await _post(\"/api/alerts/rules\", body)\n\n\n@mcp.tool()\nasync def list_watch_rules(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"List your standing watch rules (GET /api/alerts/rules).\"\"\"\n return shape(await _get(\"/api/alerts/rules\"), detail)\n\n\n@mcp.tool()\nasync def delete_watch_rule(rule_id: str) -> dict[str, Any]:\n \"\"\"Delete a standing watch rule by id (DELETE /api/alerts/rules/{rule_id}).\"\"\"\n return await _delete(f\"/api/alerts/rules/{quote(rule_id, safe='')}\")\n\n\n# ── deep analysis (DeepSeek reasoner, Ollama fallback) ────────────────────────\n\n_SYS_PROMPT = (\n \"You are a GEOINT analyst working a live open-source intelligence console. \"\n \"You are given distilled JSON from live ADS-B (aircraft), AIS (vessels), a \"\n \"GPS-jamming layer (ADS-B NACp/NIC, GPSJam method), and a cross-domain \"\n \"fusion engine whose `incident_brief` already chains co-located signals into \"\n \"ranked, cited INCIDENTS — lead your analysis from those incidents, then use \"\n \"the raw situation/area data to corroborate or challenge them. \"\n \"Reason ONLY over the provided JSON. Cite concrete numbers and IDs. Flag: \"\n \"GPS jamming/spoofing footprints, emergency squawks (7500 hijack / 7600 \"\n \"radio-fail / 7700 general), dark vessels, abnormal traffic density, and \"\n \"military activity. Be concise and structured: (1) ASSESSMENT (2 lines), \"\n \"(2) KEY FINDINGS (bullets with numbers), (3) RECOMMENDED FOLLOW-UP \"\n \"queries. If the data is thin, say so plainly — do not invent.\"\n)\n\n\n@mcp.tool()\nasync def deep_analyze(\n question: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 250.0,\n model: str | None = None,\n tier: str = \"reason\",\n) -> dict[str, Any]:\n \"\"\"Have a reasoning model reason deeply over the live intel.\n\n Gathers the relevant data (global situation, plus a focused area bundle +\n jamming + anomalies when lat/lon given) and feeds it to a real reasoning\n model — DeepSeek (``deepseek-reasoner``) when configured, else a local\n Ollama model. Heavy reasoning runs off-context; only the conclusion enters\n the agent's context. Falls back to returning the raw structured data\n (analysis=null) if no LLM backend is reachable.\n\n Args:\n question: what to analyse (e.g. \"Is there coordinated GPS jamming near\n the Baltic, and which aircraft are affected?\").\n lat, lon, radius_nm: optional area to focus on (loaded PRIMARY).\n model: optional Ollama model override used only on the Ollama fallback.\n tier: ``reason`` (deepseek-reasoner, default — judgement) or ``fast``\n (deepseek-chat — quicker, shallower).\n \"\"\"\n import json as _json\n\n from app import llm # noqa: PLC0415\n\n # 1) gather context. Pull detail='long' internally — the reasoner runs\n # off-context with its own large window, so it should judge the FULL fused\n # picture, not the token-frugal digest the agent-facing default returns.\n context: dict[str, Any] = {\n \"question\": question,\n \"situation\": await get_situation(detail=\"long\"),\n }\n context[\"incident_brief\"] = await intel_brief(\n lat=lat, lon=lon, radius_nm=radius_nm,\n window_hours=12.0 if lat is None else 6.0,\n detail=\"long\",\n )\n if lat is not None and lon is not None:\n context[\"focus_area\"] = await focus_area(lat, lon, radius_nm, detail=\"long\")\n\n # 2) reason off-context (DeepSeek → Ollama → raw data). Cap the wait so a\n # slow upstream can't hang the tool for the 180 s default — the fast tier\n # should answer well under 60 s, the deeper reason tier under 90 s.\n timeout_s = 60.0 if (tier or \"\").lower() == \"fast\" else 90.0\n res = await llm.complete(\n _SYS_PROMPT,\n f\"QUESTION: {question}\\n\\nLIVE INTEL JSON:\\n\"\n + _json.dumps(context, separators=(\",\", \":\"))[:60000],\n tier=tier,\n temperature=0.2,\n max_tokens=2048,\n timeout_s=timeout_s,\n ollama_model=model or \"\",\n )\n if not res.ok:\n return {\n \"analysis\": None,\n \"model\": res.model,\n \"backend\": res.backend,\n \"note\": f\"No LLM backend reachable ({res.error}). Returning structured \"\n \"intel for you to analyse directly. Configure DEEPSEEK_API_KEY / the \"\n \"opencode DeepSeek key, or `ollama pull qwen2.5:3b`.\",\n \"data\": context,\n }\n\n return {\n \"analysis\": res.text,\n \"model\": res.model,\n \"backend\": res.backend,\n \"focused_on\": (\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm} if lat is not None else \"global\"\n ),\n \"data_summary\": {\n \"aircraft_total\": context[\"situation\"].get(\"aircraft\", {}).get(\"total\"),\n \"jamming_high\": context[\"situation\"].get(\"gps_jamming\", {}).get(\"high\"),\n },\n }\n\n\n# ── news intelligence (debias / fact-check) ──────────────────────────────────\n\n\n@mcp.tool()\nasync def news_analysis() -> dict[str, Any]:\n \"\"\"Cross-source, debiased world-news intelligence.\n\n Scrapes ~12 outlets (BBC, Reuters, AP, Al Jazeera, Guardian, CNN, Fox, …),\n then a reasoning model strips bias/propaganda and separates VERIFIED FACTS\n (corroborated by ≥2 independent outlets) from ATTRIBUTED CLAIMS and\n rhetoric. A leader promising \"the war will end soon\" is flagged as rhetoric,\n never reported as fact. Returns events with neutral_summary, verified_facts,\n attributed_claims, bias_flags, propaganda_techniques, rhetoric_flags and a\n confidence. May take ~30s when the cache is cold (it reasons over the feed).\n \"\"\"\n return await _get(\"/api/news/analysis\")\n\n\n@mcp.tool()\nasync def fact_check(claim: str) -> dict[str, Any]:\n \"\"\"Adjudicate one free-text claim against current world-news headlines.\n\n Returns ``{verdict: true|false|misleading|unverified, reasoning,\n supporting_sources, confidence}``. Use it to sanity-check a statement before\n treating it as fact.\n \"\"\"\n return await _get(\"/api/news/factcheck\", {\"claim\": claim})\n\n\n@mcp.tool()\nasync def news_brief(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Latest assembled morning brief: top story per category plus a short\n synthesis paragraph tying the leads together. Built on a ~20h cadence by\n the background news refresher, not on-demand — use ``news_analysis`` or\n the edition route for the freshest per-story detail.\n \"\"\"\n return shape(await _get(\"/api/news/brief\"), detail)\n\n\n@mcp.tool()\nasync def aoi_imagery(\n before: str,\n after: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_km: float = 5.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n window_days: int = 30,\n) -> dict[str, Any]:\n \"\"\"Building imagery for a location at two dates — set time + place directly.\n\n Give a location (lat/lon + radius_km, OR an explicit min/max bbox) and a\n before + after date (YYYY-MM-DD). Returns what imagery is available for each\n date WITHOUT downloading: Maxar Open Data VHR (~0.3-0.5 m, event-gated — only\n where a disaster/conflict event covers the AOI) and Sentinel (10 m, global,\n any date). `best_source` says which to use. Reconstruction downloads the\n scenes to a temp dir on demand and discards them — nothing is stored.\n\n Args:\n before, after: dates as YYYY-MM-DD.\n lat, lon, radius_km: centre + radius (km) of the AOI.\n min_lon/min_lat/max_lon/max_lat: explicit bbox (overrides lat/lon).\n window_days: ± days around each date to search Maxar (events are sparse).\n \"\"\"\n params: dict[str, Any] = {\n \"before\": before,\n \"after\": after,\n \"radius_km\": radius_km,\n \"window_days\": window_days,\n }\n if None not in (min_lon, min_lat, max_lon, max_lat):\n params.update(min_lon=min_lon, min_lat=min_lat, max_lon=max_lon, max_lat=max_lat)\n else:\n params.update(lat=lat, lon=lon)\n return await _get(\"/api/imagery/aoi\", params)\n\n\n# ── entrypoint ─────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def answer(question_id: str = \"\") -> dict[str, Any]:\n \"\"\"Named questions answered with a verdict, its rule, and the evidence age.\n\n The one call that returns a CONCLUSION rather than data to interpret. Each\n answer carries `verdict` (open / reduced / closed / unknown), `threshold`\n (the rule that produced it, in words), `data_lag_s` (how old the newest\n evidence is), and `stale`.\n\n Two contracts worth relaying to a user verbatim rather than paraphrasing:\n `unknown` is a real answer and its `detail` says why we cannot answer yet,\n and `data_lag_s` of null means NO EVIDENCE WAS OBSERVED - never \"fresh\".\n Report `stale` alongside any verdict you quote.\n\n Omit question_id to list every registered question.\"\"\"\n if question_id:\n return await _get(f\"/api/answers/{question_id}\")\n return await _get(\"/api/answers\")\n\n\n@mcp.tool()\nasync def contact_provenance() -> dict[str, Any]:\n \"\"\"Which sources are seeing the sky right now, and how much they agree.\n\n Per tier: how many contacts it reported and how many ONLY it reported, plus\n the share of contacts with two or more independent observers and the\n confidence rule in force.\n\n The exclusive count is the column that matters. Crowd-sourced ADS-B accepts\n whatever is uploaded to it, so a contact only one tier can see is not\n corroborated, and a tier contributing thousands of exclusive contacts is\n either unique coverage or an unverifiable claim. Say which when you report\n it, and never present an uncorroborated contact as confirmed.\"\"\"\n return await _get(\"/api/status/provenance\")\n\n\n@mcp.tool()\nasync def history_diff(\n lamin: float,\n lomin: float,\n lamax: float,\n lomax: float,\n at_a: float,\n at_b: float | None = None,\n window_sec: int = 600,\n kind: str | None = None,\n) -> dict[str, Any]:\n \"\"\"What changed inside a box between two moments: arrived, departed, stayed.\n\n Answers from OWNED history, so it works for any past window still retained\n rather than only the present instant. `at_a` and `at_b` are epoch seconds\n (at_b defaults to now); each is widened by `window_sec` because a stored\n position is a sample rather than a continuous truth.\n\n `recorded: false` means nothing was in the archive for that box and window -\n which is NOT the same as \"nothing changed\". Do not report an empty archive\n as a quiet period.\"\"\"\n params: dict[str, Any] = {\n \"lamin\": lamin,\n \"lomin\": lomin,\n \"lamax\": lamax,\n \"lomax\": lomax,\n \"at_a\": at_a,\n \"window_sec\": window_sec,\n }\n if at_b is not None:\n params[\"at_b\"] = at_b\n if kind:\n params[\"kind\"] = kind\n return await _get(\"/api/history/diff\", params)\n\n\n@mcp.tool()\nasync def system_doctor() -> dict[str, Any]:\n \"\"\"What is configured, what is missing, and the exact line that fixes it.\n\n Use this when a layer looks empty before concluding the world is quiet: a\n blank layer is far more often an unconfigured optional source than an\n absence of activity. Every capability listed is optional - the console runs\n keyless - so a long `problems` list is not a fault list. Never report a\n missing optional key as a defect.\"\"\"\n return await _get(\"/api/status/doctor\")\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=\"OSINT GEOINT MCP server\")\n parser.add_argument(\"--http\", action=\"store_true\", help=\"serve over streamable-HTTP\")\n parser.add_argument(\"--port\", type=int, default=8765, help=\"HTTP port (with --http)\")\n parser.add_argument(\"--host\", default=\"127.0.0.1\", help=\"HTTP host (with --http)\")\n parser.add_argument(\n \"--list-tools\", action=\"store_true\", help=\"print registered tools and exit\"\n )\n args = parser.parse_args()\n\n if args.list_tools:\n tools = asyncio.run(mcp.list_tools())\n print(f\"osint-geoint MCP — {len(tools)} tools:\")\n for t in tools:\n summary = (t.description or \"\").split(\"\\n\", 1)[0]\n print(f\" • {t.name}: {summary}\")\n return\n\n if args.http:\n mcp.settings.host = args.host\n mcp.settings.port = args.port\n mcp.run(transport=\"streamable-http\")\n else:\n mcp.run() # stdio\n\n\nif __name__ == \"__main__\":\n main()\n"}, "ff0070f27499008071bb82b0e3a7081fceefb3f4": {"t": 1785765042.5562575, "n": 53528, "h": "7366ee9fdfaec810", "c": "\"\"\"OSINT GEOINT — Model Context Protocol server.\n\nExposes the live OSINT console as tools an AI agent can call. Every tool is a\nthin async wrapper over the ``/api/intel/*`` HTTP surface (``app.routes.intel``)\nso the agent shares the SAME warm in-process snapshot, AOI priority loader, and\nfusion engine the globe uses — no second upstream fan-out, no rate-limit blowup.\n\nDesign principles (the MCP brief):\n- **Context-safe.** Tools return distilled JSON (counts, grids, ≤50-item\n samples), never raw feature dumps. An agent can sweep the whole planet for a\n few hundred tokens.\n- **Area-primary.** ``focus_area`` marks a region PRIMARY: a dedicated fresh\n fetch + ongoing priority refresh, independent of global rate limits. Other\n regions keep streaming from the global snapshot.\n- **Think deeper, off-context.** ``deep_analyze`` pulls the relevant intel JSON\n and hands it to a real reasoning model — DeepSeek (``deepseek-reasoner``) when\n configured, else a local Ollama model — so heavy analysis happens off the\n agent's context and only the conclusion returns.\n\nRun:\n python -m app.mcp_server # stdio (Claude Code / Desktop / SDK)\n python -m app.mcp_server --http --port 8765 # streamable-HTTP\n python -m app.mcp_server --list-tools # introspect, for CI / verification\n\nConfig (env or apps/api/.env): API_BASE, API_KEY, DEEPSEEK_API_KEY (or the\nopencode DeepSeek key), OLLAMA_HOST, OLLAMA_MODEL.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport atexit\nimport os\nimport subprocess\nimport sys\nimport time\nfrom typing import Any\nfrom urllib.parse import quote, urlparse\n\nimport httpx\nfrom mcp.server.fastmcp import FastMCP\nfrom mcp.server.streamable_http_manager import StreamableHTTPSessionManager\nfrom mcp.server.transport_security import TransportSecuritySettings\nfrom starlette.routing import Route\n\nfrom app.config import get_settings\nfrom app.intel.shape import normalize_detail, shape\n\nmcp = FastMCP(\n \"osint-geoint\",\n instructions=(\n \"Live geospatial intelligence over open ADS-B (aircraft), AIS (vessels), \"\n \"GPS-jamming (ADS-B NACp/NIC), Sentinel-1 SAR dark vessels, geocoded \"\n \"events, and a cross-domain fusion engine. Workflow: get_situation() to \"\n \"orient -> intel_brief() for ranked, cited cross-domain INCIDENTS (the \"\n \"fused picture, global or scoped) -> focus_area(lat,lon,radius_nm) to \"\n \"load an incident's region PRIMARY -> query_vessels / gps_jamming / \"\n \"query_aircraft to drill into its evidence -> deep_analyze() to have a \"\n \"reasoning model judge it. intel_brief is the headline tool: it chains \"\n \"signals into incidents so you don't correlate raw layers by hand.\\n\\n\"\n \"CONTEXT BUDGET: most tools take detail='short'|'long'. 'short' (the \"\n \"default) is a token-frugal digest — headline counts plus the top few \"\n \"items of each list, with `*_total` giving the true size — ideal for \"\n \"orientation and broad sweeps. Switch to detail='long' only once you've \"\n \"picked an incident/area worth the full, comprehensive bundle.\"\n ),\n)\n\n\n# ── hosted mount (streamable-HTTP at /mcp of the FastAPI app) ──────────────────\n# The agent-facing endpoint. Mounting the MCP into the SAME uvicorn process the\n# globe runs lets every tool share the warm in-process snapshot + fusion engine\n# (the tools still self-call /api/intel/* over localhost, authenticated by the\n# static API_KEY). The Velocity gateway Worker proxies https://<host>/mcp here,\n# forwarding the caller's Supabase token; the backend's ApiKeyMiddleware gates it\n# like any other non-public route, so no MCP-specific auth is needed.\n\n\nclass _MCPASGIApp:\n \"\"\"Raw-ASGI adapter for the streamable-HTTP session manager.\n\n A plain function endpoint would be wrapped by Starlette as a\n request/response handler; a class instance is left as a raw ASGI app, which\n is what the transport needs (it negotiates SSE itself).\n \"\"\"\n\n def __init__(self, manager: StreamableHTTPSessionManager) -> None:\n self._manager = manager\n\n async def __call__(self, scope: Any, receive: Any, send: Any) -> None:\n await self._manager.handle_request(scope, receive, send)\n\n\ndef build_mcp_mount() -> tuple[list[Route], StreamableHTTPSessionManager]:\n \"\"\"Return (routes, session_manager) to serve this server at ``/mcp``.\n\n A FRESH manager per call — ``StreamableHTTPSessionManager.run()`` is one-shot\n (it raises if entered twice), so every FastAPI app instance, including each\n test app, needs its own. Usage::\n\n routes, mgr = build_mcp_mount()\n app.router.routes.extend(routes) # in the app factory\n async with mgr.run(): ... yield # in the app lifespan\n\n Served as exact ``Route``s (``/mcp`` and ``/mcp/``), NOT a ``Mount`` — a\n mount 307-redirects ``/mcp`` → ``/mcp/`` with a Location built from the\n request host, which through the gateway Worker would point a client at the\n bare backend origin. DNS-rebinding protection is disabled: the endpoint is\n reached server-to-server through the Worker (the Host header is the\n deployment origin, not a browser tab), and the Supabase-token check in\n ``app.auth.ApiKeyMiddleware`` is the real gate.\n \"\"\"\n manager = StreamableHTTPSessionManager(\n app=mcp._mcp_server,\n json_response=False,\n stateless=False,\n security_settings=TransportSecuritySettings(\n enable_dns_rebinding_protection=False\n ),\n )\n endpoint = _MCPASGIApp(manager)\n routes = [Route(\"/mcp\", endpoint), Route(\"/mcp/\", endpoint)]\n return routes, manager\n\n\n# ── backend HTTP plumbing ─────────────────────────────────────────────────────\n\n\ndef _api_base() -> str:\n return (os.environ.get(\"API_BASE\") or get_settings().api_base).rstrip(\"/\")\n\n\n# Cache one minted internal token per secret until ~60 s before it expires, so a\n# burst of tool calls re-signs at most once a minute instead of per request.\n_INTERNAL_JWT_TTL_S = 600 # 10 min token lifetime\n_minted_jwt: tuple[str, float] | None = None # (token, wall-clock expiry)\n\n\ndef _mint_internal_jwt(secret: str) -> str | None:\n \"\"\"Mint a short-lived HS256 token the backend's ``app.auth`` accepts.\n\n In prod the gate is Supabase-JWT-only (no static ``API_KEY``); a server-to-\n server hop carries no browser session, so we sign our own. ``_verify_hs256``\n requires an HS256 signature over the JWT secret, ``role==\"authenticated\"``,\n and an unexpired ``exp`` — we also set the standard Supabase ``aud``/``sub``/\n ``iat`` so the token is well-formed. Cached until ~60 s before ``exp``.\n \"\"\"\n global _minted_jwt\n now = time.time()\n if _minted_jwt is not None:\n token, expiry = _minted_jwt\n if expiry - 60 > now:\n return token\n try:\n import jwt # noqa: PLC0415 — pyjwt, the lib app.auth's HS256 check mirrors\n except Exception: # noqa: BLE001 — pyjwt missing → no Authorization header\n return None\n exp = int(now) + _INTERNAL_JWT_TTL_S\n claims = {\n \"role\": \"authenticated\", # the claim app.auth._verify_hs256 demands\n \"aud\": \"authenticated\",\n \"sub\": \"osint-mcp-internal\",\n \"iat\": int(now),\n \"exp\": exp,\n }\n try:\n token = jwt.encode(claims, secret, algorithm=\"HS256\")\n except Exception: # noqa: BLE001 — encode failure → degrade to no auth header\n return None\n _minted_jwt = (token, float(exp))\n return token\n\n\ndef _headers() -> dict[str, str]:\n s = get_settings()\n key = os.environ.get(\"API_KEY\") or s.api_key\n if key:\n return {\"X-API-Key\": key}\n # No static key (prod): if a Supabase JWT secret is configured, mint an\n # internal token so the self-hop to /api/intel/* authenticates. Otherwise\n # (fully open local box) send nothing, as before.\n secret = s.supabase_jwt_secret\n if secret:\n token = _mint_internal_jwt(secret)\n if token:\n return {\"Authorization\": f\"Bearer {token}\"}\n return {}\n\n\n# ── backend auto-start ────────────────────────────────────────────────────────\n# The MCP server is a thin client over the OSINT backend. If that backend is\n# not running, every tool errors and a driving agent can spin in a retry loop.\n# So on the first tool call we make sure a backend exists: reuse one already\n# listening, else spawn uvicorn ourselves (localhost only) and wait — bounded —\n# for it to come up. Set OSINT_MCP_NO_AUTOSTART=1 to disable.\n\n_BACKEND_LOCK = asyncio.Lock()\n_BACKEND_READY = False\n_BACKEND_PROC: subprocess.Popen[bytes] | None = None\n_AUTOSTART_WAIT_S = 60\n\n\ndef _is_localhost(url: str) -> bool:\n host = (urlparse(url).hostname or \"\").lower()\n return host in {\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"::1\"}\n\n\ndef _api_port() -> int:\n return urlparse(_api_base()).port or 8000\n\n\ndef _apps_api_dir() -> str:\n import app # noqa: PLC0415\n\n return os.path.dirname(os.path.dirname(os.path.abspath(app.__file__)))\n\n\nasync def _backend_healthy(timeout_s: float = 2.0) -> bool:\n try:\n async with httpx.AsyncClient(timeout=timeout_s) as c:\n r = await c.get(_api_base() + \"/api/intel/sources\", headers=_headers())\n return r.status_code == 200\n except Exception:\n return False\n\n\ndef _spawn_uvicorn(cmd: list[str]) -> subprocess.Popen[bytes]:\n \"\"\"Sync spawn (Popen returns immediately; isolated from the async path).\"\"\"\n return subprocess.Popen(\n cmd,\n cwd=_apps_api_dir(),\n stdout=subprocess.DEVNULL,\n stderr=subprocess.DEVNULL,\n env=dict(os.environ),\n )\n\n\ndef _terminate_backend() -> None:\n proc = _BACKEND_PROC\n if proc is not None and proc.poll() is None:\n proc.terminate()\n\n\nasync def _prewarm() -> None:\n \"\"\"Kick the global ADS-B snapshot so the first get_situation isn't slow.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=120.0) as c:\n await c.get(_api_base() + \"/api/adsb/global\", headers=_headers())\n except Exception:\n pass\n\n\nasync def _ensure_backend() -> None:\n \"\"\"Idempotent: guarantee a reachable backend before a tool runs.\"\"\"\n global _BACKEND_READY, _BACKEND_PROC\n if _BACKEND_READY:\n return\n async with _BACKEND_LOCK:\n if _BACKEND_READY:\n return\n if await _backend_healthy():\n _BACKEND_READY = True\n return\n # Only auto-start a LOCAL backend, and only if not opted out.\n if os.environ.get(\"OSINT_MCP_NO_AUTOSTART\") or not _is_localhost(_api_base()):\n return\n # Spawn at most ONCE per process. If a prior attempt didn't come up,\n # don't re-spawn (avoids stacking uvicorns / re-waiting every call) —\n # just let later calls re-check health in case it's still warming.\n if _BACKEND_PROC is not None:\n return\n cmd = [\n sys.executable, \"-m\", \"uvicorn\", \"app.main:app\",\n \"--host\", \"127.0.0.1\", \"--port\", str(_api_port()), \"--log-level\", \"warning\",\n ]\n try:\n _BACKEND_PROC = _spawn_uvicorn(cmd)\n except Exception:\n return # can't spawn (e.g. uvicorn missing) — tools report cleanly\n atexit.register(_terminate_backend)\n for _ in range(_AUTOSTART_WAIT_S):\n await asyncio.sleep(1.0)\n if await _backend_healthy():\n _BACKEND_READY = True\n asyncio.create_task(_prewarm()) # warm snapshot off the hot path\n return\n # Did not come up in time; leave _BACKEND_READY False so the next tool\n # call retries once more rather than looping on a dead spawn.\n\n\nasync def _get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"GET an intel endpoint. Returns parsed JSON or a structured error dict —\n never raises, so a tool call always yields something the agent can read.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in (params or {}).items() if v is not None}\n # Generous ceiling: the backend's FIRST global call cold-starts the ADS-B\n # snapshot (firehose fetch + backoff) and can take up to ~75s. After that\n # every call is instant. A short timeout here would mis-report a healthy\n # backend as unreachable on the agent's very first query.\n try:\n async with httpx.AsyncClient(timeout=90.0) as client:\n r = await client.get(url, params=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\n \"error\": \"backend_unreachable\",\n \"detail\": str(exc),\n \"url\": url,\n \"hint\": \"Backend auto-start did not come up in time. It may still be \"\n \"warming — retry once. Or start it manually: uv run --project \"\n f\"{_apps_api_dir()} uvicorn app.main:app --port {_api_port()}\",\n }\n if r.status_code != 200:\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\nasync def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:\n \"\"\"POST an intel endpoint. Same non-raising, structured-error contract as\n ``_get`` (None fields dropped so the route's own Pydantic defaults apply,\n a non-2xx status becomes a structured error dict, never HTML/an exception).\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in body.items() if v is not None}\n try:\n async with httpx.AsyncClient(timeout=30.0) as client:\n r = await client.post(url, json=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\"error\": \"backend_unreachable\", \"detail\": str(exc), \"url\": url}\n if r.status_code not in (200, 201):\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\nasync def _delete(path: str) -> dict[str, Any]:\n \"\"\"DELETE an intel endpoint. Same non-raising, structured-error contract\n as ``_get``/``_post``. A 204 (no body) is the success case.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n try:\n async with httpx.AsyncClient(timeout=30.0) as client:\n r = await client.delete(url, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\"error\": \"backend_unreachable\", \"detail\": str(exc), \"url\": url}\n if r.status_code not in (200, 204):\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n return {\"ok\": True}\n\n\n# ── tools ─────────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def get_situation(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Global situational snapshot — the cheap first call to orient.\n\n Returns total aircraft (airborne/ground, by category), GNSS-degraded count,\n active emergency squawks, the worst GPS-jamming cells worldwide, tracked\n vessel counts by category, and recent fusion-alert counts. A few hundred\n tokens describing the whole planet.\n\n detail='short' (default) trims the jamming/vessel/alert samples to their\n top few; detail='long' returns the full situation bundle.\"\"\"\n return shape(await _get(\"/api/intel/situation\"), detail)\n\n\n@mcp.tool()\nasync def focus_area(\n lat: float,\n lon: float,\n radius_nm: float = 200.0,\n label: str | None = None,\n cell_deg: float = 1.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Load a region PRIMARY and return a full intel bundle for it in one call.\n\n This is the headline tool. It triggers a DEDICATED, always-fresh fetch for\n just this area (bypassing global rate limits) and registers it for ongoing\n priority refresh. The bundle contains: fresh aircraft summary + sample,\n aircraft-density grid, GPS-jamming assessment, vessels, and fused anomalies\n (emergencies, jamming hotspots, dark vessels, alerts) with a threat level.\n\n Args:\n lat, lon: centre of the area of interest.\n radius_nm: radius in nautical miles (1–250; default 200).\n label: optional human name for the AOI (e.g. \"Kaliningrad\").\n cell_deg: density grid cell size in degrees (0.1–10; default 1.0).\n \"\"\"\n data = await _get(\n \"/api/intel/area\",\n {\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"label\": label,\n \"primary\": True,\n \"cell_deg\": cell_deg,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def aircraft_density(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n cell_deg: float = 1.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Aircraft density over an area as a grid of cells (count, by category,\n GNSS-degraded per cell) plus totals, peak cell, and in-area vessel count.\n\n Give either a centre (lat, lon [, radius_nm]) or an explicit bbox\n (min_lon, min_lat, max_lon, max_lat). cell_deg sets grid resolution.\"\"\"\n data = await _get(\n \"/api/intel/density\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat, \"cell_deg\": cell_deg,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def gps_jamming(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"GPS/GNSS jamming assessment (GPSJam method: ADS-B NACp<8 / NIC<7 binned\n into 1° cells). Returns flagged cells ranked by severity, counts of\n high/medium cells, and a sample of affected aircraft. Omit all coordinates\n for a global view; otherwise pass a centre or a bbox to scope it.\"\"\"\n data = await _get(\n \"/api/intel/jamming\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def query_aircraft(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n category: str | None = None,\n squawk: str | None = None,\n callsign_contains: str | None = None,\n min_alt_m: float | None = None,\n max_alt_m: float | None = None,\n emergency: bool | None = None,\n gnss_degraded: bool | None = None,\n on_ground: bool | None = None,\n limit: int = 50,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Filtered aircraft query against the live snapshot. Returns matched_total\n + a capped list of compact records.\n\n category ∈ airliner|private|helicopter|glider|military|emergency.\n Combine with area (centre or bbox), squawk, callsign_contains, altitude\n band (metres), emergency / gnss_degraded / on_ground booleans. limit ≤200.\"\"\"\n data = await _get(\n \"/api/intel/aircraft\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"category\": category, \"squawk\": squawk,\n \"callsign_contains\": callsign_contains,\n \"min_alt_m\": min_alt_m, \"max_alt_m\": max_alt_m,\n \"emergency\": emergency, \"gnss_degraded\": gnss_degraded,\n \"on_ground\": on_ground, \"limit\": limit,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def lookup_aircraft(ident: str) -> dict[str, Any]:\n \"\"\"Look up one aircraft by ICAO24 hex (exact) or callsign (substring).\n Returns its current state, registration, an integrity/threat assessment,\n and the latest server-held fix.\"\"\"\n # quote(): ident is agent-supplied free text going into a URL path —\n # escape it so \"x/../y\" style values cannot re-route the request.\n return await _get(f\"/api/intel/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def query_vessels(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n dark_only: bool = False,\n limit: int = 50,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Vessels (AIS) in an area, classified (cargo/tanker/fishing/passenger/\n military/sailing/pleasure/tug). dark_only=True returns only dark-vessel\n candidates (moving with no static identity). Scoped by centre or bbox.\"\"\"\n data = await _get(\n \"/api/intel/vessels\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"dark_only\": dark_only, \"limit\": limit,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def anomalies(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Fused anomaly report for an area (or global if no coords): emergency\n aircraft, GPS-jamming hotspots, dark-vessel candidates, recent fusion\n alerts, plus a triage threat_level (low|elevated|high) and score.\"\"\"\n data = await _get(\n \"/api/intel/anomalies\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def intel_brief(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n link_km: float = 50.0,\n window_hours: float = 6.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Cross-domain INCIDENT brief — the headline analytic tool.\n\n Fuses the raw layers into ranked, cited INCIDENTS instead of returning\n signals you have to correlate yourself. An incident is a CONVERGENCE: signals\n within ``link_km`` across >=2 domains (GPS-jamming, dark/AIS-off vessels,\n military air, AIS gaps, emergencies, geocoded events, quakes) — or a single\n critical/high signal. Each incident carries a rule-based narrative, a\n threat_level, the contributing evidence (with IDs), and recommended follow-up\n queries. Omit coordinates for a global brief; pass a centre+radius or bbox to\n scope it. Start here, then drill into an incident's centroid with\n query_vessels / gps_jamming / deep_analyze.\n \"\"\"\n data = await _get(\n \"/api/intel/brief\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"link_km\": link_km, \"window_hours\": window_hours,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def detect_deception(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Denial & deception — \"am I being fed?\". Flags MANIPULATED tracks distinct\n from jamming: AIS duplicate-MMSI (one identity, two hulls) and impossible\n teleports; ADS-B GPS spoofing (many aircraft snapped to one false position)\n and kinematic position-injection. Run before trusting a feed in a contested\n area. Omit coords for global.\"\"\"\n data = await _get(\n \"/api/intel/deception\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def locate_emitter(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Estimate a GPS jammer/spoofer LOCATION from the degraded-ADS-B footprint\n (severity-weighted centroid + CEP + confidence). Turns \"jamming somewhere\n here\" into \"emitter ~here ±N km\". Footprint-centroid estimate (~tens of km),\n not RF direction-finding — stated in the response. Scope with lat/lon.\"\"\"\n data = await _get(\n \"/api/intel/emitter\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def area_baseline(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Is this normal? Current vessel / dark-vessel / jamming / military counts\n z-scored against a rolling baseline, with anomalies called out (e.g. \"dark\n vessels +5σ\", \"traffic -3σ\"). Global uses the background sampler; polling an\n AOI repeatedly builds that area's baseline. Distinguishes a real shift from\n a normal day.\"\"\"\n data = await _get(\n \"/api/intel/baseline\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def whats_changed(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Standing watch — what CHANGED since the last check, not the full picture.\n\n Returns incidents that are NEW, ESCALATED, DE-ESCALATED, or RESOLVED. Global\n (no coords) reflects the background watch loop (recomputed ~every 60s); an\n AOI (lat/lon) diffs against YOUR previous whats_changed call for that area,\n so you can poll one region and be told only what moved. Use this to monitor\n instead of re-reading the whole brief each time.\n \"\"\"\n data = await _get(\n \"/api/intel/watch\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\ndef _compact_points(points: list[dict[str, Any]], max_points: int) -> list[dict[str, Any]]:\n \"\"\"Downsample one incident's observation series to its meaningful shape.\n\n Keeps the first + last point and every point whose threat ``level`` differs\n from its predecessor (a flat run of identical (level, score) is noise on a\n timeline). If transitions alone still exceed ``max_points`` it strides them\n down. Each kept point is shrunk to ``[t, level, score]`` (a list, not a\n verbose dict) so the wire form is a fraction of the size.\"\"\"\n if not points:\n return []\n keep: list[dict[str, Any]] = [points[0]]\n prev = points[0].get(\"level\")\n for p in points[1:-1]:\n if p.get(\"level\") != prev:\n keep.append(p)\n prev = p.get(\"level\")\n if len(points) > 1:\n keep.append(points[-1])\n if len(keep) > max_points:\n # Stride the kept transitions, but always retain the endpoints.\n step = (len(keep) - 1) / (max_points - 1)\n idx = sorted({0, *(round(i * step) for i in range(max_points)), len(keep) - 1})\n keep = [keep[i] for i in idx][:max_points]\n return [[p.get(\"t\"), p.get(\"level\"), p.get(\"score\")] for p in keep]\n\n\ndef _compact_history(data: dict[str, Any], limit: int, max_points: int) -> dict[str, Any]:\n \"\"\"Cap + condense the incident-history payload so the default call fits well\n under the MCP response token cap.\n\n The backend route returns EVERY incident in the window, each with its full\n per-snapshot ``points`` series and full ``narrative`` — ~89 KB at the\n default 6 h, which overflows the cap and hard-errors the tool. We keep the\n most-active incidents (the route already sorts by point-count desc), compact\n each one's series, trim the narrative, and report an honest \"showing N of M\".\n \"\"\"\n incidents = data.get(\"incidents\")\n if not isinstance(incidents, list):\n return data # error payload or unexpected shape — pass through untouched\n total = len(incidents)\n kept = incidents[:limit]\n compact: list[dict[str, Any]] = []\n for inc in kept:\n narrative = inc.get(\"narrative\")\n if isinstance(narrative, str) and len(narrative) > 240:\n narrative = narrative[:237] + \"…\"\n compact.append(\n {\n \"key\": inc.get(\"key\"),\n \"domains\": inc.get(\"domains\"),\n \"centroid\": inc.get(\"centroid\"),\n \"narrative\": narrative,\n # series is [[t, level, score], …] — compact list form\n \"series\": _compact_points(inc.get(\"points\") or [], max_points),\n }\n )\n out = {\n \"scope\": data.get(\"scope\"),\n \"window_hours\": data.get(\"window_hours\"),\n \"snapshots\": data.get(\"snapshots\"),\n \"incident_count\": total,\n \"returned\": len(compact),\n \"truncated\": total > len(compact),\n \"incidents\": compact,\n }\n if out[\"truncated\"]:\n out[\"note\"] = (\n f\"showing {len(compact)} of {total} incidents (most-active first); \"\n \"raise `limit` or scope with lat/lon/hours for more\"\n )\n return out\n\n\n@mcp.tool()\nasync def incident_history(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n hours: float = 6.0,\n limit: int = 25,\n max_incidents: int | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Timeline of how each incident built up over the recent window — per\n incident, a compact ``series`` of ``[time, threat_level, score]`` points.\n Reveals sequence (e.g. jamming first, then dark vessels, then a reported\n event). Global uses the background watch history; an AOI uses your prior\n watch calls.\n\n The full window can hold many incidents; this returns the ``limit`` most\n active ones (default 25, alias ``max_incidents``) with each series\n downsampled to its threat-level transitions, and reports\n ``incident_count`` / ``returned`` / ``truncated`` so you know if more exist.\n Raise ``limit`` or narrow with lat/lon/hours to drill in.\n \"\"\"\n cap = max_incidents if max_incidents is not None else limit\n cap = max(1, min(int(cap), 200))\n data = await _get(\n \"/api/intel/incident-history\",\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm, \"hours\": hours},\n )\n # detail='long' keeps a denser timeline (more transition points) per incident.\n max_points = 40 if normalize_detail(detail) == \"long\" else 12\n return _compact_history(data, cap, max_points=max_points)\n\n\n@mcp.tool()\nasync def vessel_dossier(mmsi: int | str, detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one vessel (MMSI): recent track, AIS gaps,\n derived speed profile (loiter / transit / loiter-then-dash), area covered,\n which live incidents it appears in, and a behaviour assessment. The track is\n the store's ~1h retention window.\"\"\"\n # MMSI is numeric; agents pass it as an int or a string — accept both.\n return shape(await _get(f\"/api/intel/dossier/vessel/{quote(str(mmsi), safe='')}\"), detail)\n\n\n@mcp.tool()\nasync def aircraft_dossier(ident: str, detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one aircraft (ICAO24 hex or callsign): recent\n track, gaps, derived speed profile, GNSS-integrity, emergency/military flags,\n and which live incidents it appears in.\"\"\"\n return shape(await _get(f\"/api/intel/dossier/aircraft/{quote(ident, safe='')}\"), detail)\n\n\n@mcp.tool()\nasync def list_focus_areas() -> dict[str, Any]:\n \"\"\"List the priority areas currently loaded PRIMARY (with fetch stats and\n whether each is served by a dedicated fetch or the degraded snapshot\n fallback).\"\"\"\n return await _get(\"/api/intel/aois\")\n\n\n@mcp.tool()\nasync def data_sources() -> dict[str, Any]:\n \"\"\"Which feeds are always-on vs key-gated, and the configured Ollama\n host/model. Use to explain coverage gaps (e.g. fires need a FIRMS key).\"\"\"\n return await _get(\"/api/intel/sources\")\n\n\n# ── 2026-07-14 data-layers wave: 12 keyless feeds ────────────────────────────\n# Each is a GeoJSON FeatureCollection whose Feature ids are `<kind>:<rawid>`, so\n# an agent can pivot from any hit to /api/entity/<id> and /api/correlations/<id>.\n\n\n@mcp.tool()\nasync def disaster_alerts(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"GDACS severity-scored global disasters (earthquake/cyclone/flood/volcano/\n drought/wildfire), each Green/Orange/Red by modelled impact.\"\"\"\n return shape(await _get(\"/api/hazards/gdacs\"), detail)\n\n\n@mcp.tool()\nasync def fire_perimeters(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Active wildfire burn-area polygons (NIFC/WFIGS) — the AREA behind FIRMS\n hotspot points, with incident name + size in acres.\"\"\"\n return shape(await _get(\"/api/hazards/fire-perimeters\"), detail)\n\n\n@mcp.tool()\nasync def tropical_cyclones(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Active tropical cyclones (NHC) with center fix, classification, intensity\n and pressure.\"\"\"\n return shape(await _get(\"/api/hazards/cyclones\"), detail)\n\n\n@mcp.tool()\nasync def volcanoes(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Smithsonian GVP Holocene volcanoes — location, type, elevation, last\n eruption year.\"\"\"\n return shape(await _get(\"/api/hazards/volcanoes\"), detail)\n\n\n@mcp.tool()\nasync def radiation(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Recent Safecast crowd radiation measurements (value + unit + location) —\n a nuclear-incident detection layer.\"\"\"\n return shape(await _get(\"/api/hazards/radiation\"), detail)\n\n\n@mcp.tool()\nasync def relief_disasters(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"ReliefWeb active humanitarian disasters, geocoded to the affected country.\"\"\"\n return shape(await _get(\"/api/hazards/reliefweb\"), detail)\n\n\n@mcp.tool()\nasync def air_quality(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Air quality (US AQI + PM2.5/PM10) sampled across major world cities —\n a proxy for industrial activity, smoke drift, and chemical release.\"\"\"\n return shape(await _get(\"/api/env/air-quality\"), detail)\n\n\n@mcp.tool()\nasync def marine_buoys(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"NDBC marine buoy observations — wave height, wind, pressure, water temp.\"\"\"\n return shape(await _get(\"/api/maritime/buoys\"), detail)\n\n\n@mcp.tool()\nasync def maritime_chokepoints(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Vessel congestion at named straits (Hormuz/Suez/Malacca/Bosphorus/…),\n derived live from the AIS union: vessel + stationary counts per chokepoint.\"\"\"\n return shape(await _get(\"/api/maritime/chokepoints\"), detail)\n\n\n@mcp.tool()\nasync def space_weather(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"SWPC space weather — solar X-ray flares, active alerts, and the auroral\n oval. The causal layer under GPS/HF degradation.\"\"\"\n return shape(await _get(\"/api/weather/swpc/space\"), detail)\n\n\n@mcp.tool()\nasync def power_plants(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"WRI global power plants (≥200 MW) — name, fuel, capacity, country. Static\n energy-infra targets to correlate strikes/outages against.\"\"\"\n return shape(await _get(\"/api/infra/powerplants\"), detail)\n\n\n@mcp.tool()\nasync def aviation_sigmet(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"AIRMET/SIGMET hazard polygons (turbulence, icing, IFR, volcanic ash,\n convective) constraining the aircraft layer.\"\"\"\n return shape(await _get(\"/api/aviation/sigmet\"), detail)\n\n\n# ── 2026-07-21 context+markets wave ──────────────────────────────────────────\n\n\n@mcp.tool()\nasync def travel_advisories(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Official country-level travel advisories pooled from US State, UK FCDO,\n and Australia Smartraveller — each country's highest reported level.\"\"\"\n return shape(await _get(\"/api/advisories\"), detail)\n\n\n@mcp.tool()\nasync def displacement(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"UN OCHA HAPI country-level IDP and refugee figures, latest reporting\n period per country.\"\"\"\n return shape(await _get(\"/api/displacement\"), detail)\n\n\n@mcp.tool()\nasync def nas_status(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"FAA National Airspace System ground stops, ground delays, arrival/\n departure delays, and airport closures, as points at the affected airport.\"\"\"\n return shape(await _get(\"/api/airspace/nas-status\"), detail)\n\n\n@mcp.tool()\nasync def climate_anomalies(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Temperature/precipitation anomalies (vs. a 5-year same-window baseline)\n over conflict-dense country centroids.\"\"\"\n return shape(await _get(\"/api/climate/anomalies\"), detail)\n\n\n@mcp.tool()\nasync def markets_snapshot(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Batched keyless market quote snapshot: indices, commodities, fx, crypto.\"\"\"\n return shape(await _get(\"/api/markets/snapshot\"), detail)\n\n\n@mcp.tool()\nasync def market_stress(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Composite market-stress score from equity drawdown, gold/oil moves, USD\n flight-to-safety, and BTC drawdown.\"\"\"\n return shape(await _get(\"/api/markets/stress\"), detail)\n\n\n# ── 2026-07-24 REST-parity wave: eq geo-filter, history, watch rules ────────\n# These three capabilities (geo-filtered /api/eq, identity-scoped\n# /api/history/track, per-identity watch-rule CRUD) shipped REST-only in the\n# prior wave with no MCP wrapper — an MCP-restricted agent had no way to reach\n# them. Thin wrappers only; the routes already do the validation.\n\n\n@mcp.tool()\nasync def quakes_near(\n lat: float, lon: float, radius_km: float, range: str = \"day\", detail: str = \"short\"\n) -> dict[str, Any]:\n \"\"\"Earthquakes (USGS) within radius_km of a point (GET /api/eq).\n\n lat, lon, and radius_km must ALL be given together — the route 422s on a\n partial set rather than silently falling back to the unfiltered global\n feed (agent-safe: pass all three or none). range is 'hour'|'day'\n (default)|'week'|'month'. For a severity-scored global disaster view\n instead, use disaster_alerts().\"\"\"\n data = await _get(\n \"/api/eq\", {\"lat\": lat, \"lon\": lon, \"radius_km\": radius_km, \"range\": range}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def track_history(\n id: str,\n from_ts: float | None = None,\n to_ts: float | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Historical position track for ONE entity (GET /api/history/track).\n\n id is 'aircraft:<icao24hex>' / 'vessel:<mmsi>', or a bare id whose shape is\n unambiguous (6-char ICAO24 hex or 9-digit MMSI) — the route infers the\n kind from it. from_ts/to_ts are unix seconds (default: the last hour). A\n id the route can't resolve returns its 422 message as-is (e.g. ambiguous\n shape — pass a 'kind:id' prefix).\"\"\"\n data = await _get(\"/api/history/track\", {\"id\": id, \"from_ts\": from_ts, \"to_ts\": to_ts})\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def create_watch_rule(\n label: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float | None = None,\n kinds: list[str] | None = None,\n min_severity: int = 1,\n channel: str = \"inapp\",\n sink_url: str | None = None,\n enabled: bool = True,\n icao24: str | None = None,\n mmsi: str | None = None,\n callsign: str | None = None,\n) -> dict[str, Any]:\n \"\"\"Create a standing watch rule (POST /api/alerts/rules).\n\n A rule needs a gate: either an identity pin (icao24/mmsi/callsign — follows\n that one entity globally, no AOI required) or a complete AOI (lat, lon;\n radius_nm defaults to 50 nm if omitted). kinds filters which signal kinds\n fire it — jamming/dark_vessel/military_air/military_vessel/incident/quake/\n fire/ais_gap/rendezvous/loiter (omit for all). channel is\n 'inapp'|'discord'|'webhook' ('discord'/'webhook' require sink_url). The\n route validates and 400/422s on a bad gate, channel, sink_url, or kind —\n this wrapper does not re-validate, it passes the error through.\"\"\"\n body = {\n \"label\": label,\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"kinds\": kinds,\n \"min_severity\": min_severity,\n \"channel\": channel,\n \"sink_url\": sink_url,\n \"enabled\": enabled,\n \"icao24\": icao24,\n \"mmsi\": mmsi,\n \"callsign\": callsign,\n }\n return await _post(\"/api/alerts/rules\", body)\n\n\n@mcp.tool()\nasync def list_watch_rules(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"List your standing watch rules (GET /api/alerts/rules).\"\"\"\n return shape(await _get(\"/api/alerts/rules\"), detail)\n\n\n@mcp.tool()\nasync def delete_watch_rule(rule_id: str) -> dict[str, Any]:\n \"\"\"Delete a standing watch rule by id (DELETE /api/alerts/rules/{rule_id}).\"\"\"\n return await _delete(f\"/api/alerts/rules/{quote(rule_id, safe='')}\")\n\n\n# ── deep analysis (DeepSeek reasoner, Ollama fallback) ────────────────────────\n\n_SYS_PROMPT = (\n \"You are a GEOINT analyst working a live open-source intelligence console. \"\n \"You are given distilled JSON from live ADS-B (aircraft), AIS (vessels), a \"\n \"GPS-jamming layer (ADS-B NACp/NIC, GPSJam method), and a cross-domain \"\n \"fusion engine whose `incident_brief` already chains co-located signals into \"\n \"ranked, cited INCIDENTS — lead your analysis from those incidents, then use \"\n \"the raw situation/area data to corroborate or challenge them. \"\n \"Reason ONLY over the provided JSON. Cite concrete numbers and IDs. Flag: \"\n \"GPS jamming/spoofing footprints, emergency squawks (7500 hijack / 7600 \"\n \"radio-fail / 7700 general), dark vessels, abnormal traffic density, and \"\n \"military activity. Be concise and structured: (1) ASSESSMENT (2 lines), \"\n \"(2) KEY FINDINGS (bullets with numbers), (3) RECOMMENDED FOLLOW-UP \"\n \"queries. If the data is thin, say so plainly — do not invent.\"\n)\n\n\n@mcp.tool()\nasync def deep_analyze(\n question: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 250.0,\n model: str | None = None,\n tier: str = \"reason\",\n) -> dict[str, Any]:\n \"\"\"Have a reasoning model reason deeply over the live intel.\n\n Gathers the relevant data (global situation, plus a focused area bundle +\n jamming + anomalies when lat/lon given) and feeds it to a real reasoning\n model — DeepSeek (``deepseek-reasoner``) when configured, else a local\n Ollama model. Heavy reasoning runs off-context; only the conclusion enters\n the agent's context. Falls back to returning the raw structured data\n (analysis=null) if no LLM backend is reachable.\n\n Args:\n question: what to analyse (e.g. \"Is there coordinated GPS jamming near\n the Baltic, and which aircraft are affected?\").\n lat, lon, radius_nm: optional area to focus on (loaded PRIMARY).\n model: optional Ollama model override used only on the Ollama fallback.\n tier: ``reason`` (deepseek-reasoner, default — judgement) or ``fast``\n (deepseek-chat — quicker, shallower).\n \"\"\"\n import json as _json\n\n from app import llm # noqa: PLC0415\n\n # 1) gather context. Pull detail='long' internally — the reasoner runs\n # off-context with its own large window, so it should judge the FULL fused\n # picture, not the token-frugal digest the agent-facing default returns.\n context: dict[str, Any] = {\n \"question\": question,\n \"situation\": await get_situation(detail=\"long\"),\n }\n context[\"incident_brief\"] = await intel_brief(\n lat=lat, lon=lon, radius_nm=radius_nm,\n window_hours=12.0 if lat is None else 6.0,\n detail=\"long\",\n )\n if lat is not None and lon is not None:\n context[\"focus_area\"] = await focus_area(lat, lon, radius_nm, detail=\"long\")\n\n # 2) reason off-context (DeepSeek → Ollama → raw data). Cap the wait so a\n # slow upstream can't hang the tool for the 180 s default — the fast tier\n # should answer well under 60 s, the deeper reason tier under 90 s.\n timeout_s = 60.0 if (tier or \"\").lower() == \"fast\" else 90.0\n res = await llm.complete(\n _SYS_PROMPT,\n f\"QUESTION: {question}\\n\\nLIVE INTEL JSON:\\n\"\n + _json.dumps(context, separators=(\",\", \":\"))[:60000],\n tier=tier,\n temperature=0.2,\n max_tokens=2048,\n timeout_s=timeout_s,\n ollama_model=model or \"\",\n )\n if not res.ok:\n return {\n \"analysis\": None,\n \"model\": res.model,\n \"backend\": res.backend,\n \"note\": f\"No LLM backend reachable ({res.error}). Returning structured \"\n \"intel for you to analyse directly. Configure DEEPSEEK_API_KEY / the \"\n \"opencode DeepSeek key, or `ollama pull qwen2.5:3b`.\",\n \"data\": context,\n }\n\n return {\n \"analysis\": res.text,\n \"model\": res.model,\n \"backend\": res.backend,\n \"focused_on\": (\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm} if lat is not None else \"global\"\n ),\n \"data_summary\": {\n \"aircraft_total\": context[\"situation\"].get(\"aircraft\", {}).get(\"total\"),\n \"jamming_high\": context[\"situation\"].get(\"gps_jamming\", {}).get(\"high\"),\n },\n }\n\n\n# ── news intelligence (debias / fact-check) ──────────────────────────────────\n\n\n@mcp.tool()\nasync def news_analysis() -> dict[str, Any]:\n \"\"\"Cross-source, debiased world-news intelligence.\n\n Scrapes ~12 outlets (BBC, Reuters, AP, Al Jazeera, Guardian, CNN, Fox, …),\n then a reasoning model strips bias/propaganda and separates VERIFIED FACTS\n (corroborated by ≥2 independent outlets) from ATTRIBUTED CLAIMS and\n rhetoric. A leader promising \"the war will end soon\" is flagged as rhetoric,\n never reported as fact. Returns events with neutral_summary, verified_facts,\n attributed_claims, bias_flags, propaganda_techniques, rhetoric_flags and a\n confidence. May take ~30s when the cache is cold (it reasons over the feed).\n \"\"\"\n return await _get(\"/api/news/analysis\")\n\n\n@mcp.tool()\nasync def fact_check(claim: str) -> dict[str, Any]:\n \"\"\"Adjudicate one free-text claim against current world-news headlines.\n\n Returns ``{verdict: true|false|misleading|unverified, reasoning,\n supporting_sources, confidence}``. Use it to sanity-check a statement before\n treating it as fact.\n \"\"\"\n return await _get(\"/api/news/factcheck\", {\"claim\": claim})\n\n\n@mcp.tool()\nasync def news_brief(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Latest assembled morning brief: top story per category plus a short\n synthesis paragraph tying the leads together. Built on a ~20h cadence by\n the background news refresher, not on-demand — use ``news_analysis`` or\n the edition route for the freshest per-story detail.\n \"\"\"\n return shape(await _get(\"/api/news/brief\"), detail)\n\n\n@mcp.tool()\nasync def aoi_imagery(\n before: str,\n after: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_km: float = 5.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n window_days: int = 30,\n) -> dict[str, Any]:\n \"\"\"Building imagery for a location at two dates — set time + place directly.\n\n Give a location (lat/lon + radius_km, OR an explicit min/max bbox) and a\n before + after date (YYYY-MM-DD). Returns what imagery is available for each\n date WITHOUT downloading: Maxar Open Data VHR (~0.3-0.5 m, event-gated — only\n where a disaster/conflict event covers the AOI) and Sentinel (10 m, global,\n any date). `best_source` says which to use. Reconstruction downloads the\n scenes to a temp dir on demand and discards them — nothing is stored.\n\n Args:\n before, after: dates as YYYY-MM-DD.\n lat, lon, radius_km: centre + radius (km) of the AOI.\n min_lon/min_lat/max_lon/max_lat: explicit bbox (overrides lat/lon).\n window_days: ± days around each date to search Maxar (events are sparse).\n \"\"\"\n params: dict[str, Any] = {\n \"before\": before,\n \"after\": after,\n \"radius_km\": radius_km,\n \"window_days\": window_days,\n }\n if None not in (min_lon, min_lat, max_lon, max_lat):\n params.update(min_lon=min_lon, min_lat=min_lat, max_lon=max_lon, max_lat=max_lat)\n else:\n params.update(lat=lat, lon=lon)\n return await _get(\"/api/imagery/aoi\", params)\n\n\n# ── entrypoint ─────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def answer(question_id: str = \"\") -> dict[str, Any]:\n \"\"\"Named questions answered with a verdict, its rule, and the evidence age.\n\n The one call that returns a CONCLUSION rather than data to interpret. Each\n answer carries `verdict` (open / reduced / closed / unknown), `threshold`\n (the rule that produced it, in words), `data_lag_s` (how old the newest\n evidence is), and `stale`.\n\n Two contracts worth relaying to a user verbatim rather than paraphrasing:\n `unknown` is a real answer and its `detail` says why we cannot answer yet,\n and `data_lag_s` of null means NO EVIDENCE WAS OBSERVED - never \"fresh\".\n Report `stale` alongside any verdict you quote.\n\n Omit question_id to list every registered question.\"\"\"\n if question_id:\n return await _get(f\"/api/answers/{question_id}\")\n return await _get(\"/api/answers\")\n\n\n@mcp.tool()\nasync def contact_provenance() -> dict[str, Any]:\n \"\"\"Which sources are seeing the sky right now, and how much they agree.\n\n Per tier: how many contacts it reported and how many ONLY it reported, plus\n the share of contacts with two or more independent observers and the\n confidence rule in force.\n\n The exclusive count is the column that matters. Crowd-sourced ADS-B accepts\n whatever is uploaded to it, so a contact only one tier can see is not\n corroborated, and a tier contributing thousands of exclusive contacts is\n either unique coverage or an unverifiable claim. Say which when you report\n it, and never present an uncorroborated contact as confirmed.\"\"\"\n return await _get(\"/api/status/provenance\")\n\n\n@mcp.tool()\nasync def history_diff(\n lamin: float,\n lomin: float,\n lamax: float,\n lomax: float,\n at_a: float,\n at_b: float | None = None,\n window_sec: int = 600,\n kind: str | None = None,\n) -> dict[str, Any]:\n \"\"\"What changed inside a box between two moments: arrived, departed, stayed.\n\n Answers from OWNED history, so it works for any past window still retained\n rather than only the present instant. `at_a` and `at_b` are epoch seconds\n (at_b defaults to now); each is widened by `window_sec` because a stored\n position is a sample rather than a continuous truth.\n\n `recorded: false` means nothing was in the archive for that box and window -\n which is NOT the same as \"nothing changed\". Do not report an empty archive\n as a quiet period.\"\"\"\n params: dict[str, Any] = {\n \"lamin\": lamin,\n \"lomin\": lomin,\n \"lamax\": lamax,\n \"lomax\": lomax,\n \"at_a\": at_a,\n \"window_sec\": window_sec,\n }\n if at_b is not None:\n params[\"at_b\"] = at_b\n if kind:\n params[\"kind\"] = kind\n return await _get(\"/api/history/diff\", params)\n\n\n@mcp.tool()\nasync def system_doctor() -> dict[str, Any]:\n \"\"\"What is configured, what is missing, and the exact line that fixes it.\n\n Use this when a layer looks empty before concluding the world is quiet: a\n blank layer is far more often an unconfigured optional source than an\n absence of activity. Every capability listed is optional - the console runs\n keyless - so a long `problems` list is not a fault list. Never report a\n missing optional key as a defect.\"\"\"\n return await _get(\"/api/status/doctor\")\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=\"OSINT GEOINT MCP server\")\n parser.add_argument(\"--http\", action=\"store_true\", help=\"serve over streamable-HTTP\")\n parser.add_argument(\"--port\", type=int, default=8765, help=\"HTTP port (with --http)\")\n parser.add_argument(\"--host\", default=\"127.0.0.1\", help=\"HTTP host (with --http)\")\n parser.add_argument(\n \"--list-tools\", action=\"store_true\", help=\"print registered tools and exit\"\n )\n args = parser.parse_args()\n\n if args.list_tools:\n tools = asyncio.run(mcp.list_tools())\n print(f\"osint-geoint MCP — {len(tools)} tools:\")\n for t in tools:\n summary = (t.description or \"\").split(\"\\n\", 1)[0]\n print(f\" • {t.name}: {summary}\")\n return\n\n if args.http:\n mcp.settings.host = args.host\n mcp.settings.port = args.port\n mcp.run(transport=\"streamable-http\")\n else:\n mcp.run() # stdio\n\n\nif __name__ == \"__main__\":\n main()\n"}, "af3ca08f266d9e8cbb4c1d06d90f9b4b9c20c284": {"t": 1785765044.1623867, "n": 50232, "h": "df3f7f3b1383e4ea", "c": "\"\"\"OSINT GEOINT — Model Context Protocol server.\n\nExposes the live OSINT console as tools an AI agent can call. Every tool is a\nthin async wrapper over the ``/api/intel/*`` HTTP surface (``app.routes.intel``)\nso the agent shares the SAME warm in-process snapshot, AOI priority loader, and\nfusion engine the globe uses — no second upstream fan-out, no rate-limit blowup.\n\nDesign principles (the MCP brief):\n- **Context-safe.** Tools return distilled JSON (counts, grids, ≤50-item\n samples), never raw feature dumps. An agent can sweep the whole planet for a\n few hundred tokens.\n- **Area-primary.** ``focus_area`` marks a region PRIMARY: a dedicated fresh\n fetch + ongoing priority refresh, independent of global rate limits. Other\n regions keep streaming from the global snapshot.\n- **Think deeper, off-context.** ``deep_analyze`` pulls the relevant intel JSON\n and hands it to a real reasoning model — DeepSeek (``deepseek-reasoner``) when\n configured, else a local Ollama model — so heavy analysis happens off the\n agent's context and only the conclusion returns.\n\nRun:\n python -m app.mcp_server # stdio (Claude Code / Desktop / SDK)\n python -m app.mcp_server --http --port 8765 # streamable-HTTP\n python -m app.mcp_server --list-tools # introspect, for CI / verification\n\nConfig (env or apps/api/.env): API_BASE, API_KEY, DEEPSEEK_API_KEY (or the\nopencode DeepSeek key), OLLAMA_HOST, OLLAMA_MODEL.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport atexit\nimport os\nimport subprocess\nimport sys\nimport time\nfrom typing import Any\nfrom urllib.parse import quote, urlparse\n\nimport httpx\nfrom mcp.server.fastmcp import FastMCP\nfrom mcp.server.streamable_http_manager import StreamableHTTPSessionManager\nfrom mcp.server.transport_security import TransportSecuritySettings\nfrom starlette.routing import Route\n\nfrom app.config import get_settings\nfrom app.intel.shape import normalize_detail, shape\n\nmcp = FastMCP(\n \"osint-geoint\",\n instructions=(\n \"Live geospatial intelligence over open ADS-B (aircraft), AIS (vessels), \"\n \"GPS-jamming (ADS-B NACp/NIC), Sentinel-1 SAR dark vessels, geocoded \"\n \"events, and a cross-domain fusion engine. Workflow: get_situation() to \"\n \"orient -> intel_brief() for ranked, cited cross-domain INCIDENTS (the \"\n \"fused picture, global or scoped) -> focus_area(lat,lon,radius_nm) to \"\n \"load an incident's region PRIMARY -> query_vessels / gps_jamming / \"\n \"query_aircraft to drill into its evidence -> deep_analyze() to have a \"\n \"reasoning model judge it. intel_brief is the headline tool: it chains \"\n \"signals into incidents so you don't correlate raw layers by hand.\\n\\n\"\n \"CONTEXT BUDGET: most tools take detail='short'|'long'. 'short' (the \"\n \"default) is a token-frugal digest — headline counts plus the top few \"\n \"items of each list, with `*_total` giving the true size — ideal for \"\n \"orientation and broad sweeps. Switch to detail='long' only once you've \"\n \"picked an incident/area worth the full, comprehensive bundle.\"\n ),\n)\n\n\n# ── hosted mount (streamable-HTTP at /mcp of the FastAPI app) ──────────────────\n# The agent-facing endpoint. Mounting the MCP into the SAME uvicorn process the\n# globe runs lets every tool share the warm in-process snapshot + fusion engine\n# (the tools still self-call /api/intel/* over localhost, authenticated by the\n# static API_KEY). The Velocity gateway Worker proxies https://<host>/mcp here,\n# forwarding the caller's Supabase token; the backend's ApiKeyMiddleware gates it\n# like any other non-public route, so no MCP-specific auth is needed.\n\n\nclass _MCPASGIApp:\n \"\"\"Raw-ASGI adapter for the streamable-HTTP session manager.\n\n A plain function endpoint would be wrapped by Starlette as a\n request/response handler; a class instance is left as a raw ASGI app, which\n is what the transport needs (it negotiates SSE itself).\n \"\"\"\n\n def __init__(self, manager: StreamableHTTPSessionManager) -> None:\n self._manager = manager\n\n async def __call__(self, scope: Any, receive: Any, send: Any) -> None:\n await self._manager.handle_request(scope, receive, send)\n\n\ndef build_mcp_mount() -> tuple[list[Route], StreamableHTTPSessionManager]:\n \"\"\"Return (routes, session_manager) to serve this server at ``/mcp``.\n\n A FRESH manager per call — ``StreamableHTTPSessionManager.run()`` is one-shot\n (it raises if entered twice), so every FastAPI app instance, including each\n test app, needs its own. Usage::\n\n routes, mgr = build_mcp_mount()\n app.router.routes.extend(routes) # in the app factory\n async with mgr.run(): ... yield # in the app lifespan\n\n Served as exact ``Route``s (``/mcp`` and ``/mcp/``), NOT a ``Mount`` — a\n mount 307-redirects ``/mcp`` → ``/mcp/`` with a Location built from the\n request host, which through the gateway Worker would point a client at the\n bare backend origin. DNS-rebinding protection is disabled: the endpoint is\n reached server-to-server through the Worker (the Host header is the\n deployment origin, not a browser tab), and the Supabase-token check in\n ``app.auth.ApiKeyMiddleware`` is the real gate.\n \"\"\"\n manager = StreamableHTTPSessionManager(\n app=mcp._mcp_server,\n json_response=False,\n stateless=False,\n security_settings=TransportSecuritySettings(\n enable_dns_rebinding_protection=False\n ),\n )\n endpoint = _MCPASGIApp(manager)\n routes = [Route(\"/mcp\", endpoint), Route(\"/mcp/\", endpoint)]\n return routes, manager\n\n\n# ── backend HTTP plumbing ─────────────────────────────────────────────────────\n\n\ndef _api_base() -> str:\n return (os.environ.get(\"API_BASE\") or get_settings().api_base).rstrip(\"/\")\n\n\n# Cache one minted internal token per secret until ~60 s before it expires, so a\n# burst of tool calls re-signs at most once a minute instead of per request.\n_INTERNAL_JWT_TTL_S = 600 # 10 min token lifetime\n_minted_jwt: tuple[str, float] | None = None # (token, wall-clock expiry)\n\n\ndef _mint_internal_jwt(secret: str) -> str | None:\n \"\"\"Mint a short-lived HS256 token the backend's ``app.auth`` accepts.\n\n In prod the gate is Supabase-JWT-only (no static ``API_KEY``); a server-to-\n server hop carries no browser session, so we sign our own. ``_verify_hs256``\n requires an HS256 signature over the JWT secret, ``role==\"authenticated\"``,\n and an unexpired ``exp`` — we also set the standard Supabase ``aud``/``sub``/\n ``iat`` so the token is well-formed. Cached until ~60 s before ``exp``.\n \"\"\"\n global _minted_jwt\n now = time.time()\n if _minted_jwt is not None:\n token, expiry = _minted_jwt\n if expiry - 60 > now:\n return token\n try:\n import jwt # noqa: PLC0415 — pyjwt, the lib app.auth's HS256 check mirrors\n except Exception: # noqa: BLE001 — pyjwt missing → no Authorization header\n return None\n exp = int(now) + _INTERNAL_JWT_TTL_S\n claims = {\n \"role\": \"authenticated\", # the claim app.auth._verify_hs256 demands\n \"aud\": \"authenticated\",\n \"sub\": \"osint-mcp-internal\",\n \"iat\": int(now),\n \"exp\": exp,\n }\n try:\n token = jwt.encode(claims, secret, algorithm=\"HS256\")\n except Exception: # noqa: BLE001 — encode failure → degrade to no auth header\n return None\n _minted_jwt = (token, float(exp))\n return token\n\n\ndef _headers() -> dict[str, str]:\n s = get_settings()\n key = os.environ.get(\"API_KEY\") or s.api_key\n if key:\n return {\"X-API-Key\": key}\n # No static key (prod): if a Supabase JWT secret is configured, mint an\n # internal token so the self-hop to /api/intel/* authenticates. Otherwise\n # (fully open local box) send nothing, as before.\n secret = s.supabase_jwt_secret\n if secret:\n token = _mint_internal_jwt(secret)\n if token:\n return {\"Authorization\": f\"Bearer {token}\"}\n return {}\n\n\n# ── backend auto-start ────────────────────────────────────────────────────────\n# The MCP server is a thin client over the OSINT backend. If that backend is\n# not running, every tool errors and a driving agent can spin in a retry loop.\n# So on the first tool call we make sure a backend exists: reuse one already\n# listening, else spawn uvicorn ourselves (localhost only) and wait — bounded —\n# for it to come up. Set OSINT_MCP_NO_AUTOSTART=1 to disable.\n\n_BACKEND_LOCK = asyncio.Lock()\n_BACKEND_READY = False\n_BACKEND_PROC: subprocess.Popen[bytes] | None = None\n_AUTOSTART_WAIT_S = 60\n\n\ndef _is_localhost(url: str) -> bool:\n host = (urlparse(url).hostname or \"\").lower()\n return host in {\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"::1\"}\n\n\ndef _api_port() -> int:\n return urlparse(_api_base()).port or 8000\n\n\ndef _apps_api_dir() -> str:\n import app # noqa: PLC0415\n\n return os.path.dirname(os.path.dirname(os.path.abspath(app.__file__)))\n\n\nasync def _backend_healthy(timeout_s: float = 2.0) -> bool:\n try:\n async with httpx.AsyncClient(timeout=timeout_s) as c:\n r = await c.get(_api_base() + \"/api/intel/sources\", headers=_headers())\n return r.status_code == 200\n except Exception:\n return False\n\n\ndef _spawn_uvicorn(cmd: list[str]) -> subprocess.Popen[bytes]:\n \"\"\"Sync spawn (Popen returns immediately; isolated from the async path).\"\"\"\n return subprocess.Popen(\n cmd,\n cwd=_apps_api_dir(),\n stdout=subprocess.DEVNULL,\n stderr=subprocess.DEVNULL,\n env=dict(os.environ),\n )\n\n\ndef _terminate_backend() -> None:\n proc = _BACKEND_PROC\n if proc is not None and proc.poll() is None:\n proc.terminate()\n\n\nasync def _prewarm() -> None:\n \"\"\"Kick the global ADS-B snapshot so the first get_situation isn't slow.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=120.0) as c:\n await c.get(_api_base() + \"/api/adsb/global\", headers=_headers())\n except Exception:\n pass\n\n\nasync def _ensure_backend() -> None:\n \"\"\"Idempotent: guarantee a reachable backend before a tool runs.\"\"\"\n global _BACKEND_READY, _BACKEND_PROC\n if _BACKEND_READY:\n return\n async with _BACKEND_LOCK:\n if _BACKEND_READY:\n return\n if await _backend_healthy():\n _BACKEND_READY = True\n return\n # Only auto-start a LOCAL backend, and only if not opted out.\n if os.environ.get(\"OSINT_MCP_NO_AUTOSTART\") or not _is_localhost(_api_base()):\n return\n # Spawn at most ONCE per process. If a prior attempt didn't come up,\n # don't re-spawn (avoids stacking uvicorns / re-waiting every call) —\n # just let later calls re-check health in case it's still warming.\n if _BACKEND_PROC is not None:\n return\n cmd = [\n sys.executable, \"-m\", \"uvicorn\", \"app.main:app\",\n \"--host\", \"127.0.0.1\", \"--port\", str(_api_port()), \"--log-level\", \"warning\",\n ]\n try:\n _BACKEND_PROC = _spawn_uvicorn(cmd)\n except Exception:\n return # can't spawn (e.g. uvicorn missing) — tools report cleanly\n atexit.register(_terminate_backend)\n for _ in range(_AUTOSTART_WAIT_S):\n await asyncio.sleep(1.0)\n if await _backend_healthy():\n _BACKEND_READY = True\n asyncio.create_task(_prewarm()) # warm snapshot off the hot path\n return\n # Did not come up in time; leave _BACKEND_READY False so the next tool\n # call retries once more rather than looping on a dead spawn.\n\n\nasync def _get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"GET an intel endpoint. Returns parsed JSON or a structured error dict —\n never raises, so a tool call always yields something the agent can read.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in (params or {}).items() if v is not None}\n # Generous ceiling: the backend's FIRST global call cold-starts the ADS-B\n # snapshot (firehose fetch + backoff) and can take up to ~75s. After that\n # every call is instant. A short timeout here would mis-report a healthy\n # backend as unreachable on the agent's very first query.\n try:\n async with httpx.AsyncClient(timeout=90.0) as client:\n r = await client.get(url, params=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\n \"error\": \"backend_unreachable\",\n \"detail\": str(exc),\n \"url\": url,\n \"hint\": \"Backend auto-start did not come up in time. It may still be \"\n \"warming — retry once. Or start it manually: uv run --project \"\n f\"{_apps_api_dir()} uvicorn app.main:app --port {_api_port()}\",\n }\n if r.status_code != 200:\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\nasync def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:\n \"\"\"POST an intel endpoint. Same non-raising, structured-error contract as\n ``_get`` (None fields dropped so the route's own Pydantic defaults apply,\n a non-2xx status becomes a structured error dict, never HTML/an exception).\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in body.items() if v is not None}\n try:\n async with httpx.AsyncClient(timeout=30.0) as client:\n r = await client.post(url, json=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\"error\": \"backend_unreachable\", \"detail\": str(exc), \"url\": url}\n if r.status_code not in (200, 201):\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\nasync def _delete(path: str) -> dict[str, Any]:\n \"\"\"DELETE an intel endpoint. Same non-raising, structured-error contract\n as ``_get``/``_post``. A 204 (no body) is the success case.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n try:\n async with httpx.AsyncClient(timeout=30.0) as client:\n r = await client.delete(url, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\"error\": \"backend_unreachable\", \"detail\": str(exc), \"url\": url}\n if r.status_code not in (200, 204):\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n return {\"ok\": True}\n\n\n# ── tools ─────────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def get_situation(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Global situational snapshot — the cheap first call to orient.\n\n Returns total aircraft (airborne/ground, by category), GNSS-degraded count,\n active emergency squawks, the worst GPS-jamming cells worldwide, tracked\n vessel counts by category, and recent fusion-alert counts. A few hundred\n tokens describing the whole planet.\n\n detail='short' (default) trims the jamming/vessel/alert samples to their\n top few; detail='long' returns the full situation bundle.\"\"\"\n return shape(await _get(\"/api/intel/situation\"), detail)\n\n\n@mcp.tool()\nasync def focus_area(\n lat: float,\n lon: float,\n radius_nm: float = 200.0,\n label: str | None = None,\n cell_deg: float = 1.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Load a region PRIMARY and return a full intel bundle for it in one call.\n\n This is the headline tool. It triggers a DEDICATED, always-fresh fetch for\n just this area (bypassing global rate limits) and registers it for ongoing\n priority refresh. The bundle contains: fresh aircraft summary + sample,\n aircraft-density grid, GPS-jamming assessment, vessels, and fused anomalies\n (emergencies, jamming hotspots, dark vessels, alerts) with a threat level.\n\n Args:\n lat, lon: centre of the area of interest.\n radius_nm: radius in nautical miles (1–250; default 200).\n label: optional human name for the AOI (e.g. \"Kaliningrad\").\n cell_deg: density grid cell size in degrees (0.1–10; default 1.0).\n \"\"\"\n data = await _get(\n \"/api/intel/area\",\n {\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"label\": label,\n \"primary\": True,\n \"cell_deg\": cell_deg,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def aircraft_density(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n cell_deg: float = 1.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Aircraft density over an area as a grid of cells (count, by category,\n GNSS-degraded per cell) plus totals, peak cell, and in-area vessel count.\n\n Give either a centre (lat, lon [, radius_nm]) or an explicit bbox\n (min_lon, min_lat, max_lon, max_lat). cell_deg sets grid resolution.\"\"\"\n data = await _get(\n \"/api/intel/density\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat, \"cell_deg\": cell_deg,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def gps_jamming(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"GPS/GNSS jamming assessment (GPSJam method: ADS-B NACp<8 / NIC<7 binned\n into 1° cells). Returns flagged cells ranked by severity, counts of\n high/medium cells, and a sample of affected aircraft. Omit all coordinates\n for a global view; otherwise pass a centre or a bbox to scope it.\"\"\"\n data = await _get(\n \"/api/intel/jamming\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def query_aircraft(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n category: str | None = None,\n squawk: str | None = None,\n callsign_contains: str | None = None,\n min_alt_m: float | None = None,\n max_alt_m: float | None = None,\n emergency: bool | None = None,\n gnss_degraded: bool | None = None,\n on_ground: bool | None = None,\n limit: int = 50,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Filtered aircraft query against the live snapshot. Returns matched_total\n + a capped list of compact records.\n\n category ∈ airliner|private|helicopter|glider|military|emergency.\n Combine with area (centre or bbox), squawk, callsign_contains, altitude\n band (metres), emergency / gnss_degraded / on_ground booleans. limit ≤200.\"\"\"\n data = await _get(\n \"/api/intel/aircraft\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"category\": category, \"squawk\": squawk,\n \"callsign_contains\": callsign_contains,\n \"min_alt_m\": min_alt_m, \"max_alt_m\": max_alt_m,\n \"emergency\": emergency, \"gnss_degraded\": gnss_degraded,\n \"on_ground\": on_ground, \"limit\": limit,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def lookup_aircraft(ident: str) -> dict[str, Any]:\n \"\"\"Look up one aircraft by ICAO24 hex (exact) or callsign (substring).\n Returns its current state, registration, an integrity/threat assessment,\n and the latest server-held fix.\"\"\"\n # quote(): ident is agent-supplied free text going into a URL path —\n # escape it so \"x/../y\" style values cannot re-route the request.\n return await _get(f\"/api/intel/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def query_vessels(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n dark_only: bool = False,\n limit: int = 50,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Vessels (AIS) in an area, classified (cargo/tanker/fishing/passenger/\n military/sailing/pleasure/tug). dark_only=True returns only dark-vessel\n candidates (moving with no static identity). Scoped by centre or bbox.\"\"\"\n data = await _get(\n \"/api/intel/vessels\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"dark_only\": dark_only, \"limit\": limit,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def anomalies(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Fused anomaly report for an area (or global if no coords): emergency\n aircraft, GPS-jamming hotspots, dark-vessel candidates, recent fusion\n alerts, plus a triage threat_level (low|elevated|high) and score.\"\"\"\n data = await _get(\n \"/api/intel/anomalies\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def intel_brief(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n link_km: float = 50.0,\n window_hours: float = 6.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Cross-domain INCIDENT brief — the headline analytic tool.\n\n Fuses the raw layers into ranked, cited INCIDENTS instead of returning\n signals you have to correlate yourself. An incident is a CONVERGENCE: signals\n within ``link_km`` across >=2 domains (GPS-jamming, dark/AIS-off vessels,\n military air, AIS gaps, emergencies, geocoded events, quakes) — or a single\n critical/high signal. Each incident carries a rule-based narrative, a\n threat_level, the contributing evidence (with IDs), and recommended follow-up\n queries. Omit coordinates for a global brief; pass a centre+radius or bbox to\n scope it. Start here, then drill into an incident's centroid with\n query_vessels / gps_jamming / deep_analyze.\n \"\"\"\n data = await _get(\n \"/api/intel/brief\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"link_km\": link_km, \"window_hours\": window_hours,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def detect_deception(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Denial & deception — \"am I being fed?\". Flags MANIPULATED tracks distinct\n from jamming: AIS duplicate-MMSI (one identity, two hulls) and impossible\n teleports; ADS-B GPS spoofing (many aircraft snapped to one false position)\n and kinematic position-injection. Run before trusting a feed in a contested\n area. Omit coords for global.\"\"\"\n data = await _get(\n \"/api/intel/deception\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def locate_emitter(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Estimate a GPS jammer/spoofer LOCATION from the degraded-ADS-B footprint\n (severity-weighted centroid + CEP + confidence). Turns \"jamming somewhere\n here\" into \"emitter ~here ±N km\". Footprint-centroid estimate (~tens of km),\n not RF direction-finding — stated in the response. Scope with lat/lon.\"\"\"\n data = await _get(\n \"/api/intel/emitter\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def area_baseline(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Is this normal? Current vessel / dark-vessel / jamming / military counts\n z-scored against a rolling baseline, with anomalies called out (e.g. \"dark\n vessels +5σ\", \"traffic -3σ\"). Global uses the background sampler; polling an\n AOI repeatedly builds that area's baseline. Distinguishes a real shift from\n a normal day.\"\"\"\n data = await _get(\n \"/api/intel/baseline\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def whats_changed(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Standing watch — what CHANGED since the last check, not the full picture.\n\n Returns incidents that are NEW, ESCALATED, DE-ESCALATED, or RESOLVED. Global\n (no coords) reflects the background watch loop (recomputed ~every 60s); an\n AOI (lat/lon) diffs against YOUR previous whats_changed call for that area,\n so you can poll one region and be told only what moved. Use this to monitor\n instead of re-reading the whole brief each time.\n \"\"\"\n data = await _get(\n \"/api/intel/watch\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\ndef _compact_points(points: list[dict[str, Any]], max_points: int) -> list[dict[str, Any]]:\n \"\"\"Downsample one incident's observation series to its meaningful shape.\n\n Keeps the first + last point and every point whose threat ``level`` differs\n from its predecessor (a flat run of identical (level, score) is noise on a\n timeline). If transitions alone still exceed ``max_points`` it strides them\n down. Each kept point is shrunk to ``[t, level, score]`` (a list, not a\n verbose dict) so the wire form is a fraction of the size.\"\"\"\n if not points:\n return []\n keep: list[dict[str, Any]] = [points[0]]\n prev = points[0].get(\"level\")\n for p in points[1:-1]:\n if p.get(\"level\") != prev:\n keep.append(p)\n prev = p.get(\"level\")\n if len(points) > 1:\n keep.append(points[-1])\n if len(keep) > max_points:\n # Stride the kept transitions, but always retain the endpoints.\n step = (len(keep) - 1) / (max_points - 1)\n idx = sorted({0, *(round(i * step) for i in range(max_points)), len(keep) - 1})\n keep = [keep[i] for i in idx][:max_points]\n return [[p.get(\"t\"), p.get(\"level\"), p.get(\"score\")] for p in keep]\n\n\ndef _compact_history(data: dict[str, Any], limit: int, max_points: int) -> dict[str, Any]:\n \"\"\"Cap + condense the incident-history payload so the default call fits well\n under the MCP response token cap.\n\n The backend route returns EVERY incident in the window, each with its full\n per-snapshot ``points`` series and full ``narrative`` — ~89 KB at the\n default 6 h, which overflows the cap and hard-errors the tool. We keep the\n most-active incidents (the route already sorts by point-count desc), compact\n each one's series, trim the narrative, and report an honest \"showing N of M\".\n \"\"\"\n incidents = data.get(\"incidents\")\n if not isinstance(incidents, list):\n return data # error payload or unexpected shape — pass through untouched\n total = len(incidents)\n kept = incidents[:limit]\n compact: list[dict[str, Any]] = []\n for inc in kept:\n narrative = inc.get(\"narrative\")\n if isinstance(narrative, str) and len(narrative) > 240:\n narrative = narrative[:237] + \"…\"\n compact.append(\n {\n \"key\": inc.get(\"key\"),\n \"domains\": inc.get(\"domains\"),\n \"centroid\": inc.get(\"centroid\"),\n \"narrative\": narrative,\n # series is [[t, level, score], …] — compact list form\n \"series\": _compact_points(inc.get(\"points\") or [], max_points),\n }\n )\n out = {\n \"scope\": data.get(\"scope\"),\n \"window_hours\": data.get(\"window_hours\"),\n \"snapshots\": data.get(\"snapshots\"),\n \"incident_count\": total,\n \"returned\": len(compact),\n \"truncated\": total > len(compact),\n \"incidents\": compact,\n }\n if out[\"truncated\"]:\n out[\"note\"] = (\n f\"showing {len(compact)} of {total} incidents (most-active first); \"\n \"raise `limit` or scope with lat/lon/hours for more\"\n )\n return out\n\n\n@mcp.tool()\nasync def incident_history(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n hours: float = 6.0,\n limit: int = 25,\n max_incidents: int | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Timeline of how each incident built up over the recent window — per\n incident, a compact ``series`` of ``[time, threat_level, score]`` points.\n Reveals sequence (e.g. jamming first, then dark vessels, then a reported\n event). Global uses the background watch history; an AOI uses your prior\n watch calls.\n\n The full window can hold many incidents; this returns the ``limit`` most\n active ones (default 25, alias ``max_incidents``) with each series\n downsampled to its threat-level transitions, and reports\n ``incident_count`` / ``returned`` / ``truncated`` so you know if more exist.\n Raise ``limit`` or narrow with lat/lon/hours to drill in.\n \"\"\"\n cap = max_incidents if max_incidents is not None else limit\n cap = max(1, min(int(cap), 200))\n data = await _get(\n \"/api/intel/incident-history\",\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm, \"hours\": hours},\n )\n # detail='long' keeps a denser timeline (more transition points) per incident.\n max_points = 40 if normalize_detail(detail) == \"long\" else 12\n return _compact_history(data, cap, max_points=max_points)\n\n\n@mcp.tool()\nasync def vessel_dossier(mmsi: int | str, detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one vessel (MMSI): recent track, AIS gaps,\n derived speed profile (loiter / transit / loiter-then-dash), area covered,\n which live incidents it appears in, and a behaviour assessment. The track is\n the store's ~1h retention window.\"\"\"\n # MMSI is numeric; agents pass it as an int or a string — accept both.\n return shape(await _get(f\"/api/intel/dossier/vessel/{quote(str(mmsi), safe='')}\"), detail)\n\n\n@mcp.tool()\nasync def aircraft_dossier(ident: str, detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one aircraft (ICAO24 hex or callsign): recent\n track, gaps, derived speed profile, GNSS-integrity, emergency/military flags,\n and which live incidents it appears in.\"\"\"\n return shape(await _get(f\"/api/intel/dossier/aircraft/{quote(ident, safe='')}\"), detail)\n\n\n@mcp.tool()\nasync def list_focus_areas() -> dict[str, Any]:\n \"\"\"List the priority areas currently loaded PRIMARY (with fetch stats and\n whether each is served by a dedicated fetch or the degraded snapshot\n fallback).\"\"\"\n return await _get(\"/api/intel/aois\")\n\n\n@mcp.tool()\nasync def data_sources() -> dict[str, Any]:\n \"\"\"Which feeds are always-on vs key-gated, and the configured Ollama\n host/model. Use to explain coverage gaps (e.g. fires need a FIRMS key).\"\"\"\n return await _get(\"/api/intel/sources\")\n\n\n# ── 2026-07-14 data-layers wave: 12 keyless feeds ────────────────────────────\n# Each is a GeoJSON FeatureCollection whose Feature ids are `<kind>:<rawid>`, so\n# an agent can pivot from any hit to /api/entity/<id> and /api/correlations/<id>.\n\n\n@mcp.tool()\nasync def disaster_alerts(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"GDACS severity-scored global disasters (earthquake/cyclone/flood/volcano/\n drought/wildfire), each Green/Orange/Red by modelled impact.\"\"\"\n return shape(await _get(\"/api/hazards/gdacs\"), detail)\n\n\n@mcp.tool()\nasync def fire_perimeters(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Active wildfire burn-area polygons (NIFC/WFIGS) — the AREA behind FIRMS\n hotspot points, with incident name + size in acres.\"\"\"\n return shape(await _get(\"/api/hazards/fire-perimeters\"), detail)\n\n\n@mcp.tool()\nasync def tropical_cyclones(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Active tropical cyclones (NHC) with center fix, classification, intensity\n and pressure.\"\"\"\n return shape(await _get(\"/api/hazards/cyclones\"), detail)\n\n\n@mcp.tool()\nasync def volcanoes(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Smithsonian GVP Holocene volcanoes — location, type, elevation, last\n eruption year.\"\"\"\n return shape(await _get(\"/api/hazards/volcanoes\"), detail)\n\n\n@mcp.tool()\nasync def radiation(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Recent Safecast crowd radiation measurements (value + unit + location) —\n a nuclear-incident detection layer.\"\"\"\n return shape(await _get(\"/api/hazards/radiation\"), detail)\n\n\n@mcp.tool()\nasync def relief_disasters(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"ReliefWeb active humanitarian disasters, geocoded to the affected country.\"\"\"\n return shape(await _get(\"/api/hazards/reliefweb\"), detail)\n\n\n@mcp.tool()\nasync def air_quality(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Air quality (US AQI + PM2.5/PM10) sampled across major world cities —\n a proxy for industrial activity, smoke drift, and chemical release.\"\"\"\n return shape(await _get(\"/api/env/air-quality\"), detail)\n\n\n@mcp.tool()\nasync def marine_buoys(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"NDBC marine buoy observations — wave height, wind, pressure, water temp.\"\"\"\n return shape(await _get(\"/api/maritime/buoys\"), detail)\n\n\n@mcp.tool()\nasync def maritime_chokepoints(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Vessel congestion at named straits (Hormuz/Suez/Malacca/Bosphorus/…),\n derived live from the AIS union: vessel + stationary counts per chokepoint.\"\"\"\n return shape(await _get(\"/api/maritime/chokepoints\"), detail)\n\n\n@mcp.tool()\nasync def space_weather(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"SWPC space weather — solar X-ray flares, active alerts, and the auroral\n oval. The causal layer under GPS/HF degradation.\"\"\"\n return shape(await _get(\"/api/weather/swpc/space\"), detail)\n\n\n@mcp.tool()\nasync def power_plants(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"WRI global power plants (≥200 MW) — name, fuel, capacity, country. Static\n energy-infra targets to correlate strikes/outages against.\"\"\"\n return shape(await _get(\"/api/infra/powerplants\"), detail)\n\n\n@mcp.tool()\nasync def aviation_sigmet(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"AIRMET/SIGMET hazard polygons (turbulence, icing, IFR, volcanic ash,\n convective) constraining the aircraft layer.\"\"\"\n return shape(await _get(\"/api/aviation/sigmet\"), detail)\n\n\n# ── 2026-07-21 context+markets wave ──────────────────────────────────────────\n\n\n@mcp.tool()\nasync def travel_advisories(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Official country-level travel advisories pooled from US State, UK FCDO,\n and Australia Smartraveller — each country's highest reported level.\"\"\"\n return shape(await _get(\"/api/advisories\"), detail)\n\n\n@mcp.tool()\nasync def displacement(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"UN OCHA HAPI country-level IDP and refugee figures, latest reporting\n period per country.\"\"\"\n return shape(await _get(\"/api/displacement\"), detail)\n\n\n@mcp.tool()\nasync def nas_status(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"FAA National Airspace System ground stops, ground delays, arrival/\n departure delays, and airport closures, as points at the affected airport.\"\"\"\n return shape(await _get(\"/api/airspace/nas-status\"), detail)\n\n\n@mcp.tool()\nasync def climate_anomalies(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Temperature/precipitation anomalies (vs. a 5-year same-window baseline)\n over conflict-dense country centroids.\"\"\"\n return shape(await _get(\"/api/climate/anomalies\"), detail)\n\n\n@mcp.tool()\nasync def markets_snapshot(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Batched keyless market quote snapshot: indices, commodities, fx, crypto.\"\"\"\n return shape(await _get(\"/api/markets/snapshot\"), detail)\n\n\n@mcp.tool()\nasync def market_stress(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Composite market-stress score from equity drawdown, gold/oil moves, USD\n flight-to-safety, and BTC drawdown.\"\"\"\n return shape(await _get(\"/api/markets/stress\"), detail)\n\n\n# ── 2026-07-24 REST-parity wave: eq geo-filter, history, watch rules ────────\n# These three capabilities (geo-filtered /api/eq, identity-scoped\n# /api/history/track, per-identity watch-rule CRUD) shipped REST-only in the\n# prior wave with no MCP wrapper — an MCP-restricted agent had no way to reach\n# them. Thin wrappers only; the routes already do the validation.\n\n\n@mcp.tool()\nasync def quakes_near(\n lat: float, lon: float, radius_km: float, range: str = \"day\", detail: str = \"short\"\n) -> dict[str, Any]:\n \"\"\"Earthquakes (USGS) within radius_km of a point (GET /api/eq).\n\n lat, lon, and radius_km must ALL be given together — the route 422s on a\n partial set rather than silently falling back to the unfiltered global\n feed (agent-safe: pass all three or none). range is 'hour'|'day'\n (default)|'week'|'month'. For a severity-scored global disaster view\n instead, use disaster_alerts().\"\"\"\n data = await _get(\n \"/api/eq\", {\"lat\": lat, \"lon\": lon, \"radius_km\": radius_km, \"range\": range}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def track_history(\n id: str,\n from_ts: float | None = None,\n to_ts: float | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Historical position track for ONE entity (GET /api/history/track).\n\n id is 'aircraft:<icao24hex>' / 'vessel:<mmsi>', or a bare id whose shape is\n unambiguous (6-char ICAO24 hex or 9-digit MMSI) — the route infers the\n kind from it. from_ts/to_ts are unix seconds (default: the last hour). A\n id the route can't resolve returns its 422 message as-is (e.g. ambiguous\n shape — pass a 'kind:id' prefix).\"\"\"\n data = await _get(\"/api/history/track\", {\"id\": id, \"from_ts\": from_ts, \"to_ts\": to_ts})\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def create_watch_rule(\n label: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float | None = None,\n kinds: list[str] | None = None,\n min_severity: int = 1,\n channel: str = \"inapp\",\n sink_url: str | None = None,\n enabled: bool = True,\n icao24: str | None = None,\n mmsi: str | None = None,\n callsign: str | None = None,\n) -> dict[str, Any]:\n \"\"\"Create a standing watch rule (POST /api/alerts/rules).\n\n A rule needs a gate: either an identity pin (icao24/mmsi/callsign — follows\n that one entity globally, no AOI required) or a complete AOI (lat, lon;\n radius_nm defaults to 50 nm if omitted). kinds filters which signal kinds\n fire it — jamming/dark_vessel/military_air/military_vessel/incident/quake/\n fire/ais_gap/rendezvous/loiter (omit for all). channel is\n 'inapp'|'discord'|'webhook' ('discord'/'webhook' require sink_url). The\n route validates and 400/422s on a bad gate, channel, sink_url, or kind —\n this wrapper does not re-validate, it passes the error through.\"\"\"\n body = {\n \"label\": label,\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"kinds\": kinds,\n \"min_severity\": min_severity,\n \"channel\": channel,\n \"sink_url\": sink_url,\n \"enabled\": enabled,\n \"icao24\": icao24,\n \"mmsi\": mmsi,\n \"callsign\": callsign,\n }\n return await _post(\"/api/alerts/rules\", body)\n\n\n@mcp.tool()\nasync def list_watch_rules(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"List your standing watch rules (GET /api/alerts/rules).\"\"\"\n return shape(await _get(\"/api/alerts/rules\"), detail)\n\n\n@mcp.tool()\nasync def delete_watch_rule(rule_id: str) -> dict[str, Any]:\n \"\"\"Delete a standing watch rule by id (DELETE /api/alerts/rules/{rule_id}).\"\"\"\n return await _delete(f\"/api/alerts/rules/{quote(rule_id, safe='')}\")\n\n\n# ── deep analysis (DeepSeek reasoner, Ollama fallback) ────────────────────────\n\n_SYS_PROMPT = (\n \"You are a GEOINT analyst working a live open-source intelligence console. \"\n \"You are given distilled JSON from live ADS-B (aircraft), AIS (vessels), a \"\n \"GPS-jamming layer (ADS-B NACp/NIC, GPSJam method), and a cross-domain \"\n \"fusion engine whose `incident_brief` already chains co-located signals into \"\n \"ranked, cited INCIDENTS — lead your analysis from those incidents, then use \"\n \"the raw situation/area data to corroborate or challenge them. \"\n \"Reason ONLY over the provided JSON. Cite concrete numbers and IDs. Flag: \"\n \"GPS jamming/spoofing footprints, emergency squawks (7500 hijack / 7600 \"\n \"radio-fail / 7700 general), dark vessels, abnormal traffic density, and \"\n \"military activity. Be concise and structured: (1) ASSESSMENT (2 lines), \"\n \"(2) KEY FINDINGS (bullets with numbers), (3) RECOMMENDED FOLLOW-UP \"\n \"queries. If the data is thin, say so plainly — do not invent.\"\n)\n\n\n@mcp.tool()\nasync def deep_analyze(\n question: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 250.0,\n model: str | None = None,\n tier: str = \"reason\",\n) -> dict[str, Any]:\n \"\"\"Have a reasoning model reason deeply over the live intel.\n\n Gathers the relevant data (global situation, plus a focused area bundle +\n jamming + anomalies when lat/lon given) and feeds it to a real reasoning\n model — DeepSeek (``deepseek-reasoner``) when configured, else a local\n Ollama model. Heavy reasoning runs off-context; only the conclusion enters\n the agent's context. Falls back to returning the raw structured data\n (analysis=null) if no LLM backend is reachable.\n\n Args:\n question: what to analyse (e.g. \"Is there coordinated GPS jamming near\n the Baltic, and which aircraft are affected?\").\n lat, lon, radius_nm: optional area to focus on (loaded PRIMARY).\n model: optional Ollama model override used only on the Ollama fallback.\n tier: ``reason`` (deepseek-reasoner, default — judgement) or ``fast``\n (deepseek-chat — quicker, shallower).\n \"\"\"\n import json as _json\n\n from app import llm # noqa: PLC0415\n\n # 1) gather context. Pull detail='long' internally — the reasoner runs\n # off-context with its own large window, so it should judge the FULL fused\n # picture, not the token-frugal digest the agent-facing default returns.\n context: dict[str, Any] = {\n \"question\": question,\n \"situation\": await get_situation(detail=\"long\"),\n }\n context[\"incident_brief\"] = await intel_brief(\n lat=lat, lon=lon, radius_nm=radius_nm,\n window_hours=12.0 if lat is None else 6.0,\n detail=\"long\",\n )\n if lat is not None and lon is not None:\n context[\"focus_area\"] = await focus_area(lat, lon, radius_nm, detail=\"long\")\n\n # 2) reason off-context (DeepSeek → Ollama → raw data). Cap the wait so a\n # slow upstream can't hang the tool for the 180 s default — the fast tier\n # should answer well under 60 s, the deeper reason tier under 90 s.\n timeout_s = 60.0 if (tier or \"\").lower() == \"fast\" else 90.0\n res = await llm.complete(\n _SYS_PROMPT,\n f\"QUESTION: {question}\\n\\nLIVE INTEL JSON:\\n\"\n + _json.dumps(context, separators=(\",\", \":\"))[:60000],\n tier=tier,\n temperature=0.2,\n max_tokens=2048,\n timeout_s=timeout_s,\n ollama_model=model or \"\",\n )\n if not res.ok:\n return {\n \"analysis\": None,\n \"model\": res.model,\n \"backend\": res.backend,\n \"note\": f\"No LLM backend reachable ({res.error}). Returning structured \"\n \"intel for you to analyse directly. Configure DEEPSEEK_API_KEY / the \"\n \"opencode DeepSeek key, or `ollama pull qwen2.5:3b`.\",\n \"data\": context,\n }\n\n return {\n \"analysis\": res.text,\n \"model\": res.model,\n \"backend\": res.backend,\n \"focused_on\": (\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm} if lat is not None else \"global\"\n ),\n \"data_summary\": {\n \"aircraft_total\": context[\"situation\"].get(\"aircraft\", {}).get(\"total\"),\n \"jamming_high\": context[\"situation\"].get(\"gps_jamming\", {}).get(\"high\"),\n },\n }\n\n\n# ── news intelligence (debias / fact-check) ──────────────────────────────────\n\n\n@mcp.tool()\nasync def news_analysis() -> dict[str, Any]:\n \"\"\"Cross-source, debiased world-news intelligence.\n\n Scrapes ~12 outlets (BBC, Reuters, AP, Al Jazeera, Guardian, CNN, Fox, …),\n then a reasoning model strips bias/propaganda and separates VERIFIED FACTS\n (corroborated by ≥2 independent outlets) from ATTRIBUTED CLAIMS and\n rhetoric. A leader promising \"the war will end soon\" is flagged as rhetoric,\n never reported as fact. Returns events with neutral_summary, verified_facts,\n attributed_claims, bias_flags, propaganda_techniques, rhetoric_flags and a\n confidence. May take ~30s when the cache is cold (it reasons over the feed).\n \"\"\"\n return await _get(\"/api/news/analysis\")\n\n\n@mcp.tool()\nasync def fact_check(claim: str) -> dict[str, Any]:\n \"\"\"Adjudicate one free-text claim against current world-news headlines.\n\n Returns ``{verdict: true|false|misleading|unverified, reasoning,\n supporting_sources, confidence}``. Use it to sanity-check a statement before\n treating it as fact.\n \"\"\"\n return await _get(\"/api/news/factcheck\", {\"claim\": claim})\n\n\n@mcp.tool()\nasync def news_brief(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Latest assembled morning brief: top story per category plus a short\n synthesis paragraph tying the leads together. Built on a ~20h cadence by\n the background news refresher, not on-demand — use ``news_analysis`` or\n the edition route for the freshest per-story detail.\n \"\"\"\n return shape(await _get(\"/api/news/brief\"), detail)\n\n\n@mcp.tool()\nasync def aoi_imagery(\n before: str,\n after: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_km: float = 5.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n window_days: int = 30,\n) -> dict[str, Any]:\n \"\"\"Building imagery for a location at two dates — set time + place directly.\n\n Give a location (lat/lon + radius_km, OR an explicit min/max bbox) and a\n before + after date (YYYY-MM-DD). Returns what imagery is available for each\n date WITHOUT downloading: Maxar Open Data VHR (~0.3-0.5 m, event-gated — only\n where a disaster/conflict event covers the AOI) and Sentinel (10 m, global,\n any date). `best_source` says which to use. Reconstruction downloads the\n scenes to a temp dir on demand and discards them — nothing is stored.\n\n Args:\n before, after: dates as YYYY-MM-DD.\n lat, lon, radius_km: centre + radius (km) of the AOI.\n min_lon/min_lat/max_lon/max_lat: explicit bbox (overrides lat/lon).\n window_days: ± days around each date to search Maxar (events are sparse).\n \"\"\"\n params: dict[str, Any] = {\n \"before\": before,\n \"after\": after,\n \"radius_km\": radius_km,\n \"window_days\": window_days,\n }\n if None not in (min_lon, min_lat, max_lon, max_lat):\n params.update(min_lon=min_lon, min_lat=min_lat, max_lon=max_lon, max_lat=max_lat)\n else:\n params.update(lat=lat, lon=lon)\n return await _get(\"/api/imagery/aoi\", params)\n\n\n# ── entrypoint ─────────────────────────────────────────────────────────────────\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=\"OSINT GEOINT MCP server\")\n parser.add_argument(\"--http\", action=\"store_true\", help=\"serve over streamable-HTTP\")\n parser.add_argument(\"--port\", type=int, default=8765, help=\"HTTP port (with --http)\")\n parser.add_argument(\"--host\", default=\"127.0.0.1\", help=\"HTTP host (with --http)\")\n parser.add_argument(\n \"--list-tools\", action=\"store_true\", help=\"print registered tools and exit\"\n )\n args = parser.parse_args()\n\n if args.list_tools:\n tools = asyncio.run(mcp.list_tools())\n print(f\"osint-geoint MCP — {len(tools)} tools:\")\n for t in tools:\n summary = (t.description or \"\").split(\"\\n\", 1)[0]\n print(f\" • {t.name}: {summary}\")\n return\n\n if args.http:\n mcp.settings.host = args.host\n mcp.settings.port = args.port\n mcp.run(transport=\"streamable-http\")\n else:\n mcp.run() # stdio\n\n\nif __name__ == \"__main__\":\n main()\n"}, "63bfc47f8d5c09bd61ebfefe6e1ca724e71e5097": {"t": 1785765045.9634352, "n": 42697, "h": "70bcbadf0a3f8a28", "c": "\"\"\"OSINT GEOINT — Model Context Protocol server.\n\nExposes the live OSINT console as tools an AI agent can call. Every tool is a\nthin async wrapper over the ``/api/intel/*`` HTTP surface (``app.routes.intel``)\nso the agent shares the SAME warm in-process snapshot, AOI priority loader, and\nfusion engine the globe uses — no second upstream fan-out, no rate-limit blowup.\n\nDesign principles (the MCP brief):\n- **Context-safe.** Tools return distilled JSON (counts, grids, ≤50-item\n samples), never raw feature dumps. An agent can sweep the whole planet for a\n few hundred tokens.\n- **Area-primary.** ``focus_area`` marks a region PRIMARY: a dedicated fresh\n fetch + ongoing priority refresh, independent of global rate limits. Other\n regions keep streaming from the global snapshot.\n- **Think deeper, off-context.** ``deep_analyze`` pulls the relevant intel JSON\n and hands it to a real reasoning model — DeepSeek (``deepseek-reasoner``) when\n configured, else a local Ollama model — so heavy analysis happens off the\n agent's context and only the conclusion returns.\n\nRun:\n python -m app.mcp_server # stdio (Claude Code / Desktop / SDK)\n python -m app.mcp_server --http --port 8765 # streamable-HTTP\n python -m app.mcp_server --list-tools # introspect, for CI / verification\n\nConfig (env or apps/api/.env): API_BASE, API_KEY, DEEPSEEK_API_KEY (or the\nopencode DeepSeek key), OLLAMA_HOST, OLLAMA_MODEL.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport atexit\nimport os\nimport subprocess\nimport sys\nimport time\nfrom typing import Any\nfrom urllib.parse import quote, urlparse\n\nimport httpx\nfrom mcp.server.fastmcp import FastMCP\nfrom mcp.server.streamable_http_manager import StreamableHTTPSessionManager\nfrom mcp.server.transport_security import TransportSecuritySettings\nfrom starlette.routing import Route\n\nfrom app.config import get_settings\nfrom app.intel.shape import normalize_detail, shape\n\nmcp = FastMCP(\n \"osint-geoint\",\n instructions=(\n \"Live geospatial intelligence over open ADS-B (aircraft), AIS (vessels), \"\n \"GPS-jamming (ADS-B NACp/NIC), Sentinel-1 SAR dark vessels, geocoded \"\n \"events, and a cross-domain fusion engine. Workflow: get_situation() to \"\n \"orient -> intel_brief() for ranked, cited cross-domain INCIDENTS (the \"\n \"fused picture, global or scoped) -> focus_area(lat,lon,radius_nm) to \"\n \"load an incident's region PRIMARY -> query_vessels / gps_jamming / \"\n \"query_aircraft to drill into its evidence -> deep_analyze() to have a \"\n \"reasoning model judge it. intel_brief is the headline tool: it chains \"\n \"signals into incidents so you don't correlate raw layers by hand.\\n\\n\"\n \"CONTEXT BUDGET: most tools take detail='short'|'long'. 'short' (the \"\n \"default) is a token-frugal digest — headline counts plus the top few \"\n \"items of each list, with `*_total` giving the true size — ideal for \"\n \"orientation and broad sweeps. Switch to detail='long' only once you've \"\n \"picked an incident/area worth the full, comprehensive bundle.\"\n ),\n)\n\n\n# ── hosted mount (streamable-HTTP at /mcp of the FastAPI app) ──────────────────\n# The agent-facing endpoint. Mounting the MCP into the SAME uvicorn process the\n# globe runs lets every tool share the warm in-process snapshot + fusion engine\n# (the tools still self-call /api/intel/* over localhost, authenticated by the\n# static API_KEY). The Velocity gateway Worker proxies https://<host>/mcp here,\n# forwarding the caller's Supabase token; the backend's ApiKeyMiddleware gates it\n# like any other non-public route, so no MCP-specific auth is needed.\n\n\nclass _MCPASGIApp:\n \"\"\"Raw-ASGI adapter for the streamable-HTTP session manager.\n\n A plain function endpoint would be wrapped by Starlette as a\n request/response handler; a class instance is left as a raw ASGI app, which\n is what the transport needs (it negotiates SSE itself).\n \"\"\"\n\n def __init__(self, manager: StreamableHTTPSessionManager) -> None:\n self._manager = manager\n\n async def __call__(self, scope: Any, receive: Any, send: Any) -> None:\n await self._manager.handle_request(scope, receive, send)\n\n\ndef build_mcp_mount() -> tuple[list[Route], StreamableHTTPSessionManager]:\n \"\"\"Return (routes, session_manager) to serve this server at ``/mcp``.\n\n A FRESH manager per call — ``StreamableHTTPSessionManager.run()`` is one-shot\n (it raises if entered twice), so every FastAPI app instance, including each\n test app, needs its own. Usage::\n\n routes, mgr = build_mcp_mount()\n app.router.routes.extend(routes) # in the app factory\n async with mgr.run(): ... yield # in the app lifespan\n\n Served as exact ``Route``s (``/mcp`` and ``/mcp/``), NOT a ``Mount`` — a\n mount 307-redirects ``/mcp`` → ``/mcp/`` with a Location built from the\n request host, which through the gateway Worker would point a client at the\n bare backend origin. DNS-rebinding protection is disabled: the endpoint is\n reached server-to-server through the Worker (the Host header is the\n deployment origin, not a browser tab), and the Supabase-token check in\n ``app.auth.ApiKeyMiddleware`` is the real gate.\n \"\"\"\n manager = StreamableHTTPSessionManager(\n app=mcp._mcp_server,\n json_response=False,\n stateless=False,\n security_settings=TransportSecuritySettings(\n enable_dns_rebinding_protection=False\n ),\n )\n endpoint = _MCPASGIApp(manager)\n routes = [Route(\"/mcp\", endpoint), Route(\"/mcp/\", endpoint)]\n return routes, manager\n\n\n# ── backend HTTP plumbing ─────────────────────────────────────────────────────\n\n\ndef _api_base() -> str:\n return (os.environ.get(\"API_BASE\") or get_settings().api_base).rstrip(\"/\")\n\n\n# Cache one minted internal token per secret until ~60 s before it expires, so a\n# burst of tool calls re-signs at most once a minute instead of per request.\n_INTERNAL_JWT_TTL_S = 600 # 10 min token lifetime\n_minted_jwt: tuple[str, float] | None = None # (token, wall-clock expiry)\n\n\ndef _mint_internal_jwt(secret: str) -> str | None:\n \"\"\"Mint a short-lived HS256 token the backend's ``app.auth`` accepts.\n\n In prod the gate is Supabase-JWT-only (no static ``API_KEY``); a server-to-\n server hop carries no browser session, so we sign our own. ``_verify_hs256``\n requires an HS256 signature over the JWT secret, ``role==\"authenticated\"``,\n and an unexpired ``exp`` — we also set the standard Supabase ``aud``/``sub``/\n ``iat`` so the token is well-formed. Cached until ~60 s before ``exp``.\n \"\"\"\n global _minted_jwt\n now = time.time()\n if _minted_jwt is not None:\n token, expiry = _minted_jwt\n if expiry - 60 > now:\n return token\n try:\n import jwt # noqa: PLC0415 — pyjwt, the lib app.auth's HS256 check mirrors\n except Exception: # noqa: BLE001 — pyjwt missing → no Authorization header\n return None\n exp = int(now) + _INTERNAL_JWT_TTL_S\n claims = {\n \"role\": \"authenticated\", # the claim app.auth._verify_hs256 demands\n \"aud\": \"authenticated\",\n \"sub\": \"osint-mcp-internal\",\n \"iat\": int(now),\n \"exp\": exp,\n }\n try:\n token = jwt.encode(claims, secret, algorithm=\"HS256\")\n except Exception: # noqa: BLE001 — encode failure → degrade to no auth header\n return None\n _minted_jwt = (token, float(exp))\n return token\n\n\ndef _headers() -> dict[str, str]:\n s = get_settings()\n key = os.environ.get(\"API_KEY\") or s.api_key\n if key:\n return {\"X-API-Key\": key}\n # No static key (prod): if a Supabase JWT secret is configured, mint an\n # internal token so the self-hop to /api/intel/* authenticates. Otherwise\n # (fully open local box) send nothing, as before.\n secret = s.supabase_jwt_secret\n if secret:\n token = _mint_internal_jwt(secret)\n if token:\n return {\"Authorization\": f\"Bearer {token}\"}\n return {}\n\n\n# ── backend auto-start ────────────────────────────────────────────────────────\n# The MCP server is a thin client over the OSINT backend. If that backend is\n# not running, every tool errors and a driving agent can spin in a retry loop.\n# So on the first tool call we make sure a backend exists: reuse one already\n# listening, else spawn uvicorn ourselves (localhost only) and wait — bounded —\n# for it to come up. Set OSINT_MCP_NO_AUTOSTART=1 to disable.\n\n_BACKEND_LOCK = asyncio.Lock()\n_BACKEND_READY = False\n_BACKEND_PROC: subprocess.Popen[bytes] | None = None\n_AUTOSTART_WAIT_S = 60\n\n\ndef _is_localhost(url: str) -> bool:\n host = (urlparse(url).hostname or \"\").lower()\n return host in {\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"::1\"}\n\n\ndef _api_port() -> int:\n return urlparse(_api_base()).port or 8000\n\n\ndef _apps_api_dir() -> str:\n import app # noqa: PLC0415\n\n return os.path.dirname(os.path.dirname(os.path.abspath(app.__file__)))\n\n\nasync def _backend_healthy(timeout_s: float = 2.0) -> bool:\n try:\n async with httpx.AsyncClient(timeout=timeout_s) as c:\n r = await c.get(_api_base() + \"/api/intel/sources\", headers=_headers())\n return r.status_code == 200\n except Exception:\n return False\n\n\ndef _spawn_uvicorn(cmd: list[str]) -> subprocess.Popen[bytes]:\n \"\"\"Sync spawn (Popen returns immediately; isolated from the async path).\"\"\"\n return subprocess.Popen(\n cmd,\n cwd=_apps_api_dir(),\n stdout=subprocess.DEVNULL,\n stderr=subprocess.DEVNULL,\n env=dict(os.environ),\n )\n\n\ndef _terminate_backend() -> None:\n proc = _BACKEND_PROC\n if proc is not None and proc.poll() is None:\n proc.terminate()\n\n\nasync def _prewarm() -> None:\n \"\"\"Kick the global ADS-B snapshot so the first get_situation isn't slow.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=120.0) as c:\n await c.get(_api_base() + \"/api/adsb/global\", headers=_headers())\n except Exception:\n pass\n\n\nasync def _ensure_backend() -> None:\n \"\"\"Idempotent: guarantee a reachable backend before a tool runs.\"\"\"\n global _BACKEND_READY, _BACKEND_PROC\n if _BACKEND_READY:\n return\n async with _BACKEND_LOCK:\n if _BACKEND_READY:\n return\n if await _backend_healthy():\n _BACKEND_READY = True\n return\n # Only auto-start a LOCAL backend, and only if not opted out.\n if os.environ.get(\"OSINT_MCP_NO_AUTOSTART\") or not _is_localhost(_api_base()):\n return\n # Spawn at most ONCE per process. If a prior attempt didn't come up,\n # don't re-spawn (avoids stacking uvicorns / re-waiting every call) —\n # just let later calls re-check health in case it's still warming.\n if _BACKEND_PROC is not None:\n return\n cmd = [\n sys.executable, \"-m\", \"uvicorn\", \"app.main:app\",\n \"--host\", \"127.0.0.1\", \"--port\", str(_api_port()), \"--log-level\", \"warning\",\n ]\n try:\n _BACKEND_PROC = _spawn_uvicorn(cmd)\n except Exception:\n return # can't spawn (e.g. uvicorn missing) — tools report cleanly\n atexit.register(_terminate_backend)\n for _ in range(_AUTOSTART_WAIT_S):\n await asyncio.sleep(1.0)\n if await _backend_healthy():\n _BACKEND_READY = True\n asyncio.create_task(_prewarm()) # warm snapshot off the hot path\n return\n # Did not come up in time; leave _BACKEND_READY False so the next tool\n # call retries once more rather than looping on a dead spawn.\n\n\nasync def _get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"GET an intel endpoint. Returns parsed JSON or a structured error dict —\n never raises, so a tool call always yields something the agent can read.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in (params or {}).items() if v is not None}\n # Generous ceiling: the backend's FIRST global call cold-starts the ADS-B\n # snapshot (firehose fetch + backoff) and can take up to ~75s. After that\n # every call is instant. A short timeout here would mis-report a healthy\n # backend as unreachable on the agent's very first query.\n try:\n async with httpx.AsyncClient(timeout=90.0) as client:\n r = await client.get(url, params=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\n \"error\": \"backend_unreachable\",\n \"detail\": str(exc),\n \"url\": url,\n \"hint\": \"Backend auto-start did not come up in time. It may still be \"\n \"warming — retry once. Or start it manually: uv run --project \"\n f\"{_apps_api_dir()} uvicorn app.main:app --port {_api_port()}\",\n }\n if r.status_code != 200:\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\n# ── tools ─────────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def get_situation(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Global situational snapshot — the cheap first call to orient.\n\n Returns total aircraft (airborne/ground, by category), GNSS-degraded count,\n active emergency squawks, the worst GPS-jamming cells worldwide, tracked\n vessel counts by category, and recent fusion-alert counts. A few hundred\n tokens describing the whole planet.\n\n detail='short' (default) trims the jamming/vessel/alert samples to their\n top few; detail='long' returns the full situation bundle.\"\"\"\n return shape(await _get(\"/api/intel/situation\"), detail)\n\n\n@mcp.tool()\nasync def focus_area(\n lat: float,\n lon: float,\n radius_nm: float = 200.0,\n label: str | None = None,\n cell_deg: float = 1.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Load a region PRIMARY and return a full intel bundle for it in one call.\n\n This is the headline tool. It triggers a DEDICATED, always-fresh fetch for\n just this area (bypassing global rate limits) and registers it for ongoing\n priority refresh. The bundle contains: fresh aircraft summary + sample,\n aircraft-density grid, GPS-jamming assessment, vessels, and fused anomalies\n (emergencies, jamming hotspots, dark vessels, alerts) with a threat level.\n\n Args:\n lat, lon: centre of the area of interest.\n radius_nm: radius in nautical miles (1–250; default 200).\n label: optional human name for the AOI (e.g. \"Kaliningrad\").\n cell_deg: density grid cell size in degrees (0.1–10; default 1.0).\n \"\"\"\n data = await _get(\n \"/api/intel/area\",\n {\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"label\": label,\n \"primary\": True,\n \"cell_deg\": cell_deg,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def aircraft_density(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n cell_deg: float = 1.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Aircraft density over an area as a grid of cells (count, by category,\n GNSS-degraded per cell) plus totals, peak cell, and in-area vessel count.\n\n Give either a centre (lat, lon [, radius_nm]) or an explicit bbox\n (min_lon, min_lat, max_lon, max_lat). cell_deg sets grid resolution.\"\"\"\n data = await _get(\n \"/api/intel/density\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat, \"cell_deg\": cell_deg,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def gps_jamming(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"GPS/GNSS jamming assessment (GPSJam method: ADS-B NACp<8 / NIC<7 binned\n into 1° cells). Returns flagged cells ranked by severity, counts of\n high/medium cells, and a sample of affected aircraft. Omit all coordinates\n for a global view; otherwise pass a centre or a bbox to scope it.\"\"\"\n data = await _get(\n \"/api/intel/jamming\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def query_aircraft(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n category: str | None = None,\n squawk: str | None = None,\n callsign_contains: str | None = None,\n min_alt_m: float | None = None,\n max_alt_m: float | None = None,\n emergency: bool | None = None,\n gnss_degraded: bool | None = None,\n on_ground: bool | None = None,\n limit: int = 50,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Filtered aircraft query against the live snapshot. Returns matched_total\n + a capped list of compact records.\n\n category ∈ airliner|private|helicopter|glider|military|emergency.\n Combine with area (centre or bbox), squawk, callsign_contains, altitude\n band (metres), emergency / gnss_degraded / on_ground booleans. limit ≤200.\"\"\"\n data = await _get(\n \"/api/intel/aircraft\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"category\": category, \"squawk\": squawk,\n \"callsign_contains\": callsign_contains,\n \"min_alt_m\": min_alt_m, \"max_alt_m\": max_alt_m,\n \"emergency\": emergency, \"gnss_degraded\": gnss_degraded,\n \"on_ground\": on_ground, \"limit\": limit,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def lookup_aircraft(ident: str) -> dict[str, Any]:\n \"\"\"Look up one aircraft by ICAO24 hex (exact) or callsign (substring).\n Returns its current state, registration, an integrity/threat assessment,\n and the latest server-held fix.\"\"\"\n # quote(): ident is agent-supplied free text going into a URL path —\n # escape it so \"x/../y\" style values cannot re-route the request.\n return await _get(f\"/api/intel/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def query_vessels(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n dark_only: bool = False,\n limit: int = 50,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Vessels (AIS) in an area, classified (cargo/tanker/fishing/passenger/\n military/sailing/pleasure/tug). dark_only=True returns only dark-vessel\n candidates (moving with no static identity). Scoped by centre or bbox.\"\"\"\n data = await _get(\n \"/api/intel/vessels\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"dark_only\": dark_only, \"limit\": limit,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def anomalies(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Fused anomaly report for an area (or global if no coords): emergency\n aircraft, GPS-jamming hotspots, dark-vessel candidates, recent fusion\n alerts, plus a triage threat_level (low|elevated|high) and score.\"\"\"\n data = await _get(\n \"/api/intel/anomalies\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def intel_brief(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n link_km: float = 50.0,\n window_hours: float = 6.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Cross-domain INCIDENT brief — the headline analytic tool.\n\n Fuses the raw layers into ranked, cited INCIDENTS instead of returning\n signals you have to correlate yourself. An incident is a CONVERGENCE: signals\n within ``link_km`` across >=2 domains (GPS-jamming, dark/AIS-off vessels,\n military air, AIS gaps, emergencies, geocoded events, quakes) — or a single\n critical/high signal. Each incident carries a rule-based narrative, a\n threat_level, the contributing evidence (with IDs), and recommended follow-up\n queries. Omit coordinates for a global brief; pass a centre+radius or bbox to\n scope it. Start here, then drill into an incident's centroid with\n query_vessels / gps_jamming / deep_analyze.\n \"\"\"\n data = await _get(\n \"/api/intel/brief\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"link_km\": link_km, \"window_hours\": window_hours,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def detect_deception(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Denial & deception — \"am I being fed?\". Flags MANIPULATED tracks distinct\n from jamming: AIS duplicate-MMSI (one identity, two hulls) and impossible\n teleports; ADS-B GPS spoofing (many aircraft snapped to one false position)\n and kinematic position-injection. Run before trusting a feed in a contested\n area. Omit coords for global.\"\"\"\n data = await _get(\n \"/api/intel/deception\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def locate_emitter(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Estimate a GPS jammer/spoofer LOCATION from the degraded-ADS-B footprint\n (severity-weighted centroid + CEP + confidence). Turns \"jamming somewhere\n here\" into \"emitter ~here ±N km\". Footprint-centroid estimate (~tens of km),\n not RF direction-finding — stated in the response. Scope with lat/lon.\"\"\"\n data = await _get(\n \"/api/intel/emitter\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def area_baseline(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Is this normal? Current vessel / dark-vessel / jamming / military counts\n z-scored against a rolling baseline, with anomalies called out (e.g. \"dark\n vessels +5σ\", \"traffic -3σ\"). Global uses the background sampler; polling an\n AOI repeatedly builds that area's baseline. Distinguishes a real shift from\n a normal day.\"\"\"\n data = await _get(\n \"/api/intel/baseline\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def whats_changed(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Standing watch — what CHANGED since the last check, not the full picture.\n\n Returns incidents that are NEW, ESCALATED, DE-ESCALATED, or RESOLVED. Global\n (no coords) reflects the background watch loop (recomputed ~every 60s); an\n AOI (lat/lon) diffs against YOUR previous whats_changed call for that area,\n so you can poll one region and be told only what moved. Use this to monitor\n instead of re-reading the whole brief each time.\n \"\"\"\n data = await _get(\n \"/api/intel/watch\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\ndef _compact_points(points: list[dict[str, Any]], max_points: int) -> list[dict[str, Any]]:\n \"\"\"Downsample one incident's observation series to its meaningful shape.\n\n Keeps the first + last point and every point whose threat ``level`` differs\n from its predecessor (a flat run of identical (level, score) is noise on a\n timeline). If transitions alone still exceed ``max_points`` it strides them\n down. Each kept point is shrunk to ``[t, level, score]`` (a list, not a\n verbose dict) so the wire form is a fraction of the size.\"\"\"\n if not points:\n return []\n keep: list[dict[str, Any]] = [points[0]]\n prev = points[0].get(\"level\")\n for p in points[1:-1]:\n if p.get(\"level\") != prev:\n keep.append(p)\n prev = p.get(\"level\")\n if len(points) > 1:\n keep.append(points[-1])\n if len(keep) > max_points:\n # Stride the kept transitions, but always retain the endpoints.\n step = (len(keep) - 1) / (max_points - 1)\n idx = sorted({0, *(round(i * step) for i in range(max_points)), len(keep) - 1})\n keep = [keep[i] for i in idx][:max_points]\n return [[p.get(\"t\"), p.get(\"level\"), p.get(\"score\")] for p in keep]\n\n\ndef _compact_history(data: dict[str, Any], limit: int, max_points: int) -> dict[str, Any]:\n \"\"\"Cap + condense the incident-history payload so the default call fits well\n under the MCP response token cap.\n\n The backend route returns EVERY incident in the window, each with its full\n per-snapshot ``points`` series and full ``narrative`` — ~89 KB at the\n default 6 h, which overflows the cap and hard-errors the tool. We keep the\n most-active incidents (the route already sorts by point-count desc), compact\n each one's series, trim the narrative, and report an honest \"showing N of M\".\n \"\"\"\n incidents = data.get(\"incidents\")\n if not isinstance(incidents, list):\n return data # error payload or unexpected shape — pass through untouched\n total = len(incidents)\n kept = incidents[:limit]\n compact: list[dict[str, Any]] = []\n for inc in kept:\n narrative = inc.get(\"narrative\")\n if isinstance(narrative, str) and len(narrative) > 240:\n narrative = narrative[:237] + \"…\"\n compact.append(\n {\n \"key\": inc.get(\"key\"),\n \"domains\": inc.get(\"domains\"),\n \"centroid\": inc.get(\"centroid\"),\n \"narrative\": narrative,\n # series is [[t, level, score], …] — compact list form\n \"series\": _compact_points(inc.get(\"points\") or [], max_points),\n }\n )\n out = {\n \"scope\": data.get(\"scope\"),\n \"window_hours\": data.get(\"window_hours\"),\n \"snapshots\": data.get(\"snapshots\"),\n \"incident_count\": total,\n \"returned\": len(compact),\n \"truncated\": total > len(compact),\n \"incidents\": compact,\n }\n if out[\"truncated\"]:\n out[\"note\"] = (\n f\"showing {len(compact)} of {total} incidents (most-active first); \"\n \"raise `limit` or scope with lat/lon/hours for more\"\n )\n return out\n\n\n@mcp.tool()\nasync def incident_history(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n hours: float = 6.0,\n limit: int = 25,\n max_incidents: int | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Timeline of how each incident built up over the recent window — per\n incident, a compact ``series`` of ``[time, threat_level, score]`` points.\n Reveals sequence (e.g. jamming first, then dark vessels, then a reported\n event). Global uses the background watch history; an AOI uses your prior\n watch calls.\n\n The full window can hold many incidents; this returns the ``limit`` most\n active ones (default 25, alias ``max_incidents``) with each series\n downsampled to its threat-level transitions, and reports\n ``incident_count`` / ``returned`` / ``truncated`` so you know if more exist.\n Raise ``limit`` or narrow with lat/lon/hours to drill in.\n \"\"\"\n cap = max_incidents if max_incidents is not None else limit\n cap = max(1, min(int(cap), 200))\n data = await _get(\n \"/api/intel/incident-history\",\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm, \"hours\": hours},\n )\n # detail='long' keeps a denser timeline (more transition points) per incident.\n max_points = 40 if normalize_detail(detail) == \"long\" else 12\n return _compact_history(data, cap, max_points=max_points)\n\n\n@mcp.tool()\nasync def vessel_dossier(mmsi: int | str, detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one vessel (MMSI): recent track, AIS gaps,\n derived speed profile (loiter / transit / loiter-then-dash), area covered,\n which live incidents it appears in, and a behaviour assessment. The track is\n the store's ~1h retention window.\"\"\"\n # MMSI is numeric; agents pass it as an int or a string — accept both.\n return shape(await _get(f\"/api/intel/dossier/vessel/{quote(str(mmsi), safe='')}\"), detail)\n\n\n@mcp.tool()\nasync def aircraft_dossier(ident: str, detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one aircraft (ICAO24 hex or callsign): recent\n track, gaps, derived speed profile, GNSS-integrity, emergency/military flags,\n and which live incidents it appears in.\"\"\"\n return shape(await _get(f\"/api/intel/dossier/aircraft/{quote(ident, safe='')}\"), detail)\n\n\n@mcp.tool()\nasync def list_focus_areas() -> dict[str, Any]:\n \"\"\"List the priority areas currently loaded PRIMARY (with fetch stats and\n whether each is served by a dedicated fetch or the degraded snapshot\n fallback).\"\"\"\n return await _get(\"/api/intel/aois\")\n\n\n@mcp.tool()\nasync def data_sources() -> dict[str, Any]:\n \"\"\"Which feeds are always-on vs key-gated, and the configured Ollama\n host/model. Use to explain coverage gaps (e.g. fires need a FIRMS key).\"\"\"\n return await _get(\"/api/intel/sources\")\n\n\n# ── 2026-07-14 data-layers wave: 12 keyless feeds ────────────────────────────\n# Each is a GeoJSON FeatureCollection whose Feature ids are `<kind>:<rawid>`, so\n# an agent can pivot from any hit to /api/entity/<id> and /api/correlations/<id>.\n\n\n@mcp.tool()\nasync def disaster_alerts(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"GDACS severity-scored global disasters (earthquake/cyclone/flood/volcano/\n drought/wildfire), each Green/Orange/Red by modelled impact.\"\"\"\n return shape(await _get(\"/api/hazards/gdacs\"), detail)\n\n\n@mcp.tool()\nasync def fire_perimeters(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Active wildfire burn-area polygons (NIFC/WFIGS) — the AREA behind FIRMS\n hotspot points, with incident name + size in acres.\"\"\"\n return shape(await _get(\"/api/hazards/fire-perimeters\"), detail)\n\n\n@mcp.tool()\nasync def tropical_cyclones(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Active tropical cyclones (NHC) with center fix, classification, intensity\n and pressure.\"\"\"\n return shape(await _get(\"/api/hazards/cyclones\"), detail)\n\n\n@mcp.tool()\nasync def volcanoes(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Smithsonian GVP Holocene volcanoes — location, type, elevation, last\n eruption year.\"\"\"\n return shape(await _get(\"/api/hazards/volcanoes\"), detail)\n\n\n@mcp.tool()\nasync def radiation(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Recent Safecast crowd radiation measurements (value + unit + location) —\n a nuclear-incident detection layer.\"\"\"\n return shape(await _get(\"/api/hazards/radiation\"), detail)\n\n\n@mcp.tool()\nasync def relief_disasters(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"ReliefWeb active humanitarian disasters, geocoded to the affected country.\"\"\"\n return shape(await _get(\"/api/hazards/reliefweb\"), detail)\n\n\n@mcp.tool()\nasync def air_quality(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Air quality (US AQI + PM2.5/PM10) sampled across major world cities —\n a proxy for industrial activity, smoke drift, and chemical release.\"\"\"\n return shape(await _get(\"/api/env/air-quality\"), detail)\n\n\n@mcp.tool()\nasync def marine_buoys(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"NDBC marine buoy observations — wave height, wind, pressure, water temp.\"\"\"\n return shape(await _get(\"/api/maritime/buoys\"), detail)\n\n\n@mcp.tool()\nasync def maritime_chokepoints(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Vessel congestion at named straits (Hormuz/Suez/Malacca/Bosphorus/…),\n derived live from the AIS union: vessel + stationary counts per chokepoint.\"\"\"\n return shape(await _get(\"/api/maritime/chokepoints\"), detail)\n\n\n@mcp.tool()\nasync def space_weather(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"SWPC space weather — solar X-ray flares, active alerts, and the auroral\n oval. The causal layer under GPS/HF degradation.\"\"\"\n return shape(await _get(\"/api/weather/swpc/space\"), detail)\n\n\n@mcp.tool()\nasync def power_plants(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"WRI global power plants (≥200 MW) — name, fuel, capacity, country. Static\n energy-infra targets to correlate strikes/outages against.\"\"\"\n return shape(await _get(\"/api/infra/powerplants\"), detail)\n\n\n@mcp.tool()\nasync def aviation_sigmet(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"AIRMET/SIGMET hazard polygons (turbulence, icing, IFR, volcanic ash,\n convective) constraining the aircraft layer.\"\"\"\n return shape(await _get(\"/api/aviation/sigmet\"), detail)\n\n\n# ── deep analysis (DeepSeek reasoner, Ollama fallback) ────────────────────────\n\n_SYS_PROMPT = (\n \"You are a GEOINT analyst working a live open-source intelligence console. \"\n \"You are given distilled JSON from live ADS-B (aircraft), AIS (vessels), a \"\n \"GPS-jamming layer (ADS-B NACp/NIC, GPSJam method), and a cross-domain \"\n \"fusion engine whose `incident_brief` already chains co-located signals into \"\n \"ranked, cited INCIDENTS — lead your analysis from those incidents, then use \"\n \"the raw situation/area data to corroborate or challenge them. \"\n \"Reason ONLY over the provided JSON. Cite concrete numbers and IDs. Flag: \"\n \"GPS jamming/spoofing footprints, emergency squawks (7500 hijack / 7600 \"\n \"radio-fail / 7700 general), dark vessels, abnormal traffic density, and \"\n \"military activity. Be concise and structured: (1) ASSESSMENT (2 lines), \"\n \"(2) KEY FINDINGS (bullets with numbers), (3) RECOMMENDED FOLLOW-UP \"\n \"queries. If the data is thin, say so plainly — do not invent.\"\n)\n\n\n@mcp.tool()\nasync def deep_analyze(\n question: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 250.0,\n model: str | None = None,\n tier: str = \"reason\",\n) -> dict[str, Any]:\n \"\"\"Have a reasoning model reason deeply over the live intel.\n\n Gathers the relevant data (global situation, plus a focused area bundle +\n jamming + anomalies when lat/lon given) and feeds it to a real reasoning\n model — DeepSeek (``deepseek-reasoner``) when configured, else a local\n Ollama model. Heavy reasoning runs off-context; only the conclusion enters\n the agent's context. Falls back to returning the raw structured data\n (analysis=null) if no LLM backend is reachable.\n\n Args:\n question: what to analyse (e.g. \"Is there coordinated GPS jamming near\n the Baltic, and which aircraft are affected?\").\n lat, lon, radius_nm: optional area to focus on (loaded PRIMARY).\n model: optional Ollama model override used only on the Ollama fallback.\n tier: ``reason`` (deepseek-reasoner, default — judgement) or ``fast``\n (deepseek-chat — quicker, shallower).\n \"\"\"\n import json as _json\n\n from app import llm # noqa: PLC0415\n\n # 1) gather context. Pull detail='long' internally — the reasoner runs\n # off-context with its own large window, so it should judge the FULL fused\n # picture, not the token-frugal digest the agent-facing default returns.\n context: dict[str, Any] = {\n \"question\": question,\n \"situation\": await get_situation(detail=\"long\"),\n }\n context[\"incident_brief\"] = await intel_brief(\n lat=lat, lon=lon, radius_nm=radius_nm,\n window_hours=12.0 if lat is None else 6.0,\n detail=\"long\",\n )\n if lat is not None and lon is not None:\n context[\"focus_area\"] = await focus_area(lat, lon, radius_nm, detail=\"long\")\n\n # 2) reason off-context (DeepSeek → Ollama → raw data). Cap the wait so a\n # slow upstream can't hang the tool for the 180 s default — the fast tier\n # should answer well under 60 s, the deeper reason tier under 90 s.\n timeout_s = 60.0 if (tier or \"\").lower() == \"fast\" else 90.0\n res = await llm.complete(\n _SYS_PROMPT,\n f\"QUESTION: {question}\\n\\nLIVE INTEL JSON:\\n\"\n + _json.dumps(context, separators=(\",\", \":\"))[:60000],\n tier=tier,\n temperature=0.2,\n max_tokens=2048,\n timeout_s=timeout_s,\n ollama_model=model or \"\",\n )\n if not res.ok:\n return {\n \"analysis\": None,\n \"model\": res.model,\n \"backend\": res.backend,\n \"note\": f\"No LLM backend reachable ({res.error}). Returning structured \"\n \"intel for you to analyse directly. Configure DEEPSEEK_API_KEY / the \"\n \"opencode DeepSeek key, or `ollama pull qwen2.5:3b`.\",\n \"data\": context,\n }\n\n return {\n \"analysis\": res.text,\n \"model\": res.model,\n \"backend\": res.backend,\n \"focused_on\": (\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm} if lat is not None else \"global\"\n ),\n \"data_summary\": {\n \"aircraft_total\": context[\"situation\"].get(\"aircraft\", {}).get(\"total\"),\n \"jamming_high\": context[\"situation\"].get(\"gps_jamming\", {}).get(\"high\"),\n },\n }\n\n\n# ── news intelligence (debias / fact-check) ──────────────────────────────────\n\n\n@mcp.tool()\nasync def news_analysis() -> dict[str, Any]:\n \"\"\"Cross-source, debiased world-news intelligence.\n\n Scrapes ~12 outlets (BBC, Reuters, AP, Al Jazeera, Guardian, CNN, Fox, …),\n then a reasoning model strips bias/propaganda and separates VERIFIED FACTS\n (corroborated by ≥2 independent outlets) from ATTRIBUTED CLAIMS and\n rhetoric. A leader promising \"the war will end soon\" is flagged as rhetoric,\n never reported as fact. Returns events with neutral_summary, verified_facts,\n attributed_claims, bias_flags, propaganda_techniques, rhetoric_flags and a\n confidence. May take ~30s when the cache is cold (it reasons over the feed).\n \"\"\"\n return await _get(\"/api/news/analysis\")\n\n\n@mcp.tool()\nasync def fact_check(claim: str) -> dict[str, Any]:\n \"\"\"Adjudicate one free-text claim against current world-news headlines.\n\n Returns ``{verdict: true|false|misleading|unverified, reasoning,\n supporting_sources, confidence}``. Use it to sanity-check a statement before\n treating it as fact.\n \"\"\"\n return await _get(\"/api/news/factcheck\", {\"claim\": claim})\n\n\n@mcp.tool()\nasync def aoi_imagery(\n before: str,\n after: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_km: float = 5.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n window_days: int = 30,\n) -> dict[str, Any]:\n \"\"\"Building imagery for a location at two dates — set time + place directly.\n\n Give a location (lat/lon + radius_km, OR an explicit min/max bbox) and a\n before + after date (YYYY-MM-DD). Returns what imagery is available for each\n date WITHOUT downloading: Maxar Open Data VHR (~0.3-0.5 m, event-gated — only\n where a disaster/conflict event covers the AOI) and Sentinel (10 m, global,\n any date). `best_source` says which to use. Reconstruction downloads the\n scenes to a temp dir on demand and discards them — nothing is stored.\n\n Args:\n before, after: dates as YYYY-MM-DD.\n lat, lon, radius_km: centre + radius (km) of the AOI.\n min_lon/min_lat/max_lon/max_lat: explicit bbox (overrides lat/lon).\n window_days: ± days around each date to search Maxar (events are sparse).\n \"\"\"\n params: dict[str, Any] = {\n \"before\": before,\n \"after\": after,\n \"radius_km\": radius_km,\n \"window_days\": window_days,\n }\n if None not in (min_lon, min_lat, max_lon, max_lat):\n params.update(min_lon=min_lon, min_lat=min_lat, max_lon=max_lon, max_lat=max_lat)\n else:\n params.update(lat=lat, lon=lon)\n return await _get(\"/api/imagery/aoi\", params)\n\n\n# ── entrypoint ─────────────────────────────────────────────────────────────────\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=\"OSINT GEOINT MCP server\")\n parser.add_argument(\"--http\", action=\"store_true\", help=\"serve over streamable-HTTP\")\n parser.add_argument(\"--port\", type=int, default=8765, help=\"HTTP port (with --http)\")\n parser.add_argument(\"--host\", default=\"127.0.0.1\", help=\"HTTP host (with --http)\")\n parser.add_argument(\n \"--list-tools\", action=\"store_true\", help=\"print registered tools and exit\"\n )\n args = parser.parse_args()\n\n if args.list_tools:\n tools = asyncio.run(mcp.list_tools())\n print(f\"osint-geoint MCP — {len(tools)} tools:\")\n for t in tools:\n summary = (t.description or \"\").split(\"\\n\", 1)[0]\n print(f\" • {t.name}: {summary}\")\n return\n\n if args.http:\n mcp.settings.host = args.host\n mcp.settings.port = args.port\n mcp.run(transport=\"streamable-http\")\n else:\n mcp.run() # stdio\n\n\nif __name__ == \"__main__\":\n main()\n"}, "be1865227a93f78738c976b81a3dd8324fc9d9bf": {"t": 1785765047.4979475, "n": 39230, "h": "9bded5dd9e2b40ff", "c": "\"\"\"OSINT GEOINT — Model Context Protocol server.\n\nExposes the live OSINT console as tools an AI agent can call. Every tool is a\nthin async wrapper over the ``/api/intel/*`` HTTP surface (``app.routes.intel``)\nso the agent shares the SAME warm in-process snapshot, AOI priority loader, and\nfusion engine the globe uses — no second upstream fan-out, no rate-limit blowup.\n\nDesign principles (the MCP brief):\n- **Context-safe.** Tools return distilled JSON (counts, grids, ≤50-item\n samples), never raw feature dumps. An agent can sweep the whole planet for a\n few hundred tokens.\n- **Area-primary.** ``focus_area`` marks a region PRIMARY: a dedicated fresh\n fetch + ongoing priority refresh, independent of global rate limits. Other\n regions keep streaming from the global snapshot.\n- **Think deeper, off-context.** ``deep_analyze`` pulls the relevant intel JSON\n and hands it to a real reasoning model — DeepSeek (``deepseek-reasoner``) when\n configured, else a local Ollama model — so heavy analysis happens off the\n agent's context and only the conclusion returns.\n\nRun:\n python -m app.mcp_server # stdio (Claude Code / Desktop / SDK)\n python -m app.mcp_server --http --port 8765 # streamable-HTTP\n python -m app.mcp_server --list-tools # introspect, for CI / verification\n\nConfig (env or apps/api/.env): API_BASE, API_KEY, DEEPSEEK_API_KEY (or the\nopencode DeepSeek key), OLLAMA_HOST, OLLAMA_MODEL.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport atexit\nimport os\nimport subprocess\nimport sys\nimport time\nfrom typing import Any\nfrom urllib.parse import quote, urlparse\n\nimport httpx\nfrom mcp.server.fastmcp import FastMCP\nfrom mcp.server.streamable_http_manager import StreamableHTTPSessionManager\nfrom mcp.server.transport_security import TransportSecuritySettings\nfrom starlette.routing import Route\n\nfrom app.config import get_settings\nfrom app.intel.shape import normalize_detail, shape\n\nmcp = FastMCP(\n \"osint-geoint\",\n instructions=(\n \"Live geospatial intelligence over open ADS-B (aircraft), AIS (vessels), \"\n \"GPS-jamming (ADS-B NACp/NIC), Sentinel-1 SAR dark vessels, geocoded \"\n \"events, and a cross-domain fusion engine. Workflow: get_situation() to \"\n \"orient -> intel_brief() for ranked, cited cross-domain INCIDENTS (the \"\n \"fused picture, global or scoped) -> focus_area(lat,lon,radius_nm) to \"\n \"load an incident's region PRIMARY -> query_vessels / gps_jamming / \"\n \"query_aircraft to drill into its evidence -> deep_analyze() to have a \"\n \"reasoning model judge it. intel_brief is the headline tool: it chains \"\n \"signals into incidents so you don't correlate raw layers by hand.\\n\\n\"\n \"CONTEXT BUDGET: most tools take detail='short'|'long'. 'short' (the \"\n \"default) is a token-frugal digest — headline counts plus the top few \"\n \"items of each list, with `*_total` giving the true size — ideal for \"\n \"orientation and broad sweeps. Switch to detail='long' only once you've \"\n \"picked an incident/area worth the full, comprehensive bundle.\"\n ),\n)\n\n\n# ── hosted mount (streamable-HTTP at /mcp of the FastAPI app) ──────────────────\n# The agent-facing endpoint. Mounting the MCP into the SAME uvicorn process the\n# globe runs lets every tool share the warm in-process snapshot + fusion engine\n# (the tools still self-call /api/intel/* over localhost, authenticated by the\n# static API_KEY). The Velocity gateway Worker proxies https://<host>/mcp here,\n# forwarding the caller's Supabase token; the backend's ApiKeyMiddleware gates it\n# like any other non-public route, so no MCP-specific auth is needed.\n\n\nclass _MCPASGIApp:\n \"\"\"Raw-ASGI adapter for the streamable-HTTP session manager.\n\n A plain function endpoint would be wrapped by Starlette as a\n request/response handler; a class instance is left as a raw ASGI app, which\n is what the transport needs (it negotiates SSE itself).\n \"\"\"\n\n def __init__(self, manager: StreamableHTTPSessionManager) -> None:\n self._manager = manager\n\n async def __call__(self, scope: Any, receive: Any, send: Any) -> None:\n await self._manager.handle_request(scope, receive, send)\n\n\ndef build_mcp_mount() -> tuple[list[Route], StreamableHTTPSessionManager]:\n \"\"\"Return (routes, session_manager) to serve this server at ``/mcp``.\n\n A FRESH manager per call — ``StreamableHTTPSessionManager.run()`` is one-shot\n (it raises if entered twice), so every FastAPI app instance, including each\n test app, needs its own. Usage::\n\n routes, mgr = build_mcp_mount()\n app.router.routes.extend(routes) # in the app factory\n async with mgr.run(): ... yield # in the app lifespan\n\n Served as exact ``Route``s (``/mcp`` and ``/mcp/``), NOT a ``Mount`` — a\n mount 307-redirects ``/mcp`` → ``/mcp/`` with a Location built from the\n request host, which through the gateway Worker would point a client at the\n bare backend origin. DNS-rebinding protection is disabled: the endpoint is\n reached server-to-server through the Worker (the Host header is the\n deployment origin, not a browser tab), and the Supabase-token check in\n ``app.auth.ApiKeyMiddleware`` is the real gate.\n \"\"\"\n manager = StreamableHTTPSessionManager(\n app=mcp._mcp_server,\n json_response=False,\n stateless=False,\n security_settings=TransportSecuritySettings(\n enable_dns_rebinding_protection=False\n ),\n )\n endpoint = _MCPASGIApp(manager)\n routes = [Route(\"/mcp\", endpoint), Route(\"/mcp/\", endpoint)]\n return routes, manager\n\n\n# ── backend HTTP plumbing ─────────────────────────────────────────────────────\n\n\ndef _api_base() -> str:\n return (os.environ.get(\"API_BASE\") or get_settings().api_base).rstrip(\"/\")\n\n\n# Cache one minted internal token per secret until ~60 s before it expires, so a\n# burst of tool calls re-signs at most once a minute instead of per request.\n_INTERNAL_JWT_TTL_S = 600 # 10 min token lifetime\n_minted_jwt: tuple[str, float] | None = None # (token, wall-clock expiry)\n\n\ndef _mint_internal_jwt(secret: str) -> str | None:\n \"\"\"Mint a short-lived HS256 token the backend's ``app.auth`` accepts.\n\n In prod the gate is Supabase-JWT-only (no static ``API_KEY``); a server-to-\n server hop carries no browser session, so we sign our own. ``_verify_hs256``\n requires an HS256 signature over the JWT secret, ``role==\"authenticated\"``,\n and an unexpired ``exp`` — we also set the standard Supabase ``aud``/``sub``/\n ``iat`` so the token is well-formed. Cached until ~60 s before ``exp``.\n \"\"\"\n global _minted_jwt\n now = time.time()\n if _minted_jwt is not None:\n token, expiry = _minted_jwt\n if expiry - 60 > now:\n return token\n try:\n import jwt # noqa: PLC0415 — pyjwt, the lib app.auth's HS256 check mirrors\n except Exception: # noqa: BLE001 — pyjwt missing → no Authorization header\n return None\n exp = int(now) + _INTERNAL_JWT_TTL_S\n claims = {\n \"role\": \"authenticated\", # the claim app.auth._verify_hs256 demands\n \"aud\": \"authenticated\",\n \"sub\": \"osint-mcp-internal\",\n \"iat\": int(now),\n \"exp\": exp,\n }\n try:\n token = jwt.encode(claims, secret, algorithm=\"HS256\")\n except Exception: # noqa: BLE001 — encode failure → degrade to no auth header\n return None\n _minted_jwt = (token, float(exp))\n return token\n\n\ndef _headers() -> dict[str, str]:\n s = get_settings()\n key = os.environ.get(\"API_KEY\") or s.api_key\n if key:\n return {\"X-API-Key\": key}\n # No static key (prod): if a Supabase JWT secret is configured, mint an\n # internal token so the self-hop to /api/intel/* authenticates. Otherwise\n # (fully open local box) send nothing, as before.\n secret = s.supabase_jwt_secret\n if secret:\n token = _mint_internal_jwt(secret)\n if token:\n return {\"Authorization\": f\"Bearer {token}\"}\n return {}\n\n\n# ── backend auto-start ────────────────────────────────────────────────────────\n# The MCP server is a thin client over the OSINT backend. If that backend is\n# not running, every tool errors and a driving agent can spin in a retry loop.\n# So on the first tool call we make sure a backend exists: reuse one already\n# listening, else spawn uvicorn ourselves (localhost only) and wait — bounded —\n# for it to come up. Set OSINT_MCP_NO_AUTOSTART=1 to disable.\n\n_BACKEND_LOCK = asyncio.Lock()\n_BACKEND_READY = False\n_BACKEND_PROC: subprocess.Popen[bytes] | None = None\n_AUTOSTART_WAIT_S = 60\n\n\ndef _is_localhost(url: str) -> bool:\n host = (urlparse(url).hostname or \"\").lower()\n return host in {\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"::1\"}\n\n\ndef _api_port() -> int:\n return urlparse(_api_base()).port or 8000\n\n\ndef _apps_api_dir() -> str:\n import app # noqa: PLC0415\n\n return os.path.dirname(os.path.dirname(os.path.abspath(app.__file__)))\n\n\nasync def _backend_healthy(timeout_s: float = 2.0) -> bool:\n try:\n async with httpx.AsyncClient(timeout=timeout_s) as c:\n r = await c.get(_api_base() + \"/api/intel/sources\", headers=_headers())\n return r.status_code == 200\n except Exception:\n return False\n\n\ndef _spawn_uvicorn(cmd: list[str]) -> subprocess.Popen[bytes]:\n \"\"\"Sync spawn (Popen returns immediately; isolated from the async path).\"\"\"\n return subprocess.Popen(\n cmd,\n cwd=_apps_api_dir(),\n stdout=subprocess.DEVNULL,\n stderr=subprocess.DEVNULL,\n env=dict(os.environ),\n )\n\n\ndef _terminate_backend() -> None:\n proc = _BACKEND_PROC\n if proc is not None and proc.poll() is None:\n proc.terminate()\n\n\nasync def _prewarm() -> None:\n \"\"\"Kick the global ADS-B snapshot so the first get_situation isn't slow.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=120.0) as c:\n await c.get(_api_base() + \"/api/adsb/global\", headers=_headers())\n except Exception:\n pass\n\n\nasync def _ensure_backend() -> None:\n \"\"\"Idempotent: guarantee a reachable backend before a tool runs.\"\"\"\n global _BACKEND_READY, _BACKEND_PROC\n if _BACKEND_READY:\n return\n async with _BACKEND_LOCK:\n if _BACKEND_READY:\n return\n if await _backend_healthy():\n _BACKEND_READY = True\n return\n # Only auto-start a LOCAL backend, and only if not opted out.\n if os.environ.get(\"OSINT_MCP_NO_AUTOSTART\") or not _is_localhost(_api_base()):\n return\n # Spawn at most ONCE per process. If a prior attempt didn't come up,\n # don't re-spawn (avoids stacking uvicorns / re-waiting every call) —\n # just let later calls re-check health in case it's still warming.\n if _BACKEND_PROC is not None:\n return\n cmd = [\n sys.executable, \"-m\", \"uvicorn\", \"app.main:app\",\n \"--host\", \"127.0.0.1\", \"--port\", str(_api_port()), \"--log-level\", \"warning\",\n ]\n try:\n _BACKEND_PROC = _spawn_uvicorn(cmd)\n except Exception:\n return # can't spawn (e.g. uvicorn missing) — tools report cleanly\n atexit.register(_terminate_backend)\n for _ in range(_AUTOSTART_WAIT_S):\n await asyncio.sleep(1.0)\n if await _backend_healthy():\n _BACKEND_READY = True\n asyncio.create_task(_prewarm()) # warm snapshot off the hot path\n return\n # Did not come up in time; leave _BACKEND_READY False so the next tool\n # call retries once more rather than looping on a dead spawn.\n\n\nasync def _get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"GET an intel endpoint. Returns parsed JSON or a structured error dict —\n never raises, so a tool call always yields something the agent can read.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in (params or {}).items() if v is not None}\n # Generous ceiling: the backend's FIRST global call cold-starts the ADS-B\n # snapshot (firehose fetch + backoff) and can take up to ~75s. After that\n # every call is instant. A short timeout here would mis-report a healthy\n # backend as unreachable on the agent's very first query.\n try:\n async with httpx.AsyncClient(timeout=90.0) as client:\n r = await client.get(url, params=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\n \"error\": \"backend_unreachable\",\n \"detail\": str(exc),\n \"url\": url,\n \"hint\": \"Backend auto-start did not come up in time. It may still be \"\n \"warming — retry once. Or start it manually: uv run --project \"\n f\"{_apps_api_dir()} uvicorn app.main:app --port {_api_port()}\",\n }\n if r.status_code != 200:\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\n# ── tools ─────────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def get_situation(detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Global situational snapshot — the cheap first call to orient.\n\n Returns total aircraft (airborne/ground, by category), GNSS-degraded count,\n active emergency squawks, the worst GPS-jamming cells worldwide, tracked\n vessel counts by category, and recent fusion-alert counts. A few hundred\n tokens describing the whole planet.\n\n detail='short' (default) trims the jamming/vessel/alert samples to their\n top few; detail='long' returns the full situation bundle.\"\"\"\n return shape(await _get(\"/api/intel/situation\"), detail)\n\n\n@mcp.tool()\nasync def focus_area(\n lat: float,\n lon: float,\n radius_nm: float = 200.0,\n label: str | None = None,\n cell_deg: float = 1.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Load a region PRIMARY and return a full intel bundle for it in one call.\n\n This is the headline tool. It triggers a DEDICATED, always-fresh fetch for\n just this area (bypassing global rate limits) and registers it for ongoing\n priority refresh. The bundle contains: fresh aircraft summary + sample,\n aircraft-density grid, GPS-jamming assessment, vessels, and fused anomalies\n (emergencies, jamming hotspots, dark vessels, alerts) with a threat level.\n\n Args:\n lat, lon: centre of the area of interest.\n radius_nm: radius in nautical miles (1–250; default 200).\n label: optional human name for the AOI (e.g. \"Kaliningrad\").\n cell_deg: density grid cell size in degrees (0.1–10; default 1.0).\n \"\"\"\n data = await _get(\n \"/api/intel/area\",\n {\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"label\": label,\n \"primary\": True,\n \"cell_deg\": cell_deg,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def aircraft_density(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n cell_deg: float = 1.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Aircraft density over an area as a grid of cells (count, by category,\n GNSS-degraded per cell) plus totals, peak cell, and in-area vessel count.\n\n Give either a centre (lat, lon [, radius_nm]) or an explicit bbox\n (min_lon, min_lat, max_lon, max_lat). cell_deg sets grid resolution.\"\"\"\n data = await _get(\n \"/api/intel/density\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat, \"cell_deg\": cell_deg,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def gps_jamming(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"GPS/GNSS jamming assessment (GPSJam method: ADS-B NACp<8 / NIC<7 binned\n into 1° cells). Returns flagged cells ranked by severity, counts of\n high/medium cells, and a sample of affected aircraft. Omit all coordinates\n for a global view; otherwise pass a centre or a bbox to scope it.\"\"\"\n data = await _get(\n \"/api/intel/jamming\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def query_aircraft(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n category: str | None = None,\n squawk: str | None = None,\n callsign_contains: str | None = None,\n min_alt_m: float | None = None,\n max_alt_m: float | None = None,\n emergency: bool | None = None,\n gnss_degraded: bool | None = None,\n on_ground: bool | None = None,\n limit: int = 50,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Filtered aircraft query against the live snapshot. Returns matched_total\n + a capped list of compact records.\n\n category ∈ airliner|private|helicopter|glider|military|emergency.\n Combine with area (centre or bbox), squawk, callsign_contains, altitude\n band (metres), emergency / gnss_degraded / on_ground booleans. limit ≤200.\"\"\"\n data = await _get(\n \"/api/intel/aircraft\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"category\": category, \"squawk\": squawk,\n \"callsign_contains\": callsign_contains,\n \"min_alt_m\": min_alt_m, \"max_alt_m\": max_alt_m,\n \"emergency\": emergency, \"gnss_degraded\": gnss_degraded,\n \"on_ground\": on_ground, \"limit\": limit,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def lookup_aircraft(ident: str) -> dict[str, Any]:\n \"\"\"Look up one aircraft by ICAO24 hex (exact) or callsign (substring).\n Returns its current state, registration, an integrity/threat assessment,\n and the latest server-held fix.\"\"\"\n # quote(): ident is agent-supplied free text going into a URL path —\n # escape it so \"x/../y\" style values cannot re-route the request.\n return await _get(f\"/api/intel/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def query_vessels(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n dark_only: bool = False,\n limit: int = 50,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Vessels (AIS) in an area, classified (cargo/tanker/fishing/passenger/\n military/sailing/pleasure/tug). dark_only=True returns only dark-vessel\n candidates (moving with no static identity). Scoped by centre or bbox.\"\"\"\n data = await _get(\n \"/api/intel/vessels\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"dark_only\": dark_only, \"limit\": limit,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def anomalies(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Fused anomaly report for an area (or global if no coords): emergency\n aircraft, GPS-jamming hotspots, dark-vessel candidates, recent fusion\n alerts, plus a triage threat_level (low|elevated|high) and score.\"\"\"\n data = await _get(\n \"/api/intel/anomalies\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def intel_brief(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n link_km: float = 50.0,\n window_hours: float = 6.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Cross-domain INCIDENT brief — the headline analytic tool.\n\n Fuses the raw layers into ranked, cited INCIDENTS instead of returning\n signals you have to correlate yourself. An incident is a CONVERGENCE: signals\n within ``link_km`` across >=2 domains (GPS-jamming, dark/AIS-off vessels,\n military air, AIS gaps, emergencies, geocoded events, quakes) — or a single\n critical/high signal. Each incident carries a rule-based narrative, a\n threat_level, the contributing evidence (with IDs), and recommended follow-up\n queries. Omit coordinates for a global brief; pass a centre+radius or bbox to\n scope it. Start here, then drill into an incident's centroid with\n query_vessels / gps_jamming / deep_analyze.\n \"\"\"\n data = await _get(\n \"/api/intel/brief\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"link_km\": link_km, \"window_hours\": window_hours,\n },\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def detect_deception(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Denial & deception — \"am I being fed?\". Flags MANIPULATED tracks distinct\n from jamming: AIS duplicate-MMSI (one identity, two hulls) and impossible\n teleports; ADS-B GPS spoofing (many aircraft snapped to one false position)\n and kinematic position-injection. Run before trusting a feed in a contested\n area. Omit coords for global.\"\"\"\n data = await _get(\n \"/api/intel/deception\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def locate_emitter(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Estimate a GPS jammer/spoofer LOCATION from the degraded-ADS-B footprint\n (severity-weighted centroid + CEP + confidence). Turns \"jamming somewhere\n here\" into \"emitter ~here ±N km\". Footprint-centroid estimate (~tens of km),\n not RF direction-finding — stated in the response. Scope with lat/lon.\"\"\"\n data = await _get(\n \"/api/intel/emitter\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def area_baseline(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Is this normal? Current vessel / dark-vessel / jamming / military counts\n z-scored against a rolling baseline, with anomalies called out (e.g. \"dark\n vessels +5σ\", \"traffic -3σ\"). Global uses the background sampler; polling an\n AOI repeatedly builds that area's baseline. Distinguishes a real shift from\n a normal day.\"\"\"\n data = await _get(\n \"/api/intel/baseline\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\n@mcp.tool()\nasync def whats_changed(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Standing watch — what CHANGED since the last check, not the full picture.\n\n Returns incidents that are NEW, ESCALATED, DE-ESCALATED, or RESOLVED. Global\n (no coords) reflects the background watch loop (recomputed ~every 60s); an\n AOI (lat/lon) diffs against YOUR previous whats_changed call for that area,\n so you can poll one region and be told only what moved. Use this to monitor\n instead of re-reading the whole brief each time.\n \"\"\"\n data = await _get(\n \"/api/intel/watch\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n return shape(data, detail)\n\n\ndef _compact_points(points: list[dict[str, Any]], max_points: int) -> list[dict[str, Any]]:\n \"\"\"Downsample one incident's observation series to its meaningful shape.\n\n Keeps the first + last point and every point whose threat ``level`` differs\n from its predecessor (a flat run of identical (level, score) is noise on a\n timeline). If transitions alone still exceed ``max_points`` it strides them\n down. Each kept point is shrunk to ``[t, level, score]`` (a list, not a\n verbose dict) so the wire form is a fraction of the size.\"\"\"\n if not points:\n return []\n keep: list[dict[str, Any]] = [points[0]]\n prev = points[0].get(\"level\")\n for p in points[1:-1]:\n if p.get(\"level\") != prev:\n keep.append(p)\n prev = p.get(\"level\")\n if len(points) > 1:\n keep.append(points[-1])\n if len(keep) > max_points:\n # Stride the kept transitions, but always retain the endpoints.\n step = (len(keep) - 1) / (max_points - 1)\n idx = sorted({0, *(round(i * step) for i in range(max_points)), len(keep) - 1})\n keep = [keep[i] for i in idx][:max_points]\n return [[p.get(\"t\"), p.get(\"level\"), p.get(\"score\")] for p in keep]\n\n\ndef _compact_history(data: dict[str, Any], limit: int, max_points: int) -> dict[str, Any]:\n \"\"\"Cap + condense the incident-history payload so the default call fits well\n under the MCP response token cap.\n\n The backend route returns EVERY incident in the window, each with its full\n per-snapshot ``points`` series and full ``narrative`` — ~89 KB at the\n default 6 h, which overflows the cap and hard-errors the tool. We keep the\n most-active incidents (the route already sorts by point-count desc), compact\n each one's series, trim the narrative, and report an honest \"showing N of M\".\n \"\"\"\n incidents = data.get(\"incidents\")\n if not isinstance(incidents, list):\n return data # error payload or unexpected shape — pass through untouched\n total = len(incidents)\n kept = incidents[:limit]\n compact: list[dict[str, Any]] = []\n for inc in kept:\n narrative = inc.get(\"narrative\")\n if isinstance(narrative, str) and len(narrative) > 240:\n narrative = narrative[:237] + \"…\"\n compact.append(\n {\n \"key\": inc.get(\"key\"),\n \"domains\": inc.get(\"domains\"),\n \"centroid\": inc.get(\"centroid\"),\n \"narrative\": narrative,\n # series is [[t, level, score], …] — compact list form\n \"series\": _compact_points(inc.get(\"points\") or [], max_points),\n }\n )\n out = {\n \"scope\": data.get(\"scope\"),\n \"window_hours\": data.get(\"window_hours\"),\n \"snapshots\": data.get(\"snapshots\"),\n \"incident_count\": total,\n \"returned\": len(compact),\n \"truncated\": total > len(compact),\n \"incidents\": compact,\n }\n if out[\"truncated\"]:\n out[\"note\"] = (\n f\"showing {len(compact)} of {total} incidents (most-active first); \"\n \"raise `limit` or scope with lat/lon/hours for more\"\n )\n return out\n\n\n@mcp.tool()\nasync def incident_history(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n hours: float = 6.0,\n limit: int = 25,\n max_incidents: int | None = None,\n detail: str = \"short\",\n) -> dict[str, Any]:\n \"\"\"Timeline of how each incident built up over the recent window — per\n incident, a compact ``series`` of ``[time, threat_level, score]`` points.\n Reveals sequence (e.g. jamming first, then dark vessels, then a reported\n event). Global uses the background watch history; an AOI uses your prior\n watch calls.\n\n The full window can hold many incidents; this returns the ``limit`` most\n active ones (default 25, alias ``max_incidents``) with each series\n downsampled to its threat-level transitions, and reports\n ``incident_count`` / ``returned`` / ``truncated`` so you know if more exist.\n Raise ``limit`` or narrow with lat/lon/hours to drill in.\n \"\"\"\n cap = max_incidents if max_incidents is not None else limit\n cap = max(1, min(int(cap), 200))\n data = await _get(\n \"/api/intel/incident-history\",\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm, \"hours\": hours},\n )\n # detail='long' keeps a denser timeline (more transition points) per incident.\n max_points = 40 if normalize_detail(detail) == \"long\" else 12\n return _compact_history(data, cap, max_points=max_points)\n\n\n@mcp.tool()\nasync def vessel_dossier(mmsi: int | str, detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one vessel (MMSI): recent track, AIS gaps,\n derived speed profile (loiter / transit / loiter-then-dash), area covered,\n which live incidents it appears in, and a behaviour assessment. The track is\n the store's ~1h retention window.\"\"\"\n # MMSI is numeric; agents pass it as an int or a string — accept both.\n return shape(await _get(f\"/api/intel/dossier/vessel/{quote(str(mmsi), safe='')}\"), detail)\n\n\n@mcp.tool()\nasync def aircraft_dossier(ident: str, detail: str = \"short\") -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one aircraft (ICAO24 hex or callsign): recent\n track, gaps, derived speed profile, GNSS-integrity, emergency/military flags,\n and which live incidents it appears in.\"\"\"\n return shape(await _get(f\"/api/intel/dossier/aircraft/{quote(ident, safe='')}\"), detail)\n\n\n@mcp.tool()\nasync def list_focus_areas() -> dict[str, Any]:\n \"\"\"List the priority areas currently loaded PRIMARY (with fetch stats and\n whether each is served by a dedicated fetch or the degraded snapshot\n fallback).\"\"\"\n return await _get(\"/api/intel/aois\")\n\n\n@mcp.tool()\nasync def data_sources() -> dict[str, Any]:\n \"\"\"Which feeds are always-on vs key-gated, and the configured Ollama\n host/model. Use to explain coverage gaps (e.g. fires need a FIRMS key).\"\"\"\n return await _get(\"/api/intel/sources\")\n\n\n# ── deep analysis (DeepSeek reasoner, Ollama fallback) ────────────────────────\n\n_SYS_PROMPT = (\n \"You are a GEOINT analyst working a live open-source intelligence console. \"\n \"You are given distilled JSON from live ADS-B (aircraft), AIS (vessels), a \"\n \"GPS-jamming layer (ADS-B NACp/NIC, GPSJam method), and a cross-domain \"\n \"fusion engine whose `incident_brief` already chains co-located signals into \"\n \"ranked, cited INCIDENTS — lead your analysis from those incidents, then use \"\n \"the raw situation/area data to corroborate or challenge them. \"\n \"Reason ONLY over the provided JSON. Cite concrete numbers and IDs. Flag: \"\n \"GPS jamming/spoofing footprints, emergency squawks (7500 hijack / 7600 \"\n \"radio-fail / 7700 general), dark vessels, abnormal traffic density, and \"\n \"military activity. Be concise and structured: (1) ASSESSMENT (2 lines), \"\n \"(2) KEY FINDINGS (bullets with numbers), (3) RECOMMENDED FOLLOW-UP \"\n \"queries. If the data is thin, say so plainly — do not invent.\"\n)\n\n\n@mcp.tool()\nasync def deep_analyze(\n question: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 250.0,\n model: str | None = None,\n tier: str = \"reason\",\n) -> dict[str, Any]:\n \"\"\"Have a reasoning model reason deeply over the live intel.\n\n Gathers the relevant data (global situation, plus a focused area bundle +\n jamming + anomalies when lat/lon given) and feeds it to a real reasoning\n model — DeepSeek (``deepseek-reasoner``) when configured, else a local\n Ollama model. Heavy reasoning runs off-context; only the conclusion enters\n the agent's context. Falls back to returning the raw structured data\n (analysis=null) if no LLM backend is reachable.\n\n Args:\n question: what to analyse (e.g. \"Is there coordinated GPS jamming near\n the Baltic, and which aircraft are affected?\").\n lat, lon, radius_nm: optional area to focus on (loaded PRIMARY).\n model: optional Ollama model override used only on the Ollama fallback.\n tier: ``reason`` (deepseek-reasoner, default — judgement) or ``fast``\n (deepseek-chat — quicker, shallower).\n \"\"\"\n import json as _json\n\n from app import llm # noqa: PLC0415\n\n # 1) gather context. Pull detail='long' internally — the reasoner runs\n # off-context with its own large window, so it should judge the FULL fused\n # picture, not the token-frugal digest the agent-facing default returns.\n context: dict[str, Any] = {\n \"question\": question,\n \"situation\": await get_situation(detail=\"long\"),\n }\n context[\"incident_brief\"] = await intel_brief(\n lat=lat, lon=lon, radius_nm=radius_nm,\n window_hours=12.0 if lat is None else 6.0,\n detail=\"long\",\n )\n if lat is not None and lon is not None:\n context[\"focus_area\"] = await focus_area(lat, lon, radius_nm, detail=\"long\")\n\n # 2) reason off-context (DeepSeek → Ollama → raw data). Cap the wait so a\n # slow upstream can't hang the tool for the 180 s default — the fast tier\n # should answer well under 60 s, the deeper reason tier under 90 s.\n timeout_s = 60.0 if (tier or \"\").lower() == \"fast\" else 90.0\n res = await llm.complete(\n _SYS_PROMPT,\n f\"QUESTION: {question}\\n\\nLIVE INTEL JSON:\\n\"\n + _json.dumps(context, separators=(\",\", \":\"))[:60000],\n tier=tier,\n temperature=0.2,\n max_tokens=2048,\n timeout_s=timeout_s,\n ollama_model=model or \"\",\n )\n if not res.ok:\n return {\n \"analysis\": None,\n \"model\": res.model,\n \"backend\": res.backend,\n \"note\": f\"No LLM backend reachable ({res.error}). Returning structured \"\n \"intel for you to analyse directly. Configure DEEPSEEK_API_KEY / the \"\n \"opencode DeepSeek key, or `ollama pull qwen2.5:3b`.\",\n \"data\": context,\n }\n\n return {\n \"analysis\": res.text,\n \"model\": res.model,\n \"backend\": res.backend,\n \"focused_on\": (\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm} if lat is not None else \"global\"\n ),\n \"data_summary\": {\n \"aircraft_total\": context[\"situation\"].get(\"aircraft\", {}).get(\"total\"),\n \"jamming_high\": context[\"situation\"].get(\"gps_jamming\", {}).get(\"high\"),\n },\n }\n\n\n# ── news intelligence (debias / fact-check) ──────────────────────────────────\n\n\n@mcp.tool()\nasync def news_analysis() -> dict[str, Any]:\n \"\"\"Cross-source, debiased world-news intelligence.\n\n Scrapes ~12 outlets (BBC, Reuters, AP, Al Jazeera, Guardian, CNN, Fox, …),\n then a reasoning model strips bias/propaganda and separates VERIFIED FACTS\n (corroborated by ≥2 independent outlets) from ATTRIBUTED CLAIMS and\n rhetoric. A leader promising \"the war will end soon\" is flagged as rhetoric,\n never reported as fact. Returns events with neutral_summary, verified_facts,\n attributed_claims, bias_flags, propaganda_techniques, rhetoric_flags and a\n confidence. May take ~30s when the cache is cold (it reasons over the feed).\n \"\"\"\n return await _get(\"/api/news/analysis\")\n\n\n@mcp.tool()\nasync def fact_check(claim: str) -> dict[str, Any]:\n \"\"\"Adjudicate one free-text claim against current world-news headlines.\n\n Returns ``{verdict: true|false|misleading|unverified, reasoning,\n supporting_sources, confidence}``. Use it to sanity-check a statement before\n treating it as fact.\n \"\"\"\n return await _get(\"/api/news/factcheck\", {\"claim\": claim})\n\n\n@mcp.tool()\nasync def aoi_imagery(\n before: str,\n after: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_km: float = 5.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n window_days: int = 30,\n) -> dict[str, Any]:\n \"\"\"Building imagery for a location at two dates — set time + place directly.\n\n Give a location (lat/lon + radius_km, OR an explicit min/max bbox) and a\n before + after date (YYYY-MM-DD). Returns what imagery is available for each\n date WITHOUT downloading: Maxar Open Data VHR (~0.3-0.5 m, event-gated — only\n where a disaster/conflict event covers the AOI) and Sentinel (10 m, global,\n any date). `best_source` says which to use. Reconstruction downloads the\n scenes to a temp dir on demand and discards them — nothing is stored.\n\n Args:\n before, after: dates as YYYY-MM-DD.\n lat, lon, radius_km: centre + radius (km) of the AOI.\n min_lon/min_lat/max_lon/max_lat: explicit bbox (overrides lat/lon).\n window_days: ± days around each date to search Maxar (events are sparse).\n \"\"\"\n params: dict[str, Any] = {\n \"before\": before,\n \"after\": after,\n \"radius_km\": radius_km,\n \"window_days\": window_days,\n }\n if None not in (min_lon, min_lat, max_lon, max_lat):\n params.update(min_lon=min_lon, min_lat=min_lat, max_lon=max_lon, max_lat=max_lat)\n else:\n params.update(lat=lat, lon=lon)\n return await _get(\"/api/imagery/aoi\", params)\n\n\n# ── entrypoint ─────────────────────────────────────────────────────────────────\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=\"OSINT GEOINT MCP server\")\n parser.add_argument(\"--http\", action=\"store_true\", help=\"serve over streamable-HTTP\")\n parser.add_argument(\"--port\", type=int, default=8765, help=\"HTTP port (with --http)\")\n parser.add_argument(\"--host\", default=\"127.0.0.1\", help=\"HTTP host (with --http)\")\n parser.add_argument(\n \"--list-tools\", action=\"store_true\", help=\"print registered tools and exit\"\n )\n args = parser.parse_args()\n\n if args.list_tools:\n tools = asyncio.run(mcp.list_tools())\n print(f\"osint-geoint MCP — {len(tools)} tools:\")\n for t in tools:\n summary = (t.description or \"\").split(\"\\n\", 1)[0]\n print(f\" • {t.name}: {summary}\")\n return\n\n if args.http:\n mcp.settings.host = args.host\n mcp.settings.port = args.port\n mcp.run(transport=\"streamable-http\")\n else:\n mcp.run() # stdio\n\n\nif __name__ == \"__main__\":\n main()\n"}, "df294a5816a253404024108442e74f761f7bd302": {"t": 1785765049.2459242, "n": 37605, "h": "1f6924e2fa240645", "c": "\"\"\"OSINT GEOINT — Model Context Protocol server.\n\nExposes the live OSINT console as tools an AI agent can call. Every tool is a\nthin async wrapper over the ``/api/intel/*`` HTTP surface (``app.routes.intel``)\nso the agent shares the SAME warm in-process snapshot, AOI priority loader, and\nfusion engine the globe uses — no second upstream fan-out, no rate-limit blowup.\n\nDesign principles (the MCP brief):\n- **Context-safe.** Tools return distilled JSON (counts, grids, ≤50-item\n samples), never raw feature dumps. An agent can sweep the whole planet for a\n few hundred tokens.\n- **Area-primary.** ``focus_area`` marks a region PRIMARY: a dedicated fresh\n fetch + ongoing priority refresh, independent of global rate limits. Other\n regions keep streaming from the global snapshot.\n- **Think deeper, off-context.** ``deep_analyze`` pulls the relevant intel JSON\n and hands it to a real reasoning model — DeepSeek (``deepseek-reasoner``) when\n configured, else a local Ollama model — so heavy analysis happens off the\n agent's context and only the conclusion returns.\n\nRun:\n python -m app.mcp_server # stdio (Claude Code / Desktop / SDK)\n python -m app.mcp_server --http --port 8765 # streamable-HTTP\n python -m app.mcp_server --list-tools # introspect, for CI / verification\n\nConfig (env or apps/api/.env): API_BASE, API_KEY, DEEPSEEK_API_KEY (or the\nopencode DeepSeek key), OLLAMA_HOST, OLLAMA_MODEL.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport atexit\nimport os\nimport subprocess\nimport sys\nimport time\nfrom typing import Any\nfrom urllib.parse import quote, urlparse\n\nimport httpx\nfrom mcp.server.fastmcp import FastMCP\nfrom mcp.server.streamable_http_manager import StreamableHTTPSessionManager\nfrom mcp.server.transport_security import TransportSecuritySettings\nfrom starlette.routing import Route\n\nfrom app.config import get_settings\n\nmcp = FastMCP(\n \"osint-geoint\",\n instructions=(\n \"Live geospatial intelligence over open ADS-B (aircraft), AIS (vessels), \"\n \"GPS-jamming (ADS-B NACp/NIC), Sentinel-1 SAR dark vessels, geocoded \"\n \"events, and a cross-domain fusion engine. Workflow: get_situation() to \"\n \"orient -> intel_brief() for ranked, cited cross-domain INCIDENTS (the \"\n \"fused picture, global or scoped) -> focus_area(lat,lon,radius_nm) to \"\n \"load an incident's region PRIMARY -> query_vessels / gps_jamming / \"\n \"query_aircraft to drill into its evidence -> deep_analyze() to have a \"\n \"reasoning model judge it. intel_brief is the headline tool: it chains \"\n \"signals into incidents so you don't correlate raw layers by hand.\"\n ),\n)\n\n\n# ── hosted mount (streamable-HTTP at /mcp of the FastAPI app) ──────────────────\n# The agent-facing endpoint. Mounting the MCP into the SAME uvicorn process the\n# globe runs lets every tool share the warm in-process snapshot + fusion engine\n# (the tools still self-call /api/intel/* over localhost, authenticated by the\n# static API_KEY). The Velocity gateway Worker proxies https://<host>/mcp here,\n# forwarding the caller's Supabase token; the backend's ApiKeyMiddleware gates it\n# like any other non-public route, so no MCP-specific auth is needed.\n\n\nclass _MCPASGIApp:\n \"\"\"Raw-ASGI adapter for the streamable-HTTP session manager.\n\n A plain function endpoint would be wrapped by Starlette as a\n request/response handler; a class instance is left as a raw ASGI app, which\n is what the transport needs (it negotiates SSE itself).\n \"\"\"\n\n def __init__(self, manager: StreamableHTTPSessionManager) -> None:\n self._manager = manager\n\n async def __call__(self, scope: Any, receive: Any, send: Any) -> None:\n await self._manager.handle_request(scope, receive, send)\n\n\ndef build_mcp_mount() -> tuple[list[Route], StreamableHTTPSessionManager]:\n \"\"\"Return (routes, session_manager) to serve this server at ``/mcp``.\n\n A FRESH manager per call — ``StreamableHTTPSessionManager.run()`` is one-shot\n (it raises if entered twice), so every FastAPI app instance, including each\n test app, needs its own. Usage::\n\n routes, mgr = build_mcp_mount()\n app.router.routes.extend(routes) # in the app factory\n async with mgr.run(): ... yield # in the app lifespan\n\n Served as exact ``Route``s (``/mcp`` and ``/mcp/``), NOT a ``Mount`` — a\n mount 307-redirects ``/mcp`` → ``/mcp/`` with a Location built from the\n request host, which through the gateway Worker would point a client at the\n bare backend origin. DNS-rebinding protection is disabled: the endpoint is\n reached server-to-server through the Worker (the Host header is the\n deployment origin, not a browser tab), and the Supabase-token check in\n ``app.auth.ApiKeyMiddleware`` is the real gate.\n \"\"\"\n manager = StreamableHTTPSessionManager(\n app=mcp._mcp_server,\n json_response=False,\n stateless=False,\n security_settings=TransportSecuritySettings(\n enable_dns_rebinding_protection=False\n ),\n )\n endpoint = _MCPASGIApp(manager)\n routes = [Route(\"/mcp\", endpoint), Route(\"/mcp/\", endpoint)]\n return routes, manager\n\n\n# ── backend HTTP plumbing ─────────────────────────────────────────────────────\n\n\ndef _api_base() -> str:\n return (os.environ.get(\"API_BASE\") or get_settings().api_base).rstrip(\"/\")\n\n\n# Cache one minted internal token per secret until ~60 s before it expires, so a\n# burst of tool calls re-signs at most once a minute instead of per request.\n_INTERNAL_JWT_TTL_S = 600 # 10 min token lifetime\n_minted_jwt: tuple[str, float] | None = None # (token, wall-clock expiry)\n\n\ndef _mint_internal_jwt(secret: str) -> str | None:\n \"\"\"Mint a short-lived HS256 token the backend's ``app.auth`` accepts.\n\n In prod the gate is Supabase-JWT-only (no static ``API_KEY``); a server-to-\n server hop carries no browser session, so we sign our own. ``_verify_hs256``\n requires an HS256 signature over the JWT secret, ``role==\"authenticated\"``,\n and an unexpired ``exp`` — we also set the standard Supabase ``aud``/``sub``/\n ``iat`` so the token is well-formed. Cached until ~60 s before ``exp``.\n \"\"\"\n global _minted_jwt\n now = time.time()\n if _minted_jwt is not None:\n token, expiry = _minted_jwt\n if expiry - 60 > now:\n return token\n try:\n import jwt # noqa: PLC0415 — pyjwt, the lib app.auth's HS256 check mirrors\n except Exception: # noqa: BLE001 — pyjwt missing → no Authorization header\n return None\n exp = int(now) + _INTERNAL_JWT_TTL_S\n claims = {\n \"role\": \"authenticated\", # the claim app.auth._verify_hs256 demands\n \"aud\": \"authenticated\",\n \"sub\": \"osint-mcp-internal\",\n \"iat\": int(now),\n \"exp\": exp,\n }\n try:\n token = jwt.encode(claims, secret, algorithm=\"HS256\")\n except Exception: # noqa: BLE001 — encode failure → degrade to no auth header\n return None\n _minted_jwt = (token, float(exp))\n return token\n\n\ndef _headers() -> dict[str, str]:\n s = get_settings()\n key = os.environ.get(\"API_KEY\") or s.api_key\n if key:\n return {\"X-API-Key\": key}\n # No static key (prod): if a Supabase JWT secret is configured, mint an\n # internal token so the self-hop to /api/intel/* authenticates. Otherwise\n # (fully open local box) send nothing, as before.\n secret = s.supabase_jwt_secret\n if secret:\n token = _mint_internal_jwt(secret)\n if token:\n return {\"Authorization\": f\"Bearer {token}\"}\n return {}\n\n\n# ── backend auto-start ────────────────────────────────────────────────────────\n# The MCP server is a thin client over the OSINT backend. If that backend is\n# not running, every tool errors and a driving agent can spin in a retry loop.\n# So on the first tool call we make sure a backend exists: reuse one already\n# listening, else spawn uvicorn ourselves (localhost only) and wait — bounded —\n# for it to come up. Set OSINT_MCP_NO_AUTOSTART=1 to disable.\n\n_BACKEND_LOCK = asyncio.Lock()\n_BACKEND_READY = False\n_BACKEND_PROC: subprocess.Popen[bytes] | None = None\n_AUTOSTART_WAIT_S = 60\n\n\ndef _is_localhost(url: str) -> bool:\n host = (urlparse(url).hostname or \"\").lower()\n return host in {\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"::1\"}\n\n\ndef _api_port() -> int:\n return urlparse(_api_base()).port or 8000\n\n\ndef _apps_api_dir() -> str:\n import app # noqa: PLC0415\n\n return os.path.dirname(os.path.dirname(os.path.abspath(app.__file__)))\n\n\nasync def _backend_healthy(timeout_s: float = 2.0) -> bool:\n try:\n async with httpx.AsyncClient(timeout=timeout_s) as c:\n r = await c.get(_api_base() + \"/api/intel/sources\", headers=_headers())\n return r.status_code == 200\n except Exception:\n return False\n\n\ndef _spawn_uvicorn(cmd: list[str]) -> subprocess.Popen[bytes]:\n \"\"\"Sync spawn (Popen returns immediately; isolated from the async path).\"\"\"\n return subprocess.Popen(\n cmd,\n cwd=_apps_api_dir(),\n stdout=subprocess.DEVNULL,\n stderr=subprocess.DEVNULL,\n env=dict(os.environ),\n )\n\n\ndef _terminate_backend() -> None:\n proc = _BACKEND_PROC\n if proc is not None and proc.poll() is None:\n proc.terminate()\n\n\nasync def _prewarm() -> None:\n \"\"\"Kick the global ADS-B snapshot so the first get_situation isn't slow.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=120.0) as c:\n await c.get(_api_base() + \"/api/adsb/global\", headers=_headers())\n except Exception:\n pass\n\n\nasync def _ensure_backend() -> None:\n \"\"\"Idempotent: guarantee a reachable backend before a tool runs.\"\"\"\n global _BACKEND_READY, _BACKEND_PROC\n if _BACKEND_READY:\n return\n async with _BACKEND_LOCK:\n if _BACKEND_READY:\n return\n if await _backend_healthy():\n _BACKEND_READY = True\n return\n # Only auto-start a LOCAL backend, and only if not opted out.\n if os.environ.get(\"OSINT_MCP_NO_AUTOSTART\") or not _is_localhost(_api_base()):\n return\n # Spawn at most ONCE per process. If a prior attempt didn't come up,\n # don't re-spawn (avoids stacking uvicorns / re-waiting every call) —\n # just let later calls re-check health in case it's still warming.\n if _BACKEND_PROC is not None:\n return\n cmd = [\n sys.executable, \"-m\", \"uvicorn\", \"app.main:app\",\n \"--host\", \"127.0.0.1\", \"--port\", str(_api_port()), \"--log-level\", \"warning\",\n ]\n try:\n _BACKEND_PROC = _spawn_uvicorn(cmd)\n except Exception:\n return # can't spawn (e.g. uvicorn missing) — tools report cleanly\n atexit.register(_terminate_backend)\n for _ in range(_AUTOSTART_WAIT_S):\n await asyncio.sleep(1.0)\n if await _backend_healthy():\n _BACKEND_READY = True\n asyncio.create_task(_prewarm()) # warm snapshot off the hot path\n return\n # Did not come up in time; leave _BACKEND_READY False so the next tool\n # call retries once more rather than looping on a dead spawn.\n\n\nasync def _get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"GET an intel endpoint. Returns parsed JSON or a structured error dict —\n never raises, so a tool call always yields something the agent can read.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in (params or {}).items() if v is not None}\n # Generous ceiling: the backend's FIRST global call cold-starts the ADS-B\n # snapshot (firehose fetch + backoff) and can take up to ~75s. After that\n # every call is instant. A short timeout here would mis-report a healthy\n # backend as unreachable on the agent's very first query.\n try:\n async with httpx.AsyncClient(timeout=90.0) as client:\n r = await client.get(url, params=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\n \"error\": \"backend_unreachable\",\n \"detail\": str(exc),\n \"url\": url,\n \"hint\": \"Backend auto-start did not come up in time. It may still be \"\n \"warming — retry once. Or start it manually: uv run --project \"\n f\"{_apps_api_dir()} uvicorn app.main:app --port {_api_port()}\",\n }\n if r.status_code != 200:\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\n# ── tools ─────────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def get_situation() -> dict[str, Any]:\n \"\"\"Global situational snapshot — the cheap first call to orient.\n\n Returns total aircraft (airborne/ground, by category), GNSS-degraded count,\n active emergency squawks, the worst GPS-jamming cells worldwide, tracked\n vessel counts by category, and recent fusion-alert counts. A few hundred\n tokens describing the whole planet.\"\"\"\n return await _get(\"/api/intel/situation\")\n\n\n@mcp.tool()\nasync def focus_area(\n lat: float,\n lon: float,\n radius_nm: float = 200.0,\n label: str | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Load a region PRIMARY and return a full intel bundle for it in one call.\n\n This is the headline tool. It triggers a DEDICATED, always-fresh fetch for\n just this area (bypassing global rate limits) and registers it for ongoing\n priority refresh. The bundle contains: fresh aircraft summary + sample,\n aircraft-density grid, GPS-jamming assessment, vessels, and fused anomalies\n (emergencies, jamming hotspots, dark vessels, alerts) with a threat level.\n\n Args:\n lat, lon: centre of the area of interest.\n radius_nm: radius in nautical miles (1–250; default 200).\n label: optional human name for the AOI (e.g. \"Kaliningrad\").\n cell_deg: density grid cell size in degrees (0.1–10; default 1.0).\n \"\"\"\n return await _get(\n \"/api/intel/area\",\n {\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"label\": label,\n \"primary\": True,\n \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def aircraft_density(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Aircraft density over an area as a grid of cells (count, by category,\n GNSS-degraded per cell) plus totals, peak cell, and in-area vessel count.\n\n Give either a centre (lat, lon [, radius_nm]) or an explicit bbox\n (min_lon, min_lat, max_lon, max_lat). cell_deg sets grid resolution.\"\"\"\n return await _get(\n \"/api/intel/density\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat, \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def gps_jamming(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"GPS/GNSS jamming assessment (GPSJam method: ADS-B NACp<8 / NIC<7 binned\n into 1° cells). Returns flagged cells ranked by severity, counts of\n high/medium cells, and a sample of affected aircraft. Omit all coordinates\n for a global view; otherwise pass a centre or a bbox to scope it.\"\"\"\n return await _get(\n \"/api/intel/jamming\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def query_aircraft(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n category: str | None = None,\n squawk: str | None = None,\n callsign_contains: str | None = None,\n min_alt_m: float | None = None,\n max_alt_m: float | None = None,\n emergency: bool | None = None,\n gnss_degraded: bool | None = None,\n on_ground: bool | None = None,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Filtered aircraft query against the live snapshot. Returns matched_total\n + a capped list of compact records.\n\n category ∈ airliner|private|helicopter|glider|military|emergency.\n Combine with area (centre or bbox), squawk, callsign_contains, altitude\n band (metres), emergency / gnss_degraded / on_ground booleans. limit ≤200.\"\"\"\n return await _get(\n \"/api/intel/aircraft\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"category\": category, \"squawk\": squawk,\n \"callsign_contains\": callsign_contains,\n \"min_alt_m\": min_alt_m, \"max_alt_m\": max_alt_m,\n \"emergency\": emergency, \"gnss_degraded\": gnss_degraded,\n \"on_ground\": on_ground, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def lookup_aircraft(ident: str) -> dict[str, Any]:\n \"\"\"Look up one aircraft by ICAO24 hex (exact) or callsign (substring).\n Returns its current state, registration, an integrity/threat assessment,\n and the latest server-held fix.\"\"\"\n # quote(): ident is agent-supplied free text going into a URL path —\n # escape it so \"x/../y\" style values cannot re-route the request.\n return await _get(f\"/api/intel/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def query_vessels(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n dark_only: bool = False,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Vessels (AIS) in an area, classified (cargo/tanker/fishing/passenger/\n military/sailing/pleasure/tug). dark_only=True returns only dark-vessel\n candidates (moving with no static identity). Scoped by centre or bbox.\"\"\"\n return await _get(\n \"/api/intel/vessels\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"dark_only\": dark_only, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def anomalies(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"Fused anomaly report for an area (or global if no coords): emergency\n aircraft, GPS-jamming hotspots, dark-vessel candidates, recent fusion\n alerts, plus a triage threat_level (low|elevated|high) and score.\"\"\"\n return await _get(\n \"/api/intel/anomalies\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def intel_brief(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n link_km: float = 50.0,\n window_hours: float = 6.0,\n) -> dict[str, Any]:\n \"\"\"Cross-domain INCIDENT brief — the headline analytic tool.\n\n Fuses the raw layers into ranked, cited INCIDENTS instead of returning\n signals you have to correlate yourself. An incident is a CONVERGENCE: signals\n within ``link_km`` across >=2 domains (GPS-jamming, dark/AIS-off vessels,\n military air, AIS gaps, emergencies, geocoded events, quakes) — or a single\n critical/high signal. Each incident carries a rule-based narrative, a\n threat_level, the contributing evidence (with IDs), and recommended follow-up\n queries. Omit coordinates for a global brief; pass a centre+radius or bbox to\n scope it. Start here, then drill into an incident's centroid with\n query_vessels / gps_jamming / deep_analyze.\n \"\"\"\n return await _get(\n \"/api/intel/brief\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"link_km\": link_km, \"window_hours\": window_hours,\n },\n )\n\n\n@mcp.tool()\nasync def detect_deception(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n) -> dict[str, Any]:\n \"\"\"Denial & deception — \"am I being fed?\". Flags MANIPULATED tracks distinct\n from jamming: AIS duplicate-MMSI (one identity, two hulls) and impossible\n teleports; ADS-B GPS spoofing (many aircraft snapped to one false position)\n and kinematic position-injection. Run before trusting a feed in a contested\n area. Omit coords for global.\"\"\"\n return await _get(\n \"/api/intel/deception\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n\n\n@mcp.tool()\nasync def locate_emitter(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n) -> dict[str, Any]:\n \"\"\"Estimate a GPS jammer/spoofer LOCATION from the degraded-ADS-B footprint\n (severity-weighted centroid + CEP + confidence). Turns \"jamming somewhere\n here\" into \"emitter ~here ±N km\". Footprint-centroid estimate (~tens of km),\n not RF direction-finding — stated in the response. Scope with lat/lon.\"\"\"\n return await _get(\n \"/api/intel/emitter\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n\n\n@mcp.tool()\nasync def area_baseline(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n) -> dict[str, Any]:\n \"\"\"Is this normal? Current vessel / dark-vessel / jamming / military counts\n z-scored against a rolling baseline, with anomalies called out (e.g. \"dark\n vessels +5σ\", \"traffic -3σ\"). Global uses the background sampler; polling an\n AOI repeatedly builds that area's baseline. Distinguishes a real shift from\n a normal day.\"\"\"\n return await _get(\n \"/api/intel/baseline\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n\n\n@mcp.tool()\nasync def whats_changed(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n) -> dict[str, Any]:\n \"\"\"Standing watch — what CHANGED since the last check, not the full picture.\n\n Returns incidents that are NEW, ESCALATED, DE-ESCALATED, or RESOLVED. Global\n (no coords) reflects the background watch loop (recomputed ~every 60s); an\n AOI (lat/lon) diffs against YOUR previous whats_changed call for that area,\n so you can poll one region and be told only what moved. Use this to monitor\n instead of re-reading the whole brief each time.\n \"\"\"\n return await _get(\n \"/api/intel/watch\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n\n\ndef _compact_points(points: list[dict[str, Any]], max_points: int) -> list[dict[str, Any]]:\n \"\"\"Downsample one incident's observation series to its meaningful shape.\n\n Keeps the first + last point and every point whose threat ``level`` differs\n from its predecessor (a flat run of identical (level, score) is noise on a\n timeline). If transitions alone still exceed ``max_points`` it strides them\n down. Each kept point is shrunk to ``[t, level, score]`` (a list, not a\n verbose dict) so the wire form is a fraction of the size.\"\"\"\n if not points:\n return []\n keep: list[dict[str, Any]] = [points[0]]\n prev = points[0].get(\"level\")\n for p in points[1:-1]:\n if p.get(\"level\") != prev:\n keep.append(p)\n prev = p.get(\"level\")\n if len(points) > 1:\n keep.append(points[-1])\n if len(keep) > max_points:\n # Stride the kept transitions, but always retain the endpoints.\n step = (len(keep) - 1) / (max_points - 1)\n idx = sorted({0, *(round(i * step) for i in range(max_points)), len(keep) - 1})\n keep = [keep[i] for i in idx][:max_points]\n return [[p.get(\"t\"), p.get(\"level\"), p.get(\"score\")] for p in keep]\n\n\ndef _compact_history(data: dict[str, Any], limit: int, max_points: int) -> dict[str, Any]:\n \"\"\"Cap + condense the incident-history payload so the default call fits well\n under the MCP response token cap.\n\n The backend route returns EVERY incident in the window, each with its full\n per-snapshot ``points`` series and full ``narrative`` — ~89 KB at the\n default 6 h, which overflows the cap and hard-errors the tool. We keep the\n most-active incidents (the route already sorts by point-count desc), compact\n each one's series, trim the narrative, and report an honest \"showing N of M\".\n \"\"\"\n incidents = data.get(\"incidents\")\n if not isinstance(incidents, list):\n return data # error payload or unexpected shape — pass through untouched\n total = len(incidents)\n kept = incidents[:limit]\n compact: list[dict[str, Any]] = []\n for inc in kept:\n narrative = inc.get(\"narrative\")\n if isinstance(narrative, str) and len(narrative) > 240:\n narrative = narrative[:237] + \"…\"\n compact.append(\n {\n \"key\": inc.get(\"key\"),\n \"domains\": inc.get(\"domains\"),\n \"centroid\": inc.get(\"centroid\"),\n \"narrative\": narrative,\n # series is [[t, level, score], …] — compact list form\n \"series\": _compact_points(inc.get(\"points\") or [], max_points),\n }\n )\n out = {\n \"scope\": data.get(\"scope\"),\n \"window_hours\": data.get(\"window_hours\"),\n \"snapshots\": data.get(\"snapshots\"),\n \"incident_count\": total,\n \"returned\": len(compact),\n \"truncated\": total > len(compact),\n \"incidents\": compact,\n }\n if out[\"truncated\"]:\n out[\"note\"] = (\n f\"showing {len(compact)} of {total} incidents (most-active first); \"\n \"raise `limit` or scope with lat/lon/hours for more\"\n )\n return out\n\n\n@mcp.tool()\nasync def incident_history(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n hours: float = 6.0,\n limit: int = 25,\n max_incidents: int | None = None,\n) -> dict[str, Any]:\n \"\"\"Timeline of how each incident built up over the recent window — per\n incident, a compact ``series`` of ``[time, threat_level, score]`` points.\n Reveals sequence (e.g. jamming first, then dark vessels, then a reported\n event). Global uses the background watch history; an AOI uses your prior\n watch calls.\n\n The full window can hold many incidents; this returns the ``limit`` most\n active ones (default 25, alias ``max_incidents``) with each series\n downsampled to its threat-level transitions, and reports\n ``incident_count`` / ``returned`` / ``truncated`` so you know if more exist.\n Raise ``limit`` or narrow with lat/lon/hours to drill in.\n \"\"\"\n cap = max_incidents if max_incidents is not None else limit\n cap = max(1, min(int(cap), 200))\n data = await _get(\n \"/api/intel/incident-history\",\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm, \"hours\": hours},\n )\n return _compact_history(data, cap, max_points=12)\n\n\n@mcp.tool()\nasync def vessel_dossier(mmsi: int | str) -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one vessel (MMSI): recent track, AIS gaps,\n derived speed profile (loiter / transit / loiter-then-dash), area covered,\n which live incidents it appears in, and a behaviour assessment. The track is\n the store's ~1h retention window.\"\"\"\n # MMSI is numeric; agents pass it as an int or a string — accept both.\n return await _get(f\"/api/intel/dossier/vessel/{quote(str(mmsi), safe='')}\")\n\n\n@mcp.tool()\nasync def aircraft_dossier(ident: str) -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one aircraft (ICAO24 hex or callsign): recent\n track, gaps, derived speed profile, GNSS-integrity, emergency/military flags,\n and which live incidents it appears in.\"\"\"\n return await _get(f\"/api/intel/dossier/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def list_focus_areas() -> dict[str, Any]:\n \"\"\"List the priority areas currently loaded PRIMARY (with fetch stats and\n whether each is served by a dedicated fetch or the degraded snapshot\n fallback).\"\"\"\n return await _get(\"/api/intel/aois\")\n\n\n@mcp.tool()\nasync def data_sources() -> dict[str, Any]:\n \"\"\"Which feeds are always-on vs key-gated, and the configured Ollama\n host/model. Use to explain coverage gaps (e.g. fires need a FIRMS key).\"\"\"\n return await _get(\"/api/intel/sources\")\n\n\n# ── deep analysis (DeepSeek reasoner, Ollama fallback) ────────────────────────\n\n_SYS_PROMPT = (\n \"You are a GEOINT analyst working a live open-source intelligence console. \"\n \"You are given distilled JSON from live ADS-B (aircraft), AIS (vessels), a \"\n \"GPS-jamming layer (ADS-B NACp/NIC, GPSJam method), and a cross-domain \"\n \"fusion engine whose `incident_brief` already chains co-located signals into \"\n \"ranked, cited INCIDENTS — lead your analysis from those incidents, then use \"\n \"the raw situation/area data to corroborate or challenge them. \"\n \"Reason ONLY over the provided JSON. Cite concrete numbers and IDs. Flag: \"\n \"GPS jamming/spoofing footprints, emergency squawks (7500 hijack / 7600 \"\n \"radio-fail / 7700 general), dark vessels, abnormal traffic density, and \"\n \"military activity. Be concise and structured: (1) ASSESSMENT (2 lines), \"\n \"(2) KEY FINDINGS (bullets with numbers), (3) RECOMMENDED FOLLOW-UP \"\n \"queries. If the data is thin, say so plainly — do not invent.\"\n)\n\n\n@mcp.tool()\nasync def deep_analyze(\n question: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 250.0,\n model: str | None = None,\n tier: str = \"reason\",\n) -> dict[str, Any]:\n \"\"\"Have a reasoning model reason deeply over the live intel.\n\n Gathers the relevant data (global situation, plus a focused area bundle +\n jamming + anomalies when lat/lon given) and feeds it to a real reasoning\n model — DeepSeek (``deepseek-reasoner``) when configured, else a local\n Ollama model. Heavy reasoning runs off-context; only the conclusion enters\n the agent's context. Falls back to returning the raw structured data\n (analysis=null) if no LLM backend is reachable.\n\n Args:\n question: what to analyse (e.g. \"Is there coordinated GPS jamming near\n the Baltic, and which aircraft are affected?\").\n lat, lon, radius_nm: optional area to focus on (loaded PRIMARY).\n model: optional Ollama model override used only on the Ollama fallback.\n tier: ``reason`` (deepseek-reasoner, default — judgement) or ``fast``\n (deepseek-chat — quicker, shallower).\n \"\"\"\n import json as _json\n\n from app import llm # noqa: PLC0415\n\n # 1) gather context (already compact). The cross-domain incident BRIEF is the\n # centrepiece — the reasoner judges fused, cited incidents rather than\n # re-correlating raw layers in its head.\n context: dict[str, Any] = {\"question\": question, \"situation\": await get_situation()}\n context[\"incident_brief\"] = await intel_brief(\n lat=lat, lon=lon, radius_nm=radius_nm,\n window_hours=12.0 if lat is None else 6.0,\n )\n if lat is not None and lon is not None:\n context[\"focus_area\"] = await focus_area(lat, lon, radius_nm)\n\n # 2) reason off-context (DeepSeek → Ollama → raw data). Cap the wait so a\n # slow upstream can't hang the tool for the 180 s default — the fast tier\n # should answer well under 60 s, the deeper reason tier under 90 s.\n timeout_s = 60.0 if (tier or \"\").lower() == \"fast\" else 90.0\n res = await llm.complete(\n _SYS_PROMPT,\n f\"QUESTION: {question}\\n\\nLIVE INTEL JSON:\\n\"\n + _json.dumps(context, separators=(\",\", \":\"))[:60000],\n tier=tier,\n temperature=0.2,\n max_tokens=2048,\n timeout_s=timeout_s,\n ollama_model=model or \"\",\n )\n if not res.ok:\n return {\n \"analysis\": None,\n \"model\": res.model,\n \"backend\": res.backend,\n \"note\": f\"No LLM backend reachable ({res.error}). Returning structured \"\n \"intel for you to analyse directly. Configure DEEPSEEK_API_KEY / the \"\n \"opencode DeepSeek key, or `ollama pull qwen2.5:3b`.\",\n \"data\": context,\n }\n\n return {\n \"analysis\": res.text,\n \"model\": res.model,\n \"backend\": res.backend,\n \"focused_on\": (\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm} if lat is not None else \"global\"\n ),\n \"data_summary\": {\n \"aircraft_total\": context[\"situation\"].get(\"aircraft\", {}).get(\"total\"),\n \"jamming_high\": context[\"situation\"].get(\"gps_jamming\", {}).get(\"high\"),\n },\n }\n\n\n# ── news intelligence (debias / fact-check) ──────────────────────────────────\n\n\n@mcp.tool()\nasync def news_analysis() -> dict[str, Any]:\n \"\"\"Cross-source, debiased world-news intelligence.\n\n Scrapes ~12 outlets (BBC, Reuters, AP, Al Jazeera, Guardian, CNN, Fox, …),\n then a reasoning model strips bias/propaganda and separates VERIFIED FACTS\n (corroborated by ≥2 independent outlets) from ATTRIBUTED CLAIMS and\n rhetoric. A leader promising \"the war will end soon\" is flagged as rhetoric,\n never reported as fact. Returns events with neutral_summary, verified_facts,\n attributed_claims, bias_flags, propaganda_techniques, rhetoric_flags and a\n confidence. May take ~30s when the cache is cold (it reasons over the feed).\n \"\"\"\n return await _get(\"/api/news/analysis\")\n\n\n@mcp.tool()\nasync def fact_check(claim: str) -> dict[str, Any]:\n \"\"\"Adjudicate one free-text claim against current world-news headlines.\n\n Returns ``{verdict: true|false|misleading|unverified, reasoning,\n supporting_sources, confidence}``. Use it to sanity-check a statement before\n treating it as fact.\n \"\"\"\n return await _get(\"/api/news/factcheck\", {\"claim\": claim})\n\n\n@mcp.tool()\nasync def aoi_imagery(\n before: str,\n after: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_km: float = 5.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n window_days: int = 30,\n) -> dict[str, Any]:\n \"\"\"Building imagery for a location at two dates — set time + place directly.\n\n Give a location (lat/lon + radius_km, OR an explicit min/max bbox) and a\n before + after date (YYYY-MM-DD). Returns what imagery is available for each\n date WITHOUT downloading: Maxar Open Data VHR (~0.3-0.5 m, event-gated — only\n where a disaster/conflict event covers the AOI) and Sentinel (10 m, global,\n any date). `best_source` says which to use. Reconstruction downloads the\n scenes to a temp dir on demand and discards them — nothing is stored.\n\n Args:\n before, after: dates as YYYY-MM-DD.\n lat, lon, radius_km: centre + radius (km) of the AOI.\n min_lon/min_lat/max_lon/max_lat: explicit bbox (overrides lat/lon).\n window_days: ± days around each date to search Maxar (events are sparse).\n \"\"\"\n params: dict[str, Any] = {\n \"before\": before,\n \"after\": after,\n \"radius_km\": radius_km,\n \"window_days\": window_days,\n }\n if None not in (min_lon, min_lat, max_lon, max_lat):\n params.update(min_lon=min_lon, min_lat=min_lat, max_lon=max_lon, max_lat=max_lat)\n else:\n params.update(lat=lat, lon=lon)\n return await _get(\"/api/imagery/aoi\", params)\n\n\n# ── entrypoint ─────────────────────────────────────────────────────────────────\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=\"OSINT GEOINT MCP server\")\n parser.add_argument(\"--http\", action=\"store_true\", help=\"serve over streamable-HTTP\")\n parser.add_argument(\"--port\", type=int, default=8765, help=\"HTTP port (with --http)\")\n parser.add_argument(\"--host\", default=\"127.0.0.1\", help=\"HTTP host (with --http)\")\n parser.add_argument(\n \"--list-tools\", action=\"store_true\", help=\"print registered tools and exit\"\n )\n args = parser.parse_args()\n\n if args.list_tools:\n tools = asyncio.run(mcp.list_tools())\n print(f\"osint-geoint MCP — {len(tools)} tools:\")\n for t in tools:\n summary = (t.description or \"\").split(\"\\n\", 1)[0]\n print(f\" • {t.name}: {summary}\")\n return\n\n if args.http:\n mcp.settings.host = args.host\n mcp.settings.port = args.port\n mcp.run(transport=\"streamable-http\")\n else:\n mcp.run() # stdio\n\n\nif __name__ == \"__main__\":\n main()\n"}, "8605bad572e30416e66f9a92367cdfb3de68102b": {"t": 1785765051.1680791, "n": 35174, "h": "1017e20901477764", "c": "\"\"\"OSINT GEOINT — Model Context Protocol server.\n\nExposes the live OSINT console as tools an AI agent can call. Every tool is a\nthin async wrapper over the ``/api/intel/*`` HTTP surface (``app.routes.intel``)\nso the agent shares the SAME warm in-process snapshot, AOI priority loader, and\nfusion engine the globe uses — no second upstream fan-out, no rate-limit blowup.\n\nDesign principles (the MCP brief):\n- **Context-safe.** Tools return distilled JSON (counts, grids, ≤50-item\n samples), never raw feature dumps. An agent can sweep the whole planet for a\n few hundred tokens.\n- **Area-primary.** ``focus_area`` marks a region PRIMARY: a dedicated fresh\n fetch + ongoing priority refresh, independent of global rate limits. Other\n regions keep streaming from the global snapshot.\n- **Think deeper, off-context.** ``deep_analyze`` pulls the relevant intel JSON\n and hands it to a real reasoning model — DeepSeek (``deepseek-reasoner``) when\n configured, else a local Ollama model — so heavy analysis happens off the\n agent's context and only the conclusion returns.\n\nRun:\n python -m app.mcp_server # stdio (Claude Code / Desktop / SDK)\n python -m app.mcp_server --http --port 8765 # streamable-HTTP\n python -m app.mcp_server --list-tools # introspect, for CI / verification\n\nConfig (env or apps/api/.env): API_BASE, API_KEY, DEEPSEEK_API_KEY (or the\nopencode DeepSeek key), OLLAMA_HOST, OLLAMA_MODEL.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport atexit\nimport os\nimport subprocess\nimport sys\nfrom typing import Any\nfrom urllib.parse import quote, urlparse\n\nimport httpx\nfrom mcp.server.fastmcp import FastMCP\nfrom mcp.server.streamable_http_manager import StreamableHTTPSessionManager\nfrom mcp.server.transport_security import TransportSecuritySettings\nfrom starlette.routing import Route\n\nfrom app.config import get_settings\n\nmcp = FastMCP(\n \"osint-geoint\",\n instructions=(\n \"Live geospatial intelligence over open ADS-B (aircraft), AIS (vessels), \"\n \"GPS-jamming (ADS-B NACp/NIC), Sentinel-1 SAR dark vessels, geocoded \"\n \"events, and a cross-domain fusion engine. Workflow: get_situation() to \"\n \"orient -> intel_brief() for ranked, cited cross-domain INCIDENTS (the \"\n \"fused picture, global or scoped) -> focus_area(lat,lon,radius_nm) to \"\n \"load an incident's region PRIMARY -> query_vessels / gps_jamming / \"\n \"query_aircraft to drill into its evidence -> deep_analyze() to have a \"\n \"reasoning model judge it. intel_brief is the headline tool: it chains \"\n \"signals into incidents so you don't correlate raw layers by hand.\"\n ),\n)\n\n\n# ── hosted mount (streamable-HTTP at /mcp of the FastAPI app) ──────────────────\n# The agent-facing endpoint. Mounting the MCP into the SAME uvicorn process the\n# globe runs lets every tool share the warm in-process snapshot + fusion engine\n# (the tools still self-call /api/intel/* over localhost, authenticated by the\n# static API_KEY). The Velocity gateway Worker proxies https://<host>/mcp here,\n# forwarding the caller's Supabase token; the backend's ApiKeyMiddleware gates it\n# like any other non-public route, so no MCP-specific auth is needed.\n\n\nclass _MCPASGIApp:\n \"\"\"Raw-ASGI adapter for the streamable-HTTP session manager.\n\n A plain function endpoint would be wrapped by Starlette as a\n request/response handler; a class instance is left as a raw ASGI app, which\n is what the transport needs (it negotiates SSE itself).\n \"\"\"\n\n def __init__(self, manager: StreamableHTTPSessionManager) -> None:\n self._manager = manager\n\n async def __call__(self, scope: Any, receive: Any, send: Any) -> None:\n await self._manager.handle_request(scope, receive, send)\n\n\ndef build_mcp_mount() -> tuple[list[Route], StreamableHTTPSessionManager]:\n \"\"\"Return (routes, session_manager) to serve this server at ``/mcp``.\n\n A FRESH manager per call — ``StreamableHTTPSessionManager.run()`` is one-shot\n (it raises if entered twice), so every FastAPI app instance, including each\n test app, needs its own. Usage::\n\n routes, mgr = build_mcp_mount()\n app.router.routes.extend(routes) # in the app factory\n async with mgr.run(): ... yield # in the app lifespan\n\n Served as exact ``Route``s (``/mcp`` and ``/mcp/``), NOT a ``Mount`` — a\n mount 307-redirects ``/mcp`` → ``/mcp/`` with a Location built from the\n request host, which through the gateway Worker would point a client at the\n bare backend origin. DNS-rebinding protection is disabled: the endpoint is\n reached server-to-server through the Worker (the Host header is the\n deployment origin, not a browser tab), and the Supabase-token check in\n ``app.auth.ApiKeyMiddleware`` is the real gate.\n \"\"\"\n manager = StreamableHTTPSessionManager(\n app=mcp._mcp_server,\n json_response=False,\n stateless=False,\n security_settings=TransportSecuritySettings(\n enable_dns_rebinding_protection=False\n ),\n )\n endpoint = _MCPASGIApp(manager)\n routes = [Route(\"/mcp\", endpoint), Route(\"/mcp/\", endpoint)]\n return routes, manager\n\n\n# ── backend HTTP plumbing ─────────────────────────────────────────────────────\n\n\ndef _api_base() -> str:\n return (os.environ.get(\"API_BASE\") or get_settings().api_base).rstrip(\"/\")\n\n\ndef _headers() -> dict[str, str]:\n key = os.environ.get(\"API_KEY\") or get_settings().api_key\n return {\"X-API-Key\": key} if key else {}\n\n\n# ── backend auto-start ────────────────────────────────────────────────────────\n# The MCP server is a thin client over the OSINT backend. If that backend is\n# not running, every tool errors and a driving agent can spin in a retry loop.\n# So on the first tool call we make sure a backend exists: reuse one already\n# listening, else spawn uvicorn ourselves (localhost only) and wait — bounded —\n# for it to come up. Set OSINT_MCP_NO_AUTOSTART=1 to disable.\n\n_BACKEND_LOCK = asyncio.Lock()\n_BACKEND_READY = False\n_BACKEND_PROC: subprocess.Popen[bytes] | None = None\n_AUTOSTART_WAIT_S = 60\n\n\ndef _is_localhost(url: str) -> bool:\n host = (urlparse(url).hostname or \"\").lower()\n return host in {\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"::1\"}\n\n\ndef _api_port() -> int:\n return urlparse(_api_base()).port or 8000\n\n\ndef _apps_api_dir() -> str:\n import app # noqa: PLC0415\n\n return os.path.dirname(os.path.dirname(os.path.abspath(app.__file__)))\n\n\nasync def _backend_healthy(timeout_s: float = 2.0) -> bool:\n try:\n async with httpx.AsyncClient(timeout=timeout_s) as c:\n r = await c.get(_api_base() + \"/api/intel/sources\", headers=_headers())\n return r.status_code == 200\n except Exception:\n return False\n\n\ndef _spawn_uvicorn(cmd: list[str]) -> subprocess.Popen[bytes]:\n \"\"\"Sync spawn (Popen returns immediately; isolated from the async path).\"\"\"\n return subprocess.Popen(\n cmd,\n cwd=_apps_api_dir(),\n stdout=subprocess.DEVNULL,\n stderr=subprocess.DEVNULL,\n env=dict(os.environ),\n )\n\n\ndef _terminate_backend() -> None:\n proc = _BACKEND_PROC\n if proc is not None and proc.poll() is None:\n proc.terminate()\n\n\nasync def _prewarm() -> None:\n \"\"\"Kick the global ADS-B snapshot so the first get_situation isn't slow.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=120.0) as c:\n await c.get(_api_base() + \"/api/adsb/global\", headers=_headers())\n except Exception:\n pass\n\n\nasync def _ensure_backend() -> None:\n \"\"\"Idempotent: guarantee a reachable backend before a tool runs.\"\"\"\n global _BACKEND_READY, _BACKEND_PROC\n if _BACKEND_READY:\n return\n async with _BACKEND_LOCK:\n if _BACKEND_READY:\n return\n if await _backend_healthy():\n _BACKEND_READY = True\n return\n # Only auto-start a LOCAL backend, and only if not opted out.\n if os.environ.get(\"OSINT_MCP_NO_AUTOSTART\") or not _is_localhost(_api_base()):\n return\n # Spawn at most ONCE per process. If a prior attempt didn't come up,\n # don't re-spawn (avoids stacking uvicorns / re-waiting every call) —\n # just let later calls re-check health in case it's still warming.\n if _BACKEND_PROC is not None:\n return\n cmd = [\n sys.executable, \"-m\", \"uvicorn\", \"app.main:app\",\n \"--host\", \"127.0.0.1\", \"--port\", str(_api_port()), \"--log-level\", \"warning\",\n ]\n try:\n _BACKEND_PROC = _spawn_uvicorn(cmd)\n except Exception:\n return # can't spawn (e.g. uvicorn missing) — tools report cleanly\n atexit.register(_terminate_backend)\n for _ in range(_AUTOSTART_WAIT_S):\n await asyncio.sleep(1.0)\n if await _backend_healthy():\n _BACKEND_READY = True\n asyncio.create_task(_prewarm()) # warm snapshot off the hot path\n return\n # Did not come up in time; leave _BACKEND_READY False so the next tool\n # call retries once more rather than looping on a dead spawn.\n\n\nasync def _get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"GET an intel endpoint. Returns parsed JSON or a structured error dict —\n never raises, so a tool call always yields something the agent can read.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in (params or {}).items() if v is not None}\n # Generous ceiling: the backend's FIRST global call cold-starts the ADS-B\n # snapshot (firehose fetch + backoff) and can take up to ~75s. After that\n # every call is instant. A short timeout here would mis-report a healthy\n # backend as unreachable on the agent's very first query.\n try:\n async with httpx.AsyncClient(timeout=90.0) as client:\n r = await client.get(url, params=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\n \"error\": \"backend_unreachable\",\n \"detail\": str(exc),\n \"url\": url,\n \"hint\": \"Backend auto-start did not come up in time. It may still be \"\n \"warming — retry once. Or start it manually: uv run --project \"\n f\"{_apps_api_dir()} uvicorn app.main:app --port {_api_port()}\",\n }\n if r.status_code != 200:\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\n# ── tools ─────────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def get_situation() -> dict[str, Any]:\n \"\"\"Global situational snapshot — the cheap first call to orient.\n\n Returns total aircraft (airborne/ground, by category), GNSS-degraded count,\n active emergency squawks, the worst GPS-jamming cells worldwide, tracked\n vessel counts by category, and recent fusion-alert counts. A few hundred\n tokens describing the whole planet.\"\"\"\n return await _get(\"/api/intel/situation\")\n\n\n@mcp.tool()\nasync def focus_area(\n lat: float,\n lon: float,\n radius_nm: float = 200.0,\n label: str | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Load a region PRIMARY and return a full intel bundle for it in one call.\n\n This is the headline tool. It triggers a DEDICATED, always-fresh fetch for\n just this area (bypassing global rate limits) and registers it for ongoing\n priority refresh. The bundle contains: fresh aircraft summary + sample,\n aircraft-density grid, GPS-jamming assessment, vessels, and fused anomalies\n (emergencies, jamming hotspots, dark vessels, alerts) with a threat level.\n\n Args:\n lat, lon: centre of the area of interest.\n radius_nm: radius in nautical miles (1–250; default 200).\n label: optional human name for the AOI (e.g. \"Kaliningrad\").\n cell_deg: density grid cell size in degrees (0.1–10; default 1.0).\n \"\"\"\n return await _get(\n \"/api/intel/area\",\n {\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"label\": label,\n \"primary\": True,\n \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def aircraft_density(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Aircraft density over an area as a grid of cells (count, by category,\n GNSS-degraded per cell) plus totals, peak cell, and in-area vessel count.\n\n Give either a centre (lat, lon [, radius_nm]) or an explicit bbox\n (min_lon, min_lat, max_lon, max_lat). cell_deg sets grid resolution.\"\"\"\n return await _get(\n \"/api/intel/density\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat, \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def gps_jamming(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"GPS/GNSS jamming assessment (GPSJam method: ADS-B NACp<8 / NIC<7 binned\n into 1° cells). Returns flagged cells ranked by severity, counts of\n high/medium cells, and a sample of affected aircraft. Omit all coordinates\n for a global view; otherwise pass a centre or a bbox to scope it.\"\"\"\n return await _get(\n \"/api/intel/jamming\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def query_aircraft(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n category: str | None = None,\n squawk: str | None = None,\n callsign_contains: str | None = None,\n min_alt_m: float | None = None,\n max_alt_m: float | None = None,\n emergency: bool | None = None,\n gnss_degraded: bool | None = None,\n on_ground: bool | None = None,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Filtered aircraft query against the live snapshot. Returns matched_total\n + a capped list of compact records.\n\n category ∈ airliner|private|helicopter|glider|military|emergency.\n Combine with area (centre or bbox), squawk, callsign_contains, altitude\n band (metres), emergency / gnss_degraded / on_ground booleans. limit ≤200.\"\"\"\n return await _get(\n \"/api/intel/aircraft\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"category\": category, \"squawk\": squawk,\n \"callsign_contains\": callsign_contains,\n \"min_alt_m\": min_alt_m, \"max_alt_m\": max_alt_m,\n \"emergency\": emergency, \"gnss_degraded\": gnss_degraded,\n \"on_ground\": on_ground, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def lookup_aircraft(ident: str) -> dict[str, Any]:\n \"\"\"Look up one aircraft by ICAO24 hex (exact) or callsign (substring).\n Returns its current state, registration, an integrity/threat assessment,\n and the latest server-held fix.\"\"\"\n # quote(): ident is agent-supplied free text going into a URL path —\n # escape it so \"x/../y\" style values cannot re-route the request.\n return await _get(f\"/api/intel/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def query_vessels(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n dark_only: bool = False,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Vessels (AIS) in an area, classified (cargo/tanker/fishing/passenger/\n military/sailing/pleasure/tug). dark_only=True returns only dark-vessel\n candidates (moving with no static identity). Scoped by centre or bbox.\"\"\"\n return await _get(\n \"/api/intel/vessels\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"dark_only\": dark_only, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def anomalies(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"Fused anomaly report for an area (or global if no coords): emergency\n aircraft, GPS-jamming hotspots, dark-vessel candidates, recent fusion\n alerts, plus a triage threat_level (low|elevated|high) and score.\"\"\"\n return await _get(\n \"/api/intel/anomalies\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def intel_brief(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n link_km: float = 50.0,\n window_hours: float = 6.0,\n) -> dict[str, Any]:\n \"\"\"Cross-domain INCIDENT brief — the headline analytic tool.\n\n Fuses the raw layers into ranked, cited INCIDENTS instead of returning\n signals you have to correlate yourself. An incident is a CONVERGENCE: signals\n within ``link_km`` across >=2 domains (GPS-jamming, dark/AIS-off vessels,\n military air, AIS gaps, emergencies, geocoded events, quakes) — or a single\n critical/high signal. Each incident carries a rule-based narrative, a\n threat_level, the contributing evidence (with IDs), and recommended follow-up\n queries. Omit coordinates for a global brief; pass a centre+radius or bbox to\n scope it. Start here, then drill into an incident's centroid with\n query_vessels / gps_jamming / deep_analyze.\n \"\"\"\n return await _get(\n \"/api/intel/brief\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"link_km\": link_km, \"window_hours\": window_hours,\n },\n )\n\n\n@mcp.tool()\nasync def detect_deception(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n) -> dict[str, Any]:\n \"\"\"Denial & deception — \"am I being fed?\". Flags MANIPULATED tracks distinct\n from jamming: AIS duplicate-MMSI (one identity, two hulls) and impossible\n teleports; ADS-B GPS spoofing (many aircraft snapped to one false position)\n and kinematic position-injection. Run before trusting a feed in a contested\n area. Omit coords for global.\"\"\"\n return await _get(\n \"/api/intel/deception\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n\n\n@mcp.tool()\nasync def locate_emitter(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n) -> dict[str, Any]:\n \"\"\"Estimate a GPS jammer/spoofer LOCATION from the degraded-ADS-B footprint\n (severity-weighted centroid + CEP + confidence). Turns \"jamming somewhere\n here\" into \"emitter ~here ±N km\". Footprint-centroid estimate (~tens of km),\n not RF direction-finding — stated in the response. Scope with lat/lon.\"\"\"\n return await _get(\n \"/api/intel/emitter\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n\n\n@mcp.tool()\nasync def area_baseline(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n) -> dict[str, Any]:\n \"\"\"Is this normal? Current vessel / dark-vessel / jamming / military counts\n z-scored against a rolling baseline, with anomalies called out (e.g. \"dark\n vessels +5σ\", \"traffic -3σ\"). Global uses the background sampler; polling an\n AOI repeatedly builds that area's baseline. Distinguishes a real shift from\n a normal day.\"\"\"\n return await _get(\n \"/api/intel/baseline\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n\n\n@mcp.tool()\nasync def whats_changed(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n) -> dict[str, Any]:\n \"\"\"Standing watch — what CHANGED since the last check, not the full picture.\n\n Returns incidents that are NEW, ESCALATED, DE-ESCALATED, or RESOLVED. Global\n (no coords) reflects the background watch loop (recomputed ~every 60s); an\n AOI (lat/lon) diffs against YOUR previous whats_changed call for that area,\n so you can poll one region and be told only what moved. Use this to monitor\n instead of re-reading the whole brief each time.\n \"\"\"\n return await _get(\n \"/api/intel/watch\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n\n\ndef _compact_points(points: list[dict[str, Any]], max_points: int) -> list[dict[str, Any]]:\n \"\"\"Downsample one incident's observation series to its meaningful shape.\n\n Keeps the first + last point and every point whose threat ``level`` differs\n from its predecessor (a flat run of identical (level, score) is noise on a\n timeline). If transitions alone still exceed ``max_points`` it strides them\n down. Each kept point is shrunk to ``[t, level, score]`` (a list, not a\n verbose dict) so the wire form is a fraction of the size.\"\"\"\n if not points:\n return []\n keep: list[dict[str, Any]] = [points[0]]\n prev = points[0].get(\"level\")\n for p in points[1:-1]:\n if p.get(\"level\") != prev:\n keep.append(p)\n prev = p.get(\"level\")\n if len(points) > 1:\n keep.append(points[-1])\n if len(keep) > max_points:\n # Stride the kept transitions, but always retain the endpoints.\n step = (len(keep) - 1) / (max_points - 1)\n idx = sorted({0, *(round(i * step) for i in range(max_points)), len(keep) - 1})\n keep = [keep[i] for i in idx][:max_points]\n return [[p.get(\"t\"), p.get(\"level\"), p.get(\"score\")] for p in keep]\n\n\ndef _compact_history(data: dict[str, Any], limit: int, max_points: int) -> dict[str, Any]:\n \"\"\"Cap + condense the incident-history payload so the default call fits well\n under the MCP response token cap.\n\n The backend route returns EVERY incident in the window, each with its full\n per-snapshot ``points`` series and full ``narrative`` — ~89 KB at the\n default 6 h, which overflows the cap and hard-errors the tool. We keep the\n most-active incidents (the route already sorts by point-count desc), compact\n each one's series, trim the narrative, and report an honest \"showing N of M\".\n \"\"\"\n incidents = data.get(\"incidents\")\n if not isinstance(incidents, list):\n return data # error payload or unexpected shape — pass through untouched\n total = len(incidents)\n kept = incidents[:limit]\n compact: list[dict[str, Any]] = []\n for inc in kept:\n narrative = inc.get(\"narrative\")\n if isinstance(narrative, str) and len(narrative) > 240:\n narrative = narrative[:237] + \"…\"\n compact.append(\n {\n \"key\": inc.get(\"key\"),\n \"domains\": inc.get(\"domains\"),\n \"centroid\": inc.get(\"centroid\"),\n \"narrative\": narrative,\n # series is [[t, level, score], …] — compact list form\n \"series\": _compact_points(inc.get(\"points\") or [], max_points),\n }\n )\n out = {\n \"scope\": data.get(\"scope\"),\n \"window_hours\": data.get(\"window_hours\"),\n \"snapshots\": data.get(\"snapshots\"),\n \"incident_count\": total,\n \"returned\": len(compact),\n \"truncated\": total > len(compact),\n \"incidents\": compact,\n }\n if out[\"truncated\"]:\n out[\"note\"] = (\n f\"showing {len(compact)} of {total} incidents (most-active first); \"\n \"raise `limit` or scope with lat/lon/hours for more\"\n )\n return out\n\n\n@mcp.tool()\nasync def incident_history(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n hours: float = 6.0,\n limit: int = 25,\n max_incidents: int | None = None,\n) -> dict[str, Any]:\n \"\"\"Timeline of how each incident built up over the recent window — per\n incident, a compact ``series`` of ``[time, threat_level, score]`` points.\n Reveals sequence (e.g. jamming first, then dark vessels, then a reported\n event). Global uses the background watch history; an AOI uses your prior\n watch calls.\n\n The full window can hold many incidents; this returns the ``limit`` most\n active ones (default 25, alias ``max_incidents``) with each series\n downsampled to its threat-level transitions, and reports\n ``incident_count`` / ``returned`` / ``truncated`` so you know if more exist.\n Raise ``limit`` or narrow with lat/lon/hours to drill in.\n \"\"\"\n cap = max_incidents if max_incidents is not None else limit\n cap = max(1, min(int(cap), 200))\n data = await _get(\n \"/api/intel/incident-history\",\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm, \"hours\": hours},\n )\n return _compact_history(data, cap, max_points=12)\n\n\n@mcp.tool()\nasync def vessel_dossier(mmsi: str) -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one vessel (MMSI): recent track, AIS gaps,\n derived speed profile (loiter / transit / loiter-then-dash), area covered,\n which live incidents it appears in, and a behaviour assessment. The track is\n the store's ~1h retention window.\"\"\"\n return await _get(f\"/api/intel/dossier/vessel/{quote(mmsi, safe='')}\")\n\n\n@mcp.tool()\nasync def aircraft_dossier(ident: str) -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one aircraft (ICAO24 hex or callsign): recent\n track, gaps, derived speed profile, GNSS-integrity, emergency/military flags,\n and which live incidents it appears in.\"\"\"\n return await _get(f\"/api/intel/dossier/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def list_focus_areas() -> dict[str, Any]:\n \"\"\"List the priority areas currently loaded PRIMARY (with fetch stats and\n whether each is served by a dedicated fetch or the degraded snapshot\n fallback).\"\"\"\n return await _get(\"/api/intel/aois\")\n\n\n@mcp.tool()\nasync def data_sources() -> dict[str, Any]:\n \"\"\"Which feeds are always-on vs key-gated, and the configured Ollama\n host/model. Use to explain coverage gaps (e.g. fires need a FIRMS key).\"\"\"\n return await _get(\"/api/intel/sources\")\n\n\n# ── deep analysis (DeepSeek reasoner, Ollama fallback) ────────────────────────\n\n_SYS_PROMPT = (\n \"You are a GEOINT analyst working a live open-source intelligence console. \"\n \"You are given distilled JSON from live ADS-B (aircraft), AIS (vessels), a \"\n \"GPS-jamming layer (ADS-B NACp/NIC, GPSJam method), and a cross-domain \"\n \"fusion engine whose `incident_brief` already chains co-located signals into \"\n \"ranked, cited INCIDENTS — lead your analysis from those incidents, then use \"\n \"the raw situation/area data to corroborate or challenge them. \"\n \"Reason ONLY over the provided JSON. Cite concrete numbers and IDs. Flag: \"\n \"GPS jamming/spoofing footprints, emergency squawks (7500 hijack / 7600 \"\n \"radio-fail / 7700 general), dark vessels, abnormal traffic density, and \"\n \"military activity. Be concise and structured: (1) ASSESSMENT (2 lines), \"\n \"(2) KEY FINDINGS (bullets with numbers), (3) RECOMMENDED FOLLOW-UP \"\n \"queries. If the data is thin, say so plainly — do not invent.\"\n)\n\n\n@mcp.tool()\nasync def deep_analyze(\n question: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 250.0,\n model: str | None = None,\n tier: str = \"reason\",\n) -> dict[str, Any]:\n \"\"\"Have a reasoning model reason deeply over the live intel.\n\n Gathers the relevant data (global situation, plus a focused area bundle +\n jamming + anomalies when lat/lon given) and feeds it to a real reasoning\n model — DeepSeek (``deepseek-reasoner``) when configured, else a local\n Ollama model. Heavy reasoning runs off-context; only the conclusion enters\n the agent's context. Falls back to returning the raw structured data\n (analysis=null) if no LLM backend is reachable.\n\n Args:\n question: what to analyse (e.g. \"Is there coordinated GPS jamming near\n the Baltic, and which aircraft are affected?\").\n lat, lon, radius_nm: optional area to focus on (loaded PRIMARY).\n model: optional Ollama model override used only on the Ollama fallback.\n tier: ``reason`` (deepseek-reasoner, default — judgement) or ``fast``\n (deepseek-chat — quicker, shallower).\n \"\"\"\n import json as _json\n\n from app import llm # noqa: PLC0415\n\n # 1) gather context (already compact). The cross-domain incident BRIEF is the\n # centrepiece — the reasoner judges fused, cited incidents rather than\n # re-correlating raw layers in its head.\n context: dict[str, Any] = {\"question\": question, \"situation\": await get_situation()}\n context[\"incident_brief\"] = await intel_brief(\n lat=lat, lon=lon, radius_nm=radius_nm,\n window_hours=12.0 if lat is None else 6.0,\n )\n if lat is not None and lon is not None:\n context[\"focus_area\"] = await focus_area(lat, lon, radius_nm)\n\n # 2) reason off-context (DeepSeek → Ollama → raw data)\n res = await llm.complete(\n _SYS_PROMPT,\n f\"QUESTION: {question}\\n\\nLIVE INTEL JSON:\\n\"\n + _json.dumps(context, separators=(\",\", \":\"))[:60000],\n tier=tier,\n temperature=0.2,\n max_tokens=2048,\n ollama_model=model or \"\",\n )\n if not res.ok:\n return {\n \"analysis\": None,\n \"model\": res.model,\n \"backend\": res.backend,\n \"note\": f\"No LLM backend reachable ({res.error}). Returning structured \"\n \"intel for you to analyse directly. Configure DEEPSEEK_API_KEY / the \"\n \"opencode DeepSeek key, or `ollama pull qwen2.5:3b`.\",\n \"data\": context,\n }\n\n return {\n \"analysis\": res.text,\n \"model\": res.model,\n \"backend\": res.backend,\n \"focused_on\": (\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm} if lat is not None else \"global\"\n ),\n \"data_summary\": {\n \"aircraft_total\": context[\"situation\"].get(\"aircraft\", {}).get(\"total\"),\n \"jamming_high\": context[\"situation\"].get(\"gps_jamming\", {}).get(\"high\"),\n },\n }\n\n\n# ── news intelligence (debias / fact-check) ──────────────────────────────────\n\n\n@mcp.tool()\nasync def news_analysis() -> dict[str, Any]:\n \"\"\"Cross-source, debiased world-news intelligence.\n\n Scrapes ~12 outlets (BBC, Reuters, AP, Al Jazeera, Guardian, CNN, Fox, …),\n then a reasoning model strips bias/propaganda and separates VERIFIED FACTS\n (corroborated by ≥2 independent outlets) from ATTRIBUTED CLAIMS and\n rhetoric. A leader promising \"the war will end soon\" is flagged as rhetoric,\n never reported as fact. Returns events with neutral_summary, verified_facts,\n attributed_claims, bias_flags, propaganda_techniques, rhetoric_flags and a\n confidence. May take ~30s when the cache is cold (it reasons over the feed).\n \"\"\"\n return await _get(\"/api/news/analysis\")\n\n\n@mcp.tool()\nasync def fact_check(claim: str) -> dict[str, Any]:\n \"\"\"Adjudicate one free-text claim against current world-news headlines.\n\n Returns ``{verdict: true|false|misleading|unverified, reasoning,\n supporting_sources, confidence}``. Use it to sanity-check a statement before\n treating it as fact.\n \"\"\"\n return await _get(\"/api/news/factcheck\", {\"claim\": claim})\n\n\n@mcp.tool()\nasync def aoi_imagery(\n before: str,\n after: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_km: float = 5.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n window_days: int = 30,\n) -> dict[str, Any]:\n \"\"\"Building imagery for a location at two dates — set time + place directly.\n\n Give a location (lat/lon + radius_km, OR an explicit min/max bbox) and a\n before + after date (YYYY-MM-DD). Returns what imagery is available for each\n date WITHOUT downloading: Maxar Open Data VHR (~0.3-0.5 m, event-gated — only\n where a disaster/conflict event covers the AOI) and Sentinel (10 m, global,\n any date). `best_source` says which to use. Reconstruction downloads the\n scenes to a temp dir on demand and discards them — nothing is stored.\n\n Args:\n before, after: dates as YYYY-MM-DD.\n lat, lon, radius_km: centre + radius (km) of the AOI.\n min_lon/min_lat/max_lon/max_lat: explicit bbox (overrides lat/lon).\n window_days: ± days around each date to search Maxar (events are sparse).\n \"\"\"\n params: dict[str, Any] = {\n \"before\": before,\n \"after\": after,\n \"radius_km\": radius_km,\n \"window_days\": window_days,\n }\n if None not in (min_lon, min_lat, max_lon, max_lat):\n params.update(min_lon=min_lon, min_lat=min_lat, max_lon=max_lon, max_lat=max_lat)\n else:\n params.update(lat=lat, lon=lon)\n return await _get(\"/api/imagery/aoi\", params)\n\n\n# ── entrypoint ─────────────────────────────────────────────────────────────────\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=\"OSINT GEOINT MCP server\")\n parser.add_argument(\"--http\", action=\"store_true\", help=\"serve over streamable-HTTP\")\n parser.add_argument(\"--port\", type=int, default=8765, help=\"HTTP port (with --http)\")\n parser.add_argument(\"--host\", default=\"127.0.0.1\", help=\"HTTP host (with --http)\")\n parser.add_argument(\n \"--list-tools\", action=\"store_true\", help=\"print registered tools and exit\"\n )\n args = parser.parse_args()\n\n if args.list_tools:\n tools = asyncio.run(mcp.list_tools())\n print(f\"osint-geoint MCP — {len(tools)} tools:\")\n for t in tools:\n summary = (t.description or \"\").split(\"\\n\", 1)[0]\n print(f\" • {t.name}: {summary}\")\n return\n\n if args.http:\n mcp.settings.host = args.host\n mcp.settings.port = args.port\n mcp.run(transport=\"streamable-http\")\n else:\n mcp.run() # stdio\n\n\nif __name__ == \"__main__\":\n main()\n"}, "6123b863b4f44b8e5fdc6e7a18b94ca2b8cd5c6d": {"t": 1785765052.66117, "n": 31445, "h": "320f9795ab55a59c", "c": "\"\"\"OSINT GEOINT — Model Context Protocol server.\n\nExposes the live OSINT console as tools an AI agent can call. Every tool is a\nthin async wrapper over the ``/api/intel/*`` HTTP surface (``app.routes.intel``)\nso the agent shares the SAME warm in-process snapshot, AOI priority loader, and\nfusion engine the globe uses — no second upstream fan-out, no rate-limit blowup.\n\nDesign principles (the MCP brief):\n- **Context-safe.** Tools return distilled JSON (counts, grids, ≤50-item\n samples), never raw feature dumps. An agent can sweep the whole planet for a\n few hundred tokens.\n- **Area-primary.** ``focus_area`` marks a region PRIMARY: a dedicated fresh\n fetch + ongoing priority refresh, independent of global rate limits. Other\n regions keep streaming from the global snapshot.\n- **Think deeper, off-context.** ``deep_analyze`` pulls the relevant intel JSON\n and hands it to a real reasoning model — DeepSeek (``deepseek-reasoner``) when\n configured, else a local Ollama model — so heavy analysis happens off the\n agent's context and only the conclusion returns.\n\nRun:\n python -m app.mcp_server # stdio (Claude Code / Desktop / SDK)\n python -m app.mcp_server --http --port 8765 # streamable-HTTP\n python -m app.mcp_server --list-tools # introspect, for CI / verification\n\nConfig (env or apps/api/.env): API_BASE, API_KEY, DEEPSEEK_API_KEY (or the\nopencode DeepSeek key), OLLAMA_HOST, OLLAMA_MODEL.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport atexit\nimport os\nimport subprocess\nimport sys\nfrom typing import Any\nfrom urllib.parse import quote, urlparse\n\nimport httpx\nfrom mcp.server.fastmcp import FastMCP\nfrom mcp.server.streamable_http_manager import StreamableHTTPSessionManager\nfrom mcp.server.transport_security import TransportSecuritySettings\nfrom starlette.routing import Route\n\nfrom app.config import get_settings\n\nmcp = FastMCP(\n \"osint-geoint\",\n instructions=(\n \"Live geospatial intelligence over open ADS-B (aircraft), AIS (vessels), \"\n \"GPS-jamming (ADS-B NACp/NIC), Sentinel-1 SAR dark vessels, geocoded \"\n \"events, and a cross-domain fusion engine. Workflow: get_situation() to \"\n \"orient -> intel_brief() for ranked, cited cross-domain INCIDENTS (the \"\n \"fused picture, global or scoped) -> focus_area(lat,lon,radius_nm) to \"\n \"load an incident's region PRIMARY -> query_vessels / gps_jamming / \"\n \"query_aircraft to drill into its evidence -> deep_analyze() to have a \"\n \"reasoning model judge it. intel_brief is the headline tool: it chains \"\n \"signals into incidents so you don't correlate raw layers by hand.\"\n ),\n)\n\n\n# ── hosted mount (streamable-HTTP at /mcp of the FastAPI app) ──────────────────\n# The agent-facing endpoint. Mounting the MCP into the SAME uvicorn process the\n# globe runs lets every tool share the warm in-process snapshot + fusion engine\n# (the tools still self-call /api/intel/* over localhost, authenticated by the\n# static API_KEY). The Velocity gateway Worker proxies https://<host>/mcp here,\n# forwarding the caller's Supabase token; the backend's ApiKeyMiddleware gates it\n# like any other non-public route, so no MCP-specific auth is needed.\n\n\nclass _MCPASGIApp:\n \"\"\"Raw-ASGI adapter for the streamable-HTTP session manager.\n\n A plain function endpoint would be wrapped by Starlette as a\n request/response handler; a class instance is left as a raw ASGI app, which\n is what the transport needs (it negotiates SSE itself).\n \"\"\"\n\n def __init__(self, manager: StreamableHTTPSessionManager) -> None:\n self._manager = manager\n\n async def __call__(self, scope: Any, receive: Any, send: Any) -> None:\n await self._manager.handle_request(scope, receive, send)\n\n\ndef build_mcp_mount() -> tuple[list[Route], StreamableHTTPSessionManager]:\n \"\"\"Return (routes, session_manager) to serve this server at ``/mcp``.\n\n A FRESH manager per call — ``StreamableHTTPSessionManager.run()`` is one-shot\n (it raises if entered twice), so every FastAPI app instance, including each\n test app, needs its own. Usage::\n\n routes, mgr = build_mcp_mount()\n app.router.routes.extend(routes) # in the app factory\n async with mgr.run(): ... yield # in the app lifespan\n\n Served as exact ``Route``s (``/mcp`` and ``/mcp/``), NOT a ``Mount`` — a\n mount 307-redirects ``/mcp`` → ``/mcp/`` with a Location built from the\n request host, which through the gateway Worker would point a client at the\n bare backend origin. DNS-rebinding protection is disabled: the endpoint is\n reached server-to-server through the Worker (the Host header is the\n deployment origin, not a browser tab), and the Supabase-token check in\n ``app.auth.ApiKeyMiddleware`` is the real gate.\n \"\"\"\n manager = StreamableHTTPSessionManager(\n app=mcp._mcp_server,\n json_response=False,\n stateless=False,\n security_settings=TransportSecuritySettings(\n enable_dns_rebinding_protection=False\n ),\n )\n endpoint = _MCPASGIApp(manager)\n routes = [Route(\"/mcp\", endpoint), Route(\"/mcp/\", endpoint)]\n return routes, manager\n\n\n# ── backend HTTP plumbing ─────────────────────────────────────────────────────\n\n\ndef _api_base() -> str:\n return (os.environ.get(\"API_BASE\") or get_settings().api_base).rstrip(\"/\")\n\n\ndef _headers() -> dict[str, str]:\n key = os.environ.get(\"API_KEY\") or get_settings().api_key\n return {\"X-API-Key\": key} if key else {}\n\n\n# ── backend auto-start ────────────────────────────────────────────────────────\n# The MCP server is a thin client over the OSINT backend. If that backend is\n# not running, every tool errors and a driving agent can spin in a retry loop.\n# So on the first tool call we make sure a backend exists: reuse one already\n# listening, else spawn uvicorn ourselves (localhost only) and wait — bounded —\n# for it to come up. Set OSINT_MCP_NO_AUTOSTART=1 to disable.\n\n_BACKEND_LOCK = asyncio.Lock()\n_BACKEND_READY = False\n_BACKEND_PROC: subprocess.Popen[bytes] | None = None\n_AUTOSTART_WAIT_S = 60\n\n\ndef _is_localhost(url: str) -> bool:\n host = (urlparse(url).hostname or \"\").lower()\n return host in {\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"::1\"}\n\n\ndef _api_port() -> int:\n return urlparse(_api_base()).port or 8000\n\n\ndef _apps_api_dir() -> str:\n import app # noqa: PLC0415\n\n return os.path.dirname(os.path.dirname(os.path.abspath(app.__file__)))\n\n\nasync def _backend_healthy(timeout_s: float = 2.0) -> bool:\n try:\n async with httpx.AsyncClient(timeout=timeout_s) as c:\n r = await c.get(_api_base() + \"/api/intel/sources\", headers=_headers())\n return r.status_code == 200\n except Exception:\n return False\n\n\ndef _spawn_uvicorn(cmd: list[str]) -> subprocess.Popen[bytes]:\n \"\"\"Sync spawn (Popen returns immediately; isolated from the async path).\"\"\"\n return subprocess.Popen(\n cmd,\n cwd=_apps_api_dir(),\n stdout=subprocess.DEVNULL,\n stderr=subprocess.DEVNULL,\n env=dict(os.environ),\n )\n\n\ndef _terminate_backend() -> None:\n proc = _BACKEND_PROC\n if proc is not None and proc.poll() is None:\n proc.terminate()\n\n\nasync def _prewarm() -> None:\n \"\"\"Kick the global ADS-B snapshot so the first get_situation isn't slow.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=120.0) as c:\n await c.get(_api_base() + \"/api/adsb/global\", headers=_headers())\n except Exception:\n pass\n\n\nasync def _ensure_backend() -> None:\n \"\"\"Idempotent: guarantee a reachable backend before a tool runs.\"\"\"\n global _BACKEND_READY, _BACKEND_PROC\n if _BACKEND_READY:\n return\n async with _BACKEND_LOCK:\n if _BACKEND_READY:\n return\n if await _backend_healthy():\n _BACKEND_READY = True\n return\n # Only auto-start a LOCAL backend, and only if not opted out.\n if os.environ.get(\"OSINT_MCP_NO_AUTOSTART\") or not _is_localhost(_api_base()):\n return\n # Spawn at most ONCE per process. If a prior attempt didn't come up,\n # don't re-spawn (avoids stacking uvicorns / re-waiting every call) —\n # just let later calls re-check health in case it's still warming.\n if _BACKEND_PROC is not None:\n return\n cmd = [\n sys.executable, \"-m\", \"uvicorn\", \"app.main:app\",\n \"--host\", \"127.0.0.1\", \"--port\", str(_api_port()), \"--log-level\", \"warning\",\n ]\n try:\n _BACKEND_PROC = _spawn_uvicorn(cmd)\n except Exception:\n return # can't spawn (e.g. uvicorn missing) — tools report cleanly\n atexit.register(_terminate_backend)\n for _ in range(_AUTOSTART_WAIT_S):\n await asyncio.sleep(1.0)\n if await _backend_healthy():\n _BACKEND_READY = True\n asyncio.create_task(_prewarm()) # warm snapshot off the hot path\n return\n # Did not come up in time; leave _BACKEND_READY False so the next tool\n # call retries once more rather than looping on a dead spawn.\n\n\nasync def _get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"GET an intel endpoint. Returns parsed JSON or a structured error dict —\n never raises, so a tool call always yields something the agent can read.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in (params or {}).items() if v is not None}\n # Generous ceiling: the backend's FIRST global call cold-starts the ADS-B\n # snapshot (firehose fetch + backoff) and can take up to ~75s. After that\n # every call is instant. A short timeout here would mis-report a healthy\n # backend as unreachable on the agent's very first query.\n try:\n async with httpx.AsyncClient(timeout=90.0) as client:\n r = await client.get(url, params=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\n \"error\": \"backend_unreachable\",\n \"detail\": str(exc),\n \"url\": url,\n \"hint\": \"Backend auto-start did not come up in time. It may still be \"\n \"warming — retry once. Or start it manually: uv run --project \"\n f\"{_apps_api_dir()} uvicorn app.main:app --port {_api_port()}\",\n }\n if r.status_code != 200:\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\n# ── tools ─────────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def get_situation() -> dict[str, Any]:\n \"\"\"Global situational snapshot — the cheap first call to orient.\n\n Returns total aircraft (airborne/ground, by category), GNSS-degraded count,\n active emergency squawks, the worst GPS-jamming cells worldwide, tracked\n vessel counts by category, and recent fusion-alert counts. A few hundred\n tokens describing the whole planet.\"\"\"\n return await _get(\"/api/intel/situation\")\n\n\n@mcp.tool()\nasync def focus_area(\n lat: float,\n lon: float,\n radius_nm: float = 200.0,\n label: str | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Load a region PRIMARY and return a full intel bundle for it in one call.\n\n This is the headline tool. It triggers a DEDICATED, always-fresh fetch for\n just this area (bypassing global rate limits) and registers it for ongoing\n priority refresh. The bundle contains: fresh aircraft summary + sample,\n aircraft-density grid, GPS-jamming assessment, vessels, and fused anomalies\n (emergencies, jamming hotspots, dark vessels, alerts) with a threat level.\n\n Args:\n lat, lon: centre of the area of interest.\n radius_nm: radius in nautical miles (1–250; default 200).\n label: optional human name for the AOI (e.g. \"Kaliningrad\").\n cell_deg: density grid cell size in degrees (0.1–10; default 1.0).\n \"\"\"\n return await _get(\n \"/api/intel/area\",\n {\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"label\": label,\n \"primary\": True,\n \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def aircraft_density(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Aircraft density over an area as a grid of cells (count, by category,\n GNSS-degraded per cell) plus totals, peak cell, and in-area vessel count.\n\n Give either a centre (lat, lon [, radius_nm]) or an explicit bbox\n (min_lon, min_lat, max_lon, max_lat). cell_deg sets grid resolution.\"\"\"\n return await _get(\n \"/api/intel/density\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat, \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def gps_jamming(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"GPS/GNSS jamming assessment (GPSJam method: ADS-B NACp<8 / NIC<7 binned\n into 1° cells). Returns flagged cells ranked by severity, counts of\n high/medium cells, and a sample of affected aircraft. Omit all coordinates\n for a global view; otherwise pass a centre or a bbox to scope it.\"\"\"\n return await _get(\n \"/api/intel/jamming\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def query_aircraft(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n category: str | None = None,\n squawk: str | None = None,\n callsign_contains: str | None = None,\n min_alt_m: float | None = None,\n max_alt_m: float | None = None,\n emergency: bool | None = None,\n gnss_degraded: bool | None = None,\n on_ground: bool | None = None,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Filtered aircraft query against the live snapshot. Returns matched_total\n + a capped list of compact records.\n\n category ∈ airliner|private|helicopter|glider|military|emergency.\n Combine with area (centre or bbox), squawk, callsign_contains, altitude\n band (metres), emergency / gnss_degraded / on_ground booleans. limit ≤200.\"\"\"\n return await _get(\n \"/api/intel/aircraft\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"category\": category, \"squawk\": squawk,\n \"callsign_contains\": callsign_contains,\n \"min_alt_m\": min_alt_m, \"max_alt_m\": max_alt_m,\n \"emergency\": emergency, \"gnss_degraded\": gnss_degraded,\n \"on_ground\": on_ground, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def lookup_aircraft(ident: str) -> dict[str, Any]:\n \"\"\"Look up one aircraft by ICAO24 hex (exact) or callsign (substring).\n Returns its current state, registration, an integrity/threat assessment,\n and the latest server-held fix.\"\"\"\n # quote(): ident is agent-supplied free text going into a URL path —\n # escape it so \"x/../y\" style values cannot re-route the request.\n return await _get(f\"/api/intel/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def query_vessels(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n dark_only: bool = False,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Vessels (AIS) in an area, classified (cargo/tanker/fishing/passenger/\n military/sailing/pleasure/tug). dark_only=True returns only dark-vessel\n candidates (moving with no static identity). Scoped by centre or bbox.\"\"\"\n return await _get(\n \"/api/intel/vessels\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"dark_only\": dark_only, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def anomalies(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"Fused anomaly report for an area (or global if no coords): emergency\n aircraft, GPS-jamming hotspots, dark-vessel candidates, recent fusion\n alerts, plus a triage threat_level (low|elevated|high) and score.\"\"\"\n return await _get(\n \"/api/intel/anomalies\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def intel_brief(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n link_km: float = 50.0,\n window_hours: float = 6.0,\n) -> dict[str, Any]:\n \"\"\"Cross-domain INCIDENT brief — the headline analytic tool.\n\n Fuses the raw layers into ranked, cited INCIDENTS instead of returning\n signals you have to correlate yourself. An incident is a CONVERGENCE: signals\n within ``link_km`` across >=2 domains (GPS-jamming, dark/AIS-off vessels,\n military air, AIS gaps, emergencies, geocoded events, quakes) — or a single\n critical/high signal. Each incident carries a rule-based narrative, a\n threat_level, the contributing evidence (with IDs), and recommended follow-up\n queries. Omit coordinates for a global brief; pass a centre+radius or bbox to\n scope it. Start here, then drill into an incident's centroid with\n query_vessels / gps_jamming / deep_analyze.\n \"\"\"\n return await _get(\n \"/api/intel/brief\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"link_km\": link_km, \"window_hours\": window_hours,\n },\n )\n\n\n@mcp.tool()\nasync def detect_deception(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n) -> dict[str, Any]:\n \"\"\"Denial & deception — \"am I being fed?\". Flags MANIPULATED tracks distinct\n from jamming: AIS duplicate-MMSI (one identity, two hulls) and impossible\n teleports; ADS-B GPS spoofing (many aircraft snapped to one false position)\n and kinematic position-injection. Run before trusting a feed in a contested\n area. Omit coords for global.\"\"\"\n return await _get(\n \"/api/intel/deception\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n\n\n@mcp.tool()\nasync def locate_emitter(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n) -> dict[str, Any]:\n \"\"\"Estimate a GPS jammer/spoofer LOCATION from the degraded-ADS-B footprint\n (severity-weighted centroid + CEP + confidence). Turns \"jamming somewhere\n here\" into \"emitter ~here ±N km\". Footprint-centroid estimate (~tens of km),\n not RF direction-finding — stated in the response. Scope with lat/lon.\"\"\"\n return await _get(\n \"/api/intel/emitter\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n\n\n@mcp.tool()\nasync def area_baseline(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n) -> dict[str, Any]:\n \"\"\"Is this normal? Current vessel / dark-vessel / jamming / military counts\n z-scored against a rolling baseline, with anomalies called out (e.g. \"dark\n vessels +5σ\", \"traffic -3σ\"). Global uses the background sampler; polling an\n AOI repeatedly builds that area's baseline. Distinguishes a real shift from\n a normal day.\"\"\"\n return await _get(\n \"/api/intel/baseline\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n\n\n@mcp.tool()\nasync def whats_changed(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n) -> dict[str, Any]:\n \"\"\"Standing watch — what CHANGED since the last check, not the full picture.\n\n Returns incidents that are NEW, ESCALATED, DE-ESCALATED, or RESOLVED. Global\n (no coords) reflects the background watch loop (recomputed ~every 60s); an\n AOI (lat/lon) diffs against YOUR previous whats_changed call for that area,\n so you can poll one region and be told only what moved. Use this to monitor\n instead of re-reading the whole brief each time.\n \"\"\"\n return await _get(\n \"/api/intel/watch\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n\n\n@mcp.tool()\nasync def incident_history(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n hours: float = 6.0,\n) -> dict[str, Any]:\n \"\"\"Timeline of how each incident built up over the recent window — per\n incident, the series of (time, threat_level, score) observations. Reveals\n sequence (e.g. jamming first, then dark vessels, then a reported event).\n Global uses the background watch history; an AOI uses your prior watch calls.\n \"\"\"\n return await _get(\n \"/api/intel/incident-history\",\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm, \"hours\": hours},\n )\n\n\n@mcp.tool()\nasync def vessel_dossier(mmsi: str) -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one vessel (MMSI): recent track, AIS gaps,\n derived speed profile (loiter / transit / loiter-then-dash), area covered,\n which live incidents it appears in, and a behaviour assessment. The track is\n the store's ~1h retention window.\"\"\"\n return await _get(f\"/api/intel/dossier/vessel/{quote(mmsi, safe='')}\")\n\n\n@mcp.tool()\nasync def aircraft_dossier(ident: str) -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one aircraft (ICAO24 hex or callsign): recent\n track, gaps, derived speed profile, GNSS-integrity, emergency/military flags,\n and which live incidents it appears in.\"\"\"\n return await _get(f\"/api/intel/dossier/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def list_focus_areas() -> dict[str, Any]:\n \"\"\"List the priority areas currently loaded PRIMARY (with fetch stats and\n whether each is served by a dedicated fetch or the degraded snapshot\n fallback).\"\"\"\n return await _get(\"/api/intel/aois\")\n\n\n@mcp.tool()\nasync def data_sources() -> dict[str, Any]:\n \"\"\"Which feeds are always-on vs key-gated, and the configured Ollama\n host/model. Use to explain coverage gaps (e.g. fires need a FIRMS key).\"\"\"\n return await _get(\"/api/intel/sources\")\n\n\n# ── deep analysis (DeepSeek reasoner, Ollama fallback) ────────────────────────\n\n_SYS_PROMPT = (\n \"You are a GEOINT analyst working a live open-source intelligence console. \"\n \"You are given distilled JSON from live ADS-B (aircraft), AIS (vessels), a \"\n \"GPS-jamming layer (ADS-B NACp/NIC, GPSJam method), and a cross-domain \"\n \"fusion engine whose `incident_brief` already chains co-located signals into \"\n \"ranked, cited INCIDENTS — lead your analysis from those incidents, then use \"\n \"the raw situation/area data to corroborate or challenge them. \"\n \"Reason ONLY over the provided JSON. Cite concrete numbers and IDs. Flag: \"\n \"GPS jamming/spoofing footprints, emergency squawks (7500 hijack / 7600 \"\n \"radio-fail / 7700 general), dark vessels, abnormal traffic density, and \"\n \"military activity. Be concise and structured: (1) ASSESSMENT (2 lines), \"\n \"(2) KEY FINDINGS (bullets with numbers), (3) RECOMMENDED FOLLOW-UP \"\n \"queries. If the data is thin, say so plainly — do not invent.\"\n)\n\n\n@mcp.tool()\nasync def deep_analyze(\n question: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 250.0,\n model: str | None = None,\n tier: str = \"reason\",\n) -> dict[str, Any]:\n \"\"\"Have a reasoning model reason deeply over the live intel.\n\n Gathers the relevant data (global situation, plus a focused area bundle +\n jamming + anomalies when lat/lon given) and feeds it to a real reasoning\n model — DeepSeek (``deepseek-reasoner``) when configured, else a local\n Ollama model. Heavy reasoning runs off-context; only the conclusion enters\n the agent's context. Falls back to returning the raw structured data\n (analysis=null) if no LLM backend is reachable.\n\n Args:\n question: what to analyse (e.g. \"Is there coordinated GPS jamming near\n the Baltic, and which aircraft are affected?\").\n lat, lon, radius_nm: optional area to focus on (loaded PRIMARY).\n model: optional Ollama model override used only on the Ollama fallback.\n tier: ``reason`` (deepseek-reasoner, default — judgement) or ``fast``\n (deepseek-chat — quicker, shallower).\n \"\"\"\n import json as _json\n\n from app import llm # noqa: PLC0415\n\n # 1) gather context (already compact). The cross-domain incident BRIEF is the\n # centrepiece — the reasoner judges fused, cited incidents rather than\n # re-correlating raw layers in its head.\n context: dict[str, Any] = {\"question\": question, \"situation\": await get_situation()}\n context[\"incident_brief\"] = await intel_brief(\n lat=lat, lon=lon, radius_nm=radius_nm,\n window_hours=12.0 if lat is None else 6.0,\n )\n if lat is not None and lon is not None:\n context[\"focus_area\"] = await focus_area(lat, lon, radius_nm)\n\n # 2) reason off-context (DeepSeek → Ollama → raw data)\n res = await llm.complete(\n _SYS_PROMPT,\n f\"QUESTION: {question}\\n\\nLIVE INTEL JSON:\\n\"\n + _json.dumps(context, separators=(\",\", \":\"))[:60000],\n tier=tier,\n temperature=0.2,\n max_tokens=2048,\n ollama_model=model or \"\",\n )\n if not res.ok:\n return {\n \"analysis\": None,\n \"model\": res.model,\n \"backend\": res.backend,\n \"note\": f\"No LLM backend reachable ({res.error}). Returning structured \"\n \"intel for you to analyse directly. Configure DEEPSEEK_API_KEY / the \"\n \"opencode DeepSeek key, or `ollama pull qwen2.5:3b`.\",\n \"data\": context,\n }\n\n return {\n \"analysis\": res.text,\n \"model\": res.model,\n \"backend\": res.backend,\n \"focused_on\": (\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm} if lat is not None else \"global\"\n ),\n \"data_summary\": {\n \"aircraft_total\": context[\"situation\"].get(\"aircraft\", {}).get(\"total\"),\n \"jamming_high\": context[\"situation\"].get(\"gps_jamming\", {}).get(\"high\"),\n },\n }\n\n\n# ── news intelligence (debias / fact-check) ──────────────────────────────────\n\n\n@mcp.tool()\nasync def news_analysis() -> dict[str, Any]:\n \"\"\"Cross-source, debiased world-news intelligence.\n\n Scrapes ~12 outlets (BBC, Reuters, AP, Al Jazeera, Guardian, CNN, Fox, …),\n then a reasoning model strips bias/propaganda and separates VERIFIED FACTS\n (corroborated by ≥2 independent outlets) from ATTRIBUTED CLAIMS and\n rhetoric. A leader promising \"the war will end soon\" is flagged as rhetoric,\n never reported as fact. Returns events with neutral_summary, verified_facts,\n attributed_claims, bias_flags, propaganda_techniques, rhetoric_flags and a\n confidence. May take ~30s when the cache is cold (it reasons over the feed).\n \"\"\"\n return await _get(\"/api/news/analysis\")\n\n\n@mcp.tool()\nasync def fact_check(claim: str) -> dict[str, Any]:\n \"\"\"Adjudicate one free-text claim against current world-news headlines.\n\n Returns ``{verdict: true|false|misleading|unverified, reasoning,\n supporting_sources, confidence}``. Use it to sanity-check a statement before\n treating it as fact.\n \"\"\"\n return await _get(\"/api/news/factcheck\", {\"claim\": claim})\n\n\n@mcp.tool()\nasync def aoi_imagery(\n before: str,\n after: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_km: float = 5.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n window_days: int = 30,\n) -> dict[str, Any]:\n \"\"\"Building imagery for a location at two dates — set time + place directly.\n\n Give a location (lat/lon + radius_km, OR an explicit min/max bbox) and a\n before + after date (YYYY-MM-DD). Returns what imagery is available for each\n date WITHOUT downloading: Maxar Open Data VHR (~0.3-0.5 m, event-gated — only\n where a disaster/conflict event covers the AOI) and Sentinel (10 m, global,\n any date). `best_source` says which to use. Reconstruction downloads the\n scenes to a temp dir on demand and discards them — nothing is stored.\n\n Args:\n before, after: dates as YYYY-MM-DD.\n lat, lon, radius_km: centre + radius (km) of the AOI.\n min_lon/min_lat/max_lon/max_lat: explicit bbox (overrides lat/lon).\n window_days: ± days around each date to search Maxar (events are sparse).\n \"\"\"\n params: dict[str, Any] = {\n \"before\": before,\n \"after\": after,\n \"radius_km\": radius_km,\n \"window_days\": window_days,\n }\n if None not in (min_lon, min_lat, max_lon, max_lat):\n params.update(min_lon=min_lon, min_lat=min_lat, max_lon=max_lon, max_lat=max_lat)\n else:\n params.update(lat=lat, lon=lon)\n return await _get(\"/api/imagery/aoi\", params)\n\n\n# ── entrypoint ─────────────────────────────────────────────────────────────────\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=\"OSINT GEOINT MCP server\")\n parser.add_argument(\"--http\", action=\"store_true\", help=\"serve over streamable-HTTP\")\n parser.add_argument(\"--port\", type=int, default=8765, help=\"HTTP port (with --http)\")\n parser.add_argument(\"--host\", default=\"127.0.0.1\", help=\"HTTP host (with --http)\")\n parser.add_argument(\n \"--list-tools\", action=\"store_true\", help=\"print registered tools and exit\"\n )\n args = parser.parse_args()\n\n if args.list_tools:\n tools = asyncio.run(mcp.list_tools())\n print(f\"osint-geoint MCP — {len(tools)} tools:\")\n for t in tools:\n summary = (t.description or \"\").split(\"\\n\", 1)[0]\n print(f\" • {t.name}: {summary}\")\n return\n\n if args.http:\n mcp.settings.host = args.host\n mcp.settings.port = args.port\n mcp.run(transport=\"streamable-http\")\n else:\n mcp.run() # stdio\n\n\nif __name__ == \"__main__\":\n main()\n"}, "95f3eb294962b9464ae79ef1c6a26d8db5892c98": {"t": 1785765054.6888766, "n": 27157, "h": "418a6f85460f42d2", "c": "\"\"\"OSINT GEOINT — Model Context Protocol server.\n\nExposes the live OSINT console as tools an AI agent can call. Every tool is a\nthin async wrapper over the ``/api/intel/*`` HTTP surface (``app.routes.intel``)\nso the agent shares the SAME warm in-process snapshot, AOI priority loader, and\nfusion engine the globe uses — no second upstream fan-out, no rate-limit blowup.\n\nDesign principles (the MCP brief):\n- **Context-safe.** Tools return distilled JSON (counts, grids, ≤50-item\n samples), never raw feature dumps. An agent can sweep the whole planet for a\n few hundred tokens.\n- **Area-primary.** ``focus_area`` marks a region PRIMARY: a dedicated fresh\n fetch + ongoing priority refresh, independent of global rate limits. Other\n regions keep streaming from the global snapshot.\n- **Think deeper, off-context.** ``deep_analyze`` pulls the relevant intel JSON\n and hands it to a real reasoning model — DeepSeek (``deepseek-reasoner``) when\n configured, else a local Ollama model — so heavy analysis happens off the\n agent's context and only the conclusion returns.\n\nRun:\n python -m app.mcp_server # stdio (Claude Code / Desktop / SDK)\n python -m app.mcp_server --http --port 8765 # streamable-HTTP\n python -m app.mcp_server --list-tools # introspect, for CI / verification\n\nConfig (env or apps/api/.env): API_BASE, API_KEY, DEEPSEEK_API_KEY (or the\nopencode DeepSeek key), OLLAMA_HOST, OLLAMA_MODEL.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport atexit\nimport os\nimport subprocess\nimport sys\nfrom typing import Any\nfrom urllib.parse import quote, urlparse\n\nimport httpx\nfrom mcp.server.fastmcp import FastMCP\n\nfrom app.config import get_settings\n\nmcp = FastMCP(\n \"osint-geoint\",\n instructions=(\n \"Live geospatial intelligence over open ADS-B (aircraft), AIS (vessels), \"\n \"GPS-jamming (ADS-B NACp/NIC), Sentinel-1 SAR dark vessels, geocoded \"\n \"events, and a cross-domain fusion engine. Workflow: get_situation() to \"\n \"orient -> intel_brief() for ranked, cited cross-domain INCIDENTS (the \"\n \"fused picture, global or scoped) -> focus_area(lat,lon,radius_nm) to \"\n \"load an incident's region PRIMARY -> query_vessels / gps_jamming / \"\n \"query_aircraft to drill into its evidence -> deep_analyze() to have a \"\n \"reasoning model judge it. intel_brief is the headline tool: it chains \"\n \"signals into incidents so you don't correlate raw layers by hand.\"\n ),\n)\n\n\n# ── backend HTTP plumbing ─────────────────────────────────────────────────────\n\n\ndef _api_base() -> str:\n return (os.environ.get(\"API_BASE\") or get_settings().api_base).rstrip(\"/\")\n\n\ndef _headers() -> dict[str, str]:\n key = os.environ.get(\"API_KEY\") or get_settings().api_key\n return {\"X-API-Key\": key} if key else {}\n\n\n# ── backend auto-start ────────────────────────────────────────────────────────\n# The MCP server is a thin client over the OSINT backend. If that backend is\n# not running, every tool errors and a driving agent can spin in a retry loop.\n# So on the first tool call we make sure a backend exists: reuse one already\n# listening, else spawn uvicorn ourselves (localhost only) and wait — bounded —\n# for it to come up. Set OSINT_MCP_NO_AUTOSTART=1 to disable.\n\n_BACKEND_LOCK = asyncio.Lock()\n_BACKEND_READY = False\n_BACKEND_PROC: subprocess.Popen[bytes] | None = None\n_AUTOSTART_WAIT_S = 60\n\n\ndef _is_localhost(url: str) -> bool:\n host = (urlparse(url).hostname or \"\").lower()\n return host in {\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"::1\"}\n\n\ndef _api_port() -> int:\n return urlparse(_api_base()).port or 8000\n\n\ndef _apps_api_dir() -> str:\n import app # noqa: PLC0415\n\n return os.path.dirname(os.path.dirname(os.path.abspath(app.__file__)))\n\n\nasync def _backend_healthy(timeout_s: float = 2.0) -> bool:\n try:\n async with httpx.AsyncClient(timeout=timeout_s) as c:\n r = await c.get(_api_base() + \"/api/intel/sources\", headers=_headers())\n return r.status_code == 200\n except Exception:\n return False\n\n\ndef _spawn_uvicorn(cmd: list[str]) -> subprocess.Popen[bytes]:\n \"\"\"Sync spawn (Popen returns immediately; isolated from the async path).\"\"\"\n return subprocess.Popen(\n cmd,\n cwd=_apps_api_dir(),\n stdout=subprocess.DEVNULL,\n stderr=subprocess.DEVNULL,\n env=dict(os.environ),\n )\n\n\ndef _terminate_backend() -> None:\n proc = _BACKEND_PROC\n if proc is not None and proc.poll() is None:\n proc.terminate()\n\n\nasync def _prewarm() -> None:\n \"\"\"Kick the global ADS-B snapshot so the first get_situation isn't slow.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=120.0) as c:\n await c.get(_api_base() + \"/api/adsb/global\", headers=_headers())\n except Exception:\n pass\n\n\nasync def _ensure_backend() -> None:\n \"\"\"Idempotent: guarantee a reachable backend before a tool runs.\"\"\"\n global _BACKEND_READY, _BACKEND_PROC\n if _BACKEND_READY:\n return\n async with _BACKEND_LOCK:\n if _BACKEND_READY:\n return\n if await _backend_healthy():\n _BACKEND_READY = True\n return\n # Only auto-start a LOCAL backend, and only if not opted out.\n if os.environ.get(\"OSINT_MCP_NO_AUTOSTART\") or not _is_localhost(_api_base()):\n return\n # Spawn at most ONCE per process. If a prior attempt didn't come up,\n # don't re-spawn (avoids stacking uvicorns / re-waiting every call) —\n # just let later calls re-check health in case it's still warming.\n if _BACKEND_PROC is not None:\n return\n cmd = [\n sys.executable, \"-m\", \"uvicorn\", \"app.main:app\",\n \"--host\", \"127.0.0.1\", \"--port\", str(_api_port()), \"--log-level\", \"warning\",\n ]\n try:\n _BACKEND_PROC = _spawn_uvicorn(cmd)\n except Exception:\n return # can't spawn (e.g. uvicorn missing) — tools report cleanly\n atexit.register(_terminate_backend)\n for _ in range(_AUTOSTART_WAIT_S):\n await asyncio.sleep(1.0)\n if await _backend_healthy():\n _BACKEND_READY = True\n asyncio.create_task(_prewarm()) # warm snapshot off the hot path\n return\n # Did not come up in time; leave _BACKEND_READY False so the next tool\n # call retries once more rather than looping on a dead spawn.\n\n\nasync def _get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"GET an intel endpoint. Returns parsed JSON or a structured error dict —\n never raises, so a tool call always yields something the agent can read.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in (params or {}).items() if v is not None}\n # Generous ceiling: the backend's FIRST global call cold-starts the ADS-B\n # snapshot (firehose fetch + backoff) and can take up to ~75s. After that\n # every call is instant. A short timeout here would mis-report a healthy\n # backend as unreachable on the agent's very first query.\n try:\n async with httpx.AsyncClient(timeout=90.0) as client:\n r = await client.get(url, params=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\n \"error\": \"backend_unreachable\",\n \"detail\": str(exc),\n \"url\": url,\n \"hint\": \"Backend auto-start did not come up in time. It may still be \"\n \"warming — retry once. Or start it manually: uv run --project \"\n f\"{_apps_api_dir()} uvicorn app.main:app --port {_api_port()}\",\n }\n if r.status_code != 200:\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\n# ── tools ─────────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def get_situation() -> dict[str, Any]:\n \"\"\"Global situational snapshot — the cheap first call to orient.\n\n Returns total aircraft (airborne/ground, by category), GNSS-degraded count,\n active emergency squawks, the worst GPS-jamming cells worldwide, tracked\n vessel counts by category, and recent fusion-alert counts. A few hundred\n tokens describing the whole planet.\"\"\"\n return await _get(\"/api/intel/situation\")\n\n\n@mcp.tool()\nasync def focus_area(\n lat: float,\n lon: float,\n radius_nm: float = 200.0,\n label: str | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Load a region PRIMARY and return a full intel bundle for it in one call.\n\n This is the headline tool. It triggers a DEDICATED, always-fresh fetch for\n just this area (bypassing global rate limits) and registers it for ongoing\n priority refresh. The bundle contains: fresh aircraft summary + sample,\n aircraft-density grid, GPS-jamming assessment, vessels, and fused anomalies\n (emergencies, jamming hotspots, dark vessels, alerts) with a threat level.\n\n Args:\n lat, lon: centre of the area of interest.\n radius_nm: radius in nautical miles (1–250; default 200).\n label: optional human name for the AOI (e.g. \"Kaliningrad\").\n cell_deg: density grid cell size in degrees (0.1–10; default 1.0).\n \"\"\"\n return await _get(\n \"/api/intel/area\",\n {\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"label\": label,\n \"primary\": True,\n \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def aircraft_density(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Aircraft density over an area as a grid of cells (count, by category,\n GNSS-degraded per cell) plus totals, peak cell, and in-area vessel count.\n\n Give either a centre (lat, lon [, radius_nm]) or an explicit bbox\n (min_lon, min_lat, max_lon, max_lat). cell_deg sets grid resolution.\"\"\"\n return await _get(\n \"/api/intel/density\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat, \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def gps_jamming(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"GPS/GNSS jamming assessment (GPSJam method: ADS-B NACp<8 / NIC<7 binned\n into 1° cells). Returns flagged cells ranked by severity, counts of\n high/medium cells, and a sample of affected aircraft. Omit all coordinates\n for a global view; otherwise pass a centre or a bbox to scope it.\"\"\"\n return await _get(\n \"/api/intel/jamming\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def query_aircraft(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n category: str | None = None,\n squawk: str | None = None,\n callsign_contains: str | None = None,\n min_alt_m: float | None = None,\n max_alt_m: float | None = None,\n emergency: bool | None = None,\n gnss_degraded: bool | None = None,\n on_ground: bool | None = None,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Filtered aircraft query against the live snapshot. Returns matched_total\n + a capped list of compact records.\n\n category ∈ airliner|private|helicopter|glider|military|emergency.\n Combine with area (centre or bbox), squawk, callsign_contains, altitude\n band (metres), emergency / gnss_degraded / on_ground booleans. limit ≤200.\"\"\"\n return await _get(\n \"/api/intel/aircraft\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"category\": category, \"squawk\": squawk,\n \"callsign_contains\": callsign_contains,\n \"min_alt_m\": min_alt_m, \"max_alt_m\": max_alt_m,\n \"emergency\": emergency, \"gnss_degraded\": gnss_degraded,\n \"on_ground\": on_ground, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def lookup_aircraft(ident: str) -> dict[str, Any]:\n \"\"\"Look up one aircraft by ICAO24 hex (exact) or callsign (substring).\n Returns its current state, registration, an integrity/threat assessment,\n and the latest server-held fix.\"\"\"\n # quote(): ident is agent-supplied free text going into a URL path —\n # escape it so \"x/../y\" style values cannot re-route the request.\n return await _get(f\"/api/intel/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def query_vessels(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n dark_only: bool = False,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Vessels (AIS) in an area, classified (cargo/tanker/fishing/passenger/\n military/sailing/pleasure/tug). dark_only=True returns only dark-vessel\n candidates (moving with no static identity). Scoped by centre or bbox.\"\"\"\n return await _get(\n \"/api/intel/vessels\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"dark_only\": dark_only, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def anomalies(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"Fused anomaly report for an area (or global if no coords): emergency\n aircraft, GPS-jamming hotspots, dark-vessel candidates, recent fusion\n alerts, plus a triage threat_level (low|elevated|high) and score.\"\"\"\n return await _get(\n \"/api/intel/anomalies\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def intel_brief(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n link_km: float = 50.0,\n window_hours: float = 6.0,\n) -> dict[str, Any]:\n \"\"\"Cross-domain INCIDENT brief — the headline analytic tool.\n\n Fuses the raw layers into ranked, cited INCIDENTS instead of returning\n signals you have to correlate yourself. An incident is a CONVERGENCE: signals\n within ``link_km`` across >=2 domains (GPS-jamming, dark/AIS-off vessels,\n military air, AIS gaps, emergencies, geocoded events, quakes) — or a single\n critical/high signal. Each incident carries a rule-based narrative, a\n threat_level, the contributing evidence (with IDs), and recommended follow-up\n queries. Omit coordinates for a global brief; pass a centre+radius or bbox to\n scope it. Start here, then drill into an incident's centroid with\n query_vessels / gps_jamming / deep_analyze.\n \"\"\"\n return await _get(\n \"/api/intel/brief\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"link_km\": link_km, \"window_hours\": window_hours,\n },\n )\n\n\n@mcp.tool()\nasync def detect_deception(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n) -> dict[str, Any]:\n \"\"\"Denial & deception — \"am I being fed?\". Flags MANIPULATED tracks distinct\n from jamming: AIS duplicate-MMSI (one identity, two hulls) and impossible\n teleports; ADS-B GPS spoofing (many aircraft snapped to one false position)\n and kinematic position-injection. Run before trusting a feed in a contested\n area. Omit coords for global.\"\"\"\n return await _get(\n \"/api/intel/deception\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n\n\n@mcp.tool()\nasync def locate_emitter(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n) -> dict[str, Any]:\n \"\"\"Estimate a GPS jammer/spoofer LOCATION from the degraded-ADS-B footprint\n (severity-weighted centroid + CEP + confidence). Turns \"jamming somewhere\n here\" into \"emitter ~here ±N km\". Footprint-centroid estimate (~tens of km),\n not RF direction-finding — stated in the response. Scope with lat/lon.\"\"\"\n return await _get(\n \"/api/intel/emitter\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n\n\n@mcp.tool()\nasync def area_baseline(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n) -> dict[str, Any]:\n \"\"\"Is this normal? Current vessel / dark-vessel / jamming / military counts\n z-scored against a rolling baseline, with anomalies called out (e.g. \"dark\n vessels +5σ\", \"traffic -3σ\"). Global uses the background sampler; polling an\n AOI repeatedly builds that area's baseline. Distinguishes a real shift from\n a normal day.\"\"\"\n return await _get(\n \"/api/intel/baseline\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n\n\n@mcp.tool()\nasync def whats_changed(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n) -> dict[str, Any]:\n \"\"\"Standing watch — what CHANGED since the last check, not the full picture.\n\n Returns incidents that are NEW, ESCALATED, DE-ESCALATED, or RESOLVED. Global\n (no coords) reflects the background watch loop (recomputed ~every 60s); an\n AOI (lat/lon) diffs against YOUR previous whats_changed call for that area,\n so you can poll one region and be told only what moved. Use this to monitor\n instead of re-reading the whole brief each time.\n \"\"\"\n return await _get(\n \"/api/intel/watch\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n\n\n@mcp.tool()\nasync def incident_history(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n hours: float = 6.0,\n) -> dict[str, Any]:\n \"\"\"Timeline of how each incident built up over the recent window — per\n incident, the series of (time, threat_level, score) observations. Reveals\n sequence (e.g. jamming first, then dark vessels, then a reported event).\n Global uses the background watch history; an AOI uses your prior watch calls.\n \"\"\"\n return await _get(\n \"/api/intel/incident-history\",\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm, \"hours\": hours},\n )\n\n\n@mcp.tool()\nasync def vessel_dossier(mmsi: str) -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one vessel (MMSI): recent track, AIS gaps,\n derived speed profile (loiter / transit / loiter-then-dash), area covered,\n which live incidents it appears in, and a behaviour assessment. The track is\n the store's ~1h retention window.\"\"\"\n return await _get(f\"/api/intel/dossier/vessel/{quote(mmsi, safe='')}\")\n\n\n@mcp.tool()\nasync def aircraft_dossier(ident: str) -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one aircraft (ICAO24 hex or callsign): recent\n track, gaps, derived speed profile, GNSS-integrity, emergency/military flags,\n and which live incidents it appears in.\"\"\"\n return await _get(f\"/api/intel/dossier/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def list_focus_areas() -> dict[str, Any]:\n \"\"\"List the priority areas currently loaded PRIMARY (with fetch stats and\n whether each is served by a dedicated fetch or the degraded snapshot\n fallback).\"\"\"\n return await _get(\"/api/intel/aois\")\n\n\n@mcp.tool()\nasync def data_sources() -> dict[str, Any]:\n \"\"\"Which feeds are always-on vs key-gated, and the configured Ollama\n host/model. Use to explain coverage gaps (e.g. fires need a FIRMS key).\"\"\"\n return await _get(\"/api/intel/sources\")\n\n\n# ── deep analysis (DeepSeek reasoner, Ollama fallback) ────────────────────────\n\n_SYS_PROMPT = (\n \"You are a GEOINT analyst working a live open-source intelligence console. \"\n \"You are given distilled JSON from live ADS-B (aircraft), AIS (vessels), a \"\n \"GPS-jamming layer (ADS-B NACp/NIC, GPSJam method), and a cross-domain \"\n \"fusion engine whose `incident_brief` already chains co-located signals into \"\n \"ranked, cited INCIDENTS — lead your analysis from those incidents, then use \"\n \"the raw situation/area data to corroborate or challenge them. \"\n \"Reason ONLY over the provided JSON. Cite concrete numbers and IDs. Flag: \"\n \"GPS jamming/spoofing footprints, emergency squawks (7500 hijack / 7600 \"\n \"radio-fail / 7700 general), dark vessels, abnormal traffic density, and \"\n \"military activity. Be concise and structured: (1) ASSESSMENT (2 lines), \"\n \"(2) KEY FINDINGS (bullets with numbers), (3) RECOMMENDED FOLLOW-UP \"\n \"queries. If the data is thin, say so plainly — do not invent.\"\n)\n\n\n@mcp.tool()\nasync def deep_analyze(\n question: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 250.0,\n model: str | None = None,\n tier: str = \"reason\",\n) -> dict[str, Any]:\n \"\"\"Have a reasoning model reason deeply over the live intel.\n\n Gathers the relevant data (global situation, plus a focused area bundle +\n jamming + anomalies when lat/lon given) and feeds it to a real reasoning\n model — DeepSeek (``deepseek-reasoner``) when configured, else a local\n Ollama model. Heavy reasoning runs off-context; only the conclusion enters\n the agent's context. Falls back to returning the raw structured data\n (analysis=null) if no LLM backend is reachable.\n\n Args:\n question: what to analyse (e.g. \"Is there coordinated GPS jamming near\n the Baltic, and which aircraft are affected?\").\n lat, lon, radius_nm: optional area to focus on (loaded PRIMARY).\n model: optional Ollama model override used only on the Ollama fallback.\n tier: ``reason`` (deepseek-reasoner, default — judgement) or ``fast``\n (deepseek-chat — quicker, shallower).\n \"\"\"\n import json as _json\n\n from app import llm # noqa: PLC0415\n\n # 1) gather context (already compact). The cross-domain incident BRIEF is the\n # centrepiece — the reasoner judges fused, cited incidents rather than\n # re-correlating raw layers in its head.\n context: dict[str, Any] = {\"question\": question, \"situation\": await get_situation()}\n context[\"incident_brief\"] = await intel_brief(\n lat=lat, lon=lon, radius_nm=radius_nm,\n window_hours=12.0 if lat is None else 6.0,\n )\n if lat is not None and lon is not None:\n context[\"focus_area\"] = await focus_area(lat, lon, radius_nm)\n\n # 2) reason off-context (DeepSeek → Ollama → raw data)\n res = await llm.complete(\n _SYS_PROMPT,\n f\"QUESTION: {question}\\n\\nLIVE INTEL JSON:\\n\"\n + _json.dumps(context, separators=(\",\", \":\"))[:60000],\n tier=tier,\n temperature=0.2,\n max_tokens=2048,\n ollama_model=model or \"\",\n )\n if not res.ok:\n return {\n \"analysis\": None,\n \"model\": res.model,\n \"backend\": res.backend,\n \"note\": f\"No LLM backend reachable ({res.error}). Returning structured \"\n \"intel for you to analyse directly. Configure DEEPSEEK_API_KEY / the \"\n \"opencode DeepSeek key, or `ollama pull qwen2.5:3b`.\",\n \"data\": context,\n }\n\n return {\n \"analysis\": res.text,\n \"model\": res.model,\n \"backend\": res.backend,\n \"focused_on\": (\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm} if lat is not None else \"global\"\n ),\n \"data_summary\": {\n \"aircraft_total\": context[\"situation\"].get(\"aircraft\", {}).get(\"total\"),\n \"jamming_high\": context[\"situation\"].get(\"gps_jamming\", {}).get(\"high\"),\n },\n }\n\n\n# ── news intelligence (debias / fact-check) ──────────────────────────────────\n\n\n@mcp.tool()\nasync def news_analysis() -> dict[str, Any]:\n \"\"\"Cross-source, debiased world-news intelligence.\n\n Scrapes ~12 outlets (BBC, Reuters, AP, Al Jazeera, Guardian, CNN, Fox, …),\n then a reasoning model strips bias/propaganda and separates VERIFIED FACTS\n (corroborated by ≥2 independent outlets) from ATTRIBUTED CLAIMS and\n rhetoric. A leader promising \"the war will end soon\" is flagged as rhetoric,\n never reported as fact. Returns events with neutral_summary, verified_facts,\n attributed_claims, bias_flags, propaganda_techniques, rhetoric_flags and a\n confidence. May take ~30s when the cache is cold (it reasons over the feed).\n \"\"\"\n return await _get(\"/api/news/analysis\")\n\n\n@mcp.tool()\nasync def fact_check(claim: str) -> dict[str, Any]:\n \"\"\"Adjudicate one free-text claim against current world-news headlines.\n\n Returns ``{verdict: true|false|misleading|unverified, reasoning,\n supporting_sources, confidence}``. Use it to sanity-check a statement before\n treating it as fact.\n \"\"\"\n return await _get(\"/api/news/factcheck\", {\"claim\": claim})\n\n\n# ── entrypoint ─────────────────────────────────────────────────────────────────\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=\"OSINT GEOINT MCP server\")\n parser.add_argument(\"--http\", action=\"store_true\", help=\"serve over streamable-HTTP\")\n parser.add_argument(\"--port\", type=int, default=8765, help=\"HTTP port (with --http)\")\n parser.add_argument(\"--host\", default=\"127.0.0.1\", help=\"HTTP host (with --http)\")\n parser.add_argument(\n \"--list-tools\", action=\"store_true\", help=\"print registered tools and exit\"\n )\n args = parser.parse_args()\n\n if args.list_tools:\n tools = asyncio.run(mcp.list_tools())\n print(f\"osint-geoint MCP — {len(tools)} tools:\")\n for t in tools:\n summary = (t.description or \"\").split(\"\\n\", 1)[0]\n print(f\" • {t.name}: {summary}\")\n return\n\n if args.http:\n mcp.settings.host = args.host\n mcp.settings.port = args.port\n mcp.run(transport=\"streamable-http\")\n else:\n mcp.run() # stdio\n\n\nif __name__ == \"__main__\":\n main()\n"}, "e1cc066bc27d231eb6e90ba81390e486828485c7": {"t": 1785765057.6920176, "n": 25362, "h": "b7bf195b7c9a06ce", "c": "\"\"\"OSINT GEOINT — Model Context Protocol server.\n\nExposes the live OSINT console as tools an AI agent can call. Every tool is a\nthin async wrapper over the ``/api/intel/*`` HTTP surface (``app.routes.intel``)\nso the agent shares the SAME warm in-process snapshot, AOI priority loader, and\nfusion engine the globe uses — no second upstream fan-out, no rate-limit blowup.\n\nDesign principles (the MCP brief):\n- **Context-safe.** Tools return distilled JSON (counts, grids, ≤50-item\n samples), never raw feature dumps. An agent can sweep the whole planet for a\n few hundred tokens.\n- **Area-primary.** ``focus_area`` marks a region PRIMARY: a dedicated fresh\n fetch + ongoing priority refresh, independent of global rate limits. Other\n regions keep streaming from the global snapshot.\n- **Think deeper, off-context.** ``deep_analyze`` pulls the relevant intel JSON\n and hands it to a real reasoning model — DeepSeek (``deepseek-reasoner``) when\n configured, else a local Ollama model — so heavy analysis happens off the\n agent's context and only the conclusion returns.\n\nRun:\n python -m app.mcp_server # stdio (Claude Code / Desktop / SDK)\n python -m app.mcp_server --http --port 8765 # streamable-HTTP\n python -m app.mcp_server --list-tools # introspect, for CI / verification\n\nConfig (env or apps/api/.env): API_BASE, API_KEY, DEEPSEEK_API_KEY (or the\nopencode DeepSeek key), OLLAMA_HOST, OLLAMA_MODEL.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport atexit\nimport os\nimport subprocess\nimport sys\nfrom typing import Any\nfrom urllib.parse import quote, urlparse\n\nimport httpx\nfrom mcp.server.fastmcp import FastMCP\n\nfrom app.config import get_settings\n\nmcp = FastMCP(\n \"osint-geoint\",\n instructions=(\n \"Live geospatial intelligence over open ADS-B (aircraft), AIS (vessels), \"\n \"GPS-jamming (ADS-B NACp/NIC), Sentinel-1 SAR dark vessels, geocoded \"\n \"events, and a cross-domain fusion engine. Workflow: get_situation() to \"\n \"orient -> intel_brief() for ranked, cited cross-domain INCIDENTS (the \"\n \"fused picture, global or scoped) -> focus_area(lat,lon,radius_nm) to \"\n \"load an incident's region PRIMARY -> query_vessels / gps_jamming / \"\n \"query_aircraft to drill into its evidence -> deep_analyze() to have a \"\n \"reasoning model judge it. intel_brief is the headline tool: it chains \"\n \"signals into incidents so you don't correlate raw layers by hand.\"\n ),\n)\n\n\n# ── backend HTTP plumbing ─────────────────────────────────────────────────────\n\n\ndef _api_base() -> str:\n return (os.environ.get(\"API_BASE\") or get_settings().api_base).rstrip(\"/\")\n\n\ndef _headers() -> dict[str, str]:\n key = os.environ.get(\"API_KEY\") or get_settings().api_key\n return {\"X-API-Key\": key} if key else {}\n\n\n# ── backend auto-start ────────────────────────────────────────────────────────\n# The MCP server is a thin client over the OSINT backend. If that backend is\n# not running, every tool errors and a driving agent can spin in a retry loop.\n# So on the first tool call we make sure a backend exists: reuse one already\n# listening, else spawn uvicorn ourselves (localhost only) and wait — bounded —\n# for it to come up. Set OSINT_MCP_NO_AUTOSTART=1 to disable.\n\n_BACKEND_LOCK = asyncio.Lock()\n_BACKEND_READY = False\n_BACKEND_PROC: subprocess.Popen[bytes] | None = None\n_AUTOSTART_WAIT_S = 60\n\n\ndef _is_localhost(url: str) -> bool:\n host = (urlparse(url).hostname or \"\").lower()\n return host in {\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"::1\"}\n\n\ndef _api_port() -> int:\n return urlparse(_api_base()).port or 8000\n\n\ndef _apps_api_dir() -> str:\n import app # noqa: PLC0415\n\n return os.path.dirname(os.path.dirname(os.path.abspath(app.__file__)))\n\n\nasync def _backend_healthy(timeout_s: float = 2.0) -> bool:\n try:\n async with httpx.AsyncClient(timeout=timeout_s) as c:\n r = await c.get(_api_base() + \"/api/intel/sources\", headers=_headers())\n return r.status_code == 200\n except Exception:\n return False\n\n\ndef _spawn_uvicorn(cmd: list[str]) -> subprocess.Popen[bytes]:\n \"\"\"Sync spawn (Popen returns immediately; isolated from the async path).\"\"\"\n return subprocess.Popen(\n cmd,\n cwd=_apps_api_dir(),\n stdout=subprocess.DEVNULL,\n stderr=subprocess.DEVNULL,\n env=dict(os.environ),\n )\n\n\ndef _terminate_backend() -> None:\n proc = _BACKEND_PROC\n if proc is not None and proc.poll() is None:\n proc.terminate()\n\n\nasync def _prewarm() -> None:\n \"\"\"Kick the global ADS-B snapshot so the first get_situation isn't slow.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=120.0) as c:\n await c.get(_api_base() + \"/api/adsb/global\", headers=_headers())\n except Exception:\n pass\n\n\nasync def _ensure_backend() -> None:\n \"\"\"Idempotent: guarantee a reachable backend before a tool runs.\"\"\"\n global _BACKEND_READY, _BACKEND_PROC\n if _BACKEND_READY:\n return\n async with _BACKEND_LOCK:\n if _BACKEND_READY:\n return\n if await _backend_healthy():\n _BACKEND_READY = True\n return\n # Only auto-start a LOCAL backend, and only if not opted out.\n if os.environ.get(\"OSINT_MCP_NO_AUTOSTART\") or not _is_localhost(_api_base()):\n return\n # Spawn at most ONCE per process. If a prior attempt didn't come up,\n # don't re-spawn (avoids stacking uvicorns / re-waiting every call) —\n # just let later calls re-check health in case it's still warming.\n if _BACKEND_PROC is not None:\n return\n cmd = [\n sys.executable, \"-m\", \"uvicorn\", \"app.main:app\",\n \"--host\", \"127.0.0.1\", \"--port\", str(_api_port()), \"--log-level\", \"warning\",\n ]\n try:\n _BACKEND_PROC = _spawn_uvicorn(cmd)\n except Exception:\n return # can't spawn (e.g. uvicorn missing) — tools report cleanly\n atexit.register(_terminate_backend)\n for _ in range(_AUTOSTART_WAIT_S):\n await asyncio.sleep(1.0)\n if await _backend_healthy():\n _BACKEND_READY = True\n asyncio.create_task(_prewarm()) # warm snapshot off the hot path\n return\n # Did not come up in time; leave _BACKEND_READY False so the next tool\n # call retries once more rather than looping on a dead spawn.\n\n\nasync def _get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"GET an intel endpoint. Returns parsed JSON or a structured error dict —\n never raises, so a tool call always yields something the agent can read.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in (params or {}).items() if v is not None}\n # Generous ceiling: the backend's FIRST global call cold-starts the ADS-B\n # snapshot (firehose fetch + backoff) and can take up to ~75s. After that\n # every call is instant. A short timeout here would mis-report a healthy\n # backend as unreachable on the agent's very first query.\n try:\n async with httpx.AsyncClient(timeout=90.0) as client:\n r = await client.get(url, params=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\n \"error\": \"backend_unreachable\",\n \"detail\": str(exc),\n \"url\": url,\n \"hint\": \"Backend auto-start did not come up in time. It may still be \"\n \"warming — retry once. Or start it manually: uv run --project \"\n f\"{_apps_api_dir()} uvicorn app.main:app --port {_api_port()}\",\n }\n if r.status_code != 200:\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\n# ── tools ─────────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def get_situation() -> dict[str, Any]:\n \"\"\"Global situational snapshot — the cheap first call to orient.\n\n Returns total aircraft (airborne/ground, by category), GNSS-degraded count,\n active emergency squawks, the worst GPS-jamming cells worldwide, tracked\n vessel counts by category, and recent fusion-alert counts. A few hundred\n tokens describing the whole planet.\"\"\"\n return await _get(\"/api/intel/situation\")\n\n\n@mcp.tool()\nasync def focus_area(\n lat: float,\n lon: float,\n radius_nm: float = 200.0,\n label: str | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Load a region PRIMARY and return a full intel bundle for it in one call.\n\n This is the headline tool. It triggers a DEDICATED, always-fresh fetch for\n just this area (bypassing global rate limits) and registers it for ongoing\n priority refresh. The bundle contains: fresh aircraft summary + sample,\n aircraft-density grid, GPS-jamming assessment, vessels, and fused anomalies\n (emergencies, jamming hotspots, dark vessels, alerts) with a threat level.\n\n Args:\n lat, lon: centre of the area of interest.\n radius_nm: radius in nautical miles (1–250; default 200).\n label: optional human name for the AOI (e.g. \"Kaliningrad\").\n cell_deg: density grid cell size in degrees (0.1–10; default 1.0).\n \"\"\"\n return await _get(\n \"/api/intel/area\",\n {\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"label\": label,\n \"primary\": True,\n \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def aircraft_density(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Aircraft density over an area as a grid of cells (count, by category,\n GNSS-degraded per cell) plus totals, peak cell, and in-area vessel count.\n\n Give either a centre (lat, lon [, radius_nm]) or an explicit bbox\n (min_lon, min_lat, max_lon, max_lat). cell_deg sets grid resolution.\"\"\"\n return await _get(\n \"/api/intel/density\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat, \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def gps_jamming(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"GPS/GNSS jamming assessment (GPSJam method: ADS-B NACp<8 / NIC<7 binned\n into 1° cells). Returns flagged cells ranked by severity, counts of\n high/medium cells, and a sample of affected aircraft. Omit all coordinates\n for a global view; otherwise pass a centre or a bbox to scope it.\"\"\"\n return await _get(\n \"/api/intel/jamming\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def query_aircraft(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n category: str | None = None,\n squawk: str | None = None,\n callsign_contains: str | None = None,\n min_alt_m: float | None = None,\n max_alt_m: float | None = None,\n emergency: bool | None = None,\n gnss_degraded: bool | None = None,\n on_ground: bool | None = None,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Filtered aircraft query against the live snapshot. Returns matched_total\n + a capped list of compact records.\n\n category ∈ airliner|private|helicopter|glider|military|emergency.\n Combine with area (centre or bbox), squawk, callsign_contains, altitude\n band (metres), emergency / gnss_degraded / on_ground booleans. limit ≤200.\"\"\"\n return await _get(\n \"/api/intel/aircraft\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"category\": category, \"squawk\": squawk,\n \"callsign_contains\": callsign_contains,\n \"min_alt_m\": min_alt_m, \"max_alt_m\": max_alt_m,\n \"emergency\": emergency, \"gnss_degraded\": gnss_degraded,\n \"on_ground\": on_ground, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def lookup_aircraft(ident: str) -> dict[str, Any]:\n \"\"\"Look up one aircraft by ICAO24 hex (exact) or callsign (substring).\n Returns its current state, registration, an integrity/threat assessment,\n and the latest server-held fix.\"\"\"\n # quote(): ident is agent-supplied free text going into a URL path —\n # escape it so \"x/../y\" style values cannot re-route the request.\n return await _get(f\"/api/intel/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def query_vessels(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n dark_only: bool = False,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Vessels (AIS) in an area, classified (cargo/tanker/fishing/passenger/\n military/sailing/pleasure/tug). dark_only=True returns only dark-vessel\n candidates (moving with no static identity). Scoped by centre or bbox.\"\"\"\n return await _get(\n \"/api/intel/vessels\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"dark_only\": dark_only, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def anomalies(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"Fused anomaly report for an area (or global if no coords): emergency\n aircraft, GPS-jamming hotspots, dark-vessel candidates, recent fusion\n alerts, plus a triage threat_level (low|elevated|high) and score.\"\"\"\n return await _get(\n \"/api/intel/anomalies\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def intel_brief(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n link_km: float = 50.0,\n window_hours: float = 6.0,\n) -> dict[str, Any]:\n \"\"\"Cross-domain INCIDENT brief — the headline analytic tool.\n\n Fuses the raw layers into ranked, cited INCIDENTS instead of returning\n signals you have to correlate yourself. An incident is a CONVERGENCE: signals\n within ``link_km`` across >=2 domains (GPS-jamming, dark/AIS-off vessels,\n military air, AIS gaps, emergencies, geocoded events, quakes) — or a single\n critical/high signal. Each incident carries a rule-based narrative, a\n threat_level, the contributing evidence (with IDs), and recommended follow-up\n queries. Omit coordinates for a global brief; pass a centre+radius or bbox to\n scope it. Start here, then drill into an incident's centroid with\n query_vessels / gps_jamming / deep_analyze.\n \"\"\"\n return await _get(\n \"/api/intel/brief\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"link_km\": link_km, \"window_hours\": window_hours,\n },\n )\n\n\n@mcp.tool()\nasync def whats_changed(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n) -> dict[str, Any]:\n \"\"\"Standing watch — what CHANGED since the last check, not the full picture.\n\n Returns incidents that are NEW, ESCALATED, DE-ESCALATED, or RESOLVED. Global\n (no coords) reflects the background watch loop (recomputed ~every 60s); an\n AOI (lat/lon) diffs against YOUR previous whats_changed call for that area,\n so you can poll one region and be told only what moved. Use this to monitor\n instead of re-reading the whole brief each time.\n \"\"\"\n return await _get(\n \"/api/intel/watch\", {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm}\n )\n\n\n@mcp.tool()\nasync def incident_history(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n hours: float = 6.0,\n) -> dict[str, Any]:\n \"\"\"Timeline of how each incident built up over the recent window — per\n incident, the series of (time, threat_level, score) observations. Reveals\n sequence (e.g. jamming first, then dark vessels, then a reported event).\n Global uses the background watch history; an AOI uses your prior watch calls.\n \"\"\"\n return await _get(\n \"/api/intel/incident-history\",\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm, \"hours\": hours},\n )\n\n\n@mcp.tool()\nasync def vessel_dossier(mmsi: str) -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one vessel (MMSI): recent track, AIS gaps,\n derived speed profile (loiter / transit / loiter-then-dash), area covered,\n which live incidents it appears in, and a behaviour assessment. The track is\n the store's ~1h retention window.\"\"\"\n return await _get(f\"/api/intel/dossier/vessel/{quote(mmsi, safe='')}\")\n\n\n@mcp.tool()\nasync def aircraft_dossier(ident: str) -> dict[str, Any]:\n \"\"\"Pattern-of-life dossier for one aircraft (ICAO24 hex or callsign): recent\n track, gaps, derived speed profile, GNSS-integrity, emergency/military flags,\n and which live incidents it appears in.\"\"\"\n return await _get(f\"/api/intel/dossier/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def list_focus_areas() -> dict[str, Any]:\n \"\"\"List the priority areas currently loaded PRIMARY (with fetch stats and\n whether each is served by a dedicated fetch or the degraded snapshot\n fallback).\"\"\"\n return await _get(\"/api/intel/aois\")\n\n\n@mcp.tool()\nasync def data_sources() -> dict[str, Any]:\n \"\"\"Which feeds are always-on vs key-gated, and the configured Ollama\n host/model. Use to explain coverage gaps (e.g. fires need a FIRMS key).\"\"\"\n return await _get(\"/api/intel/sources\")\n\n\n# ── deep analysis (DeepSeek reasoner, Ollama fallback) ────────────────────────\n\n_SYS_PROMPT = (\n \"You are a GEOINT analyst working a live open-source intelligence console. \"\n \"You are given distilled JSON from live ADS-B (aircraft), AIS (vessels), a \"\n \"GPS-jamming layer (ADS-B NACp/NIC, GPSJam method), and a cross-domain \"\n \"fusion engine whose `incident_brief` already chains co-located signals into \"\n \"ranked, cited INCIDENTS — lead your analysis from those incidents, then use \"\n \"the raw situation/area data to corroborate or challenge them. \"\n \"Reason ONLY over the provided JSON. Cite concrete numbers and IDs. Flag: \"\n \"GPS jamming/spoofing footprints, emergency squawks (7500 hijack / 7600 \"\n \"radio-fail / 7700 general), dark vessels, abnormal traffic density, and \"\n \"military activity. Be concise and structured: (1) ASSESSMENT (2 lines), \"\n \"(2) KEY FINDINGS (bullets with numbers), (3) RECOMMENDED FOLLOW-UP \"\n \"queries. If the data is thin, say so plainly — do not invent.\"\n)\n\n\n@mcp.tool()\nasync def deep_analyze(\n question: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 250.0,\n model: str | None = None,\n tier: str = \"reason\",\n) -> dict[str, Any]:\n \"\"\"Have a reasoning model reason deeply over the live intel.\n\n Gathers the relevant data (global situation, plus a focused area bundle +\n jamming + anomalies when lat/lon given) and feeds it to a real reasoning\n model — DeepSeek (``deepseek-reasoner``) when configured, else a local\n Ollama model. Heavy reasoning runs off-context; only the conclusion enters\n the agent's context. Falls back to returning the raw structured data\n (analysis=null) if no LLM backend is reachable.\n\n Args:\n question: what to analyse (e.g. \"Is there coordinated GPS jamming near\n the Baltic, and which aircraft are affected?\").\n lat, lon, radius_nm: optional area to focus on (loaded PRIMARY).\n model: optional Ollama model override used only on the Ollama fallback.\n tier: ``reason`` (deepseek-reasoner, default — judgement) or ``fast``\n (deepseek-chat — quicker, shallower).\n \"\"\"\n import json as _json\n\n from app import llm # noqa: PLC0415\n\n # 1) gather context (already compact). The cross-domain incident BRIEF is the\n # centrepiece — the reasoner judges fused, cited incidents rather than\n # re-correlating raw layers in its head.\n context: dict[str, Any] = {\"question\": question, \"situation\": await get_situation()}\n context[\"incident_brief\"] = await intel_brief(\n lat=lat, lon=lon, radius_nm=radius_nm,\n window_hours=12.0 if lat is None else 6.0,\n )\n if lat is not None and lon is not None:\n context[\"focus_area\"] = await focus_area(lat, lon, radius_nm)\n\n # 2) reason off-context (DeepSeek → Ollama → raw data)\n res = await llm.complete(\n _SYS_PROMPT,\n f\"QUESTION: {question}\\n\\nLIVE INTEL JSON:\\n\"\n + _json.dumps(context, separators=(\",\", \":\"))[:60000],\n tier=tier,\n temperature=0.2,\n max_tokens=2048,\n ollama_model=model or \"\",\n )\n if not res.ok:\n return {\n \"analysis\": None,\n \"model\": res.model,\n \"backend\": res.backend,\n \"note\": f\"No LLM backend reachable ({res.error}). Returning structured \"\n \"intel for you to analyse directly. Configure DEEPSEEK_API_KEY / the \"\n \"opencode DeepSeek key, or `ollama pull qwen2.5:3b`.\",\n \"data\": context,\n }\n\n return {\n \"analysis\": res.text,\n \"model\": res.model,\n \"backend\": res.backend,\n \"focused_on\": (\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm} if lat is not None else \"global\"\n ),\n \"data_summary\": {\n \"aircraft_total\": context[\"situation\"].get(\"aircraft\", {}).get(\"total\"),\n \"jamming_high\": context[\"situation\"].get(\"gps_jamming\", {}).get(\"high\"),\n },\n }\n\n\n# ── news intelligence (debias / fact-check) ──────────────────────────────────\n\n\n@mcp.tool()\nasync def news_analysis() -> dict[str, Any]:\n \"\"\"Cross-source, debiased world-news intelligence.\n\n Scrapes ~12 outlets (BBC, Reuters, AP, Al Jazeera, Guardian, CNN, Fox, …),\n then a reasoning model strips bias/propaganda and separates VERIFIED FACTS\n (corroborated by ≥2 independent outlets) from ATTRIBUTED CLAIMS and\n rhetoric. A leader promising \"the war will end soon\" is flagged as rhetoric,\n never reported as fact. Returns events with neutral_summary, verified_facts,\n attributed_claims, bias_flags, propaganda_techniques, rhetoric_flags and a\n confidence. May take ~30s when the cache is cold (it reasons over the feed).\n \"\"\"\n return await _get(\"/api/news/analysis\")\n\n\n@mcp.tool()\nasync def fact_check(claim: str) -> dict[str, Any]:\n \"\"\"Adjudicate one free-text claim against current world-news headlines.\n\n Returns ``{verdict: true|false|misleading|unverified, reasoning,\n supporting_sources, confidence}``. Use it to sanity-check a statement before\n treating it as fact.\n \"\"\"\n return await _get(\"/api/news/factcheck\", {\"claim\": claim})\n\n\n# ── entrypoint ─────────────────────────────────────────────────────────────────\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=\"OSINT GEOINT MCP server\")\n parser.add_argument(\"--http\", action=\"store_true\", help=\"serve over streamable-HTTP\")\n parser.add_argument(\"--port\", type=int, default=8765, help=\"HTTP port (with --http)\")\n parser.add_argument(\"--host\", default=\"127.0.0.1\", help=\"HTTP host (with --http)\")\n parser.add_argument(\n \"--list-tools\", action=\"store_true\", help=\"print registered tools and exit\"\n )\n args = parser.parse_args()\n\n if args.list_tools:\n tools = asyncio.run(mcp.list_tools())\n print(f\"osint-geoint MCP — {len(tools)} tools:\")\n for t in tools:\n summary = (t.description or \"\").split(\"\\n\", 1)[0]\n print(f\" • {t.name}: {summary}\")\n return\n\n if args.http:\n mcp.settings.host = args.host\n mcp.settings.port = args.port\n mcp.run(transport=\"streamable-http\")\n else:\n mcp.run() # stdio\n\n\nif __name__ == \"__main__\":\n main()\n"}, "c4b913cf2b2f75815eea65c28f0c21ef652cb4b0": {"t": 1785765060.5798428, "n": 23221, "h": "99383c74b8a77970", "c": "\"\"\"OSINT GEOINT — Model Context Protocol server.\n\nExposes the live OSINT console as tools an AI agent can call. Every tool is a\nthin async wrapper over the ``/api/intel/*`` HTTP surface (``app.routes.intel``)\nso the agent shares the SAME warm in-process snapshot, AOI priority loader, and\nfusion engine the globe uses — no second upstream fan-out, no rate-limit blowup.\n\nDesign principles (the MCP brief):\n- **Context-safe.** Tools return distilled JSON (counts, grids, ≤50-item\n samples), never raw feature dumps. An agent can sweep the whole planet for a\n few hundred tokens.\n- **Area-primary.** ``focus_area`` marks a region PRIMARY: a dedicated fresh\n fetch + ongoing priority refresh, independent of global rate limits. Other\n regions keep streaming from the global snapshot.\n- **Think deeper, off-context.** ``deep_analyze`` pulls the relevant intel JSON\n and hands it to a real reasoning model — DeepSeek (``deepseek-reasoner``) when\n configured, else a local Ollama model — so heavy analysis happens off the\n agent's context and only the conclusion returns.\n\nRun:\n python -m app.mcp_server # stdio (Claude Code / Desktop / SDK)\n python -m app.mcp_server --http --port 8765 # streamable-HTTP\n python -m app.mcp_server --list-tools # introspect, for CI / verification\n\nConfig (env or apps/api/.env): API_BASE, API_KEY, DEEPSEEK_API_KEY (or the\nopencode DeepSeek key), OLLAMA_HOST, OLLAMA_MODEL.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport atexit\nimport os\nimport subprocess\nimport sys\nfrom typing import Any\nfrom urllib.parse import quote, urlparse\n\nimport httpx\nfrom mcp.server.fastmcp import FastMCP\n\nfrom app.config import get_settings\n\nmcp = FastMCP(\n \"osint-geoint\",\n instructions=(\n \"Live geospatial intelligence over open ADS-B (aircraft), AIS (vessels), \"\n \"GPS-jamming (ADS-B NACp/NIC), Sentinel-1 SAR dark vessels, geocoded \"\n \"events, and a cross-domain fusion engine. Workflow: get_situation() to \"\n \"orient -> intel_brief() for ranked, cited cross-domain INCIDENTS (the \"\n \"fused picture, global or scoped) -> focus_area(lat,lon,radius_nm) to \"\n \"load an incident's region PRIMARY -> query_vessels / gps_jamming / \"\n \"query_aircraft to drill into its evidence -> deep_analyze() to have a \"\n \"reasoning model judge it. intel_brief is the headline tool: it chains \"\n \"signals into incidents so you don't correlate raw layers by hand.\"\n ),\n)\n\n\n# ── backend HTTP plumbing ─────────────────────────────────────────────────────\n\n\ndef _api_base() -> str:\n return (os.environ.get(\"API_BASE\") or get_settings().api_base).rstrip(\"/\")\n\n\ndef _headers() -> dict[str, str]:\n key = os.environ.get(\"API_KEY\") or get_settings().api_key\n return {\"X-API-Key\": key} if key else {}\n\n\n# ── backend auto-start ────────────────────────────────────────────────────────\n# The MCP server is a thin client over the OSINT backend. If that backend is\n# not running, every tool errors and a driving agent can spin in a retry loop.\n# So on the first tool call we make sure a backend exists: reuse one already\n# listening, else spawn uvicorn ourselves (localhost only) and wait — bounded —\n# for it to come up. Set OSINT_MCP_NO_AUTOSTART=1 to disable.\n\n_BACKEND_LOCK = asyncio.Lock()\n_BACKEND_READY = False\n_BACKEND_PROC: subprocess.Popen[bytes] | None = None\n_AUTOSTART_WAIT_S = 60\n\n\ndef _is_localhost(url: str) -> bool:\n host = (urlparse(url).hostname or \"\").lower()\n return host in {\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"::1\"}\n\n\ndef _api_port() -> int:\n return urlparse(_api_base()).port or 8000\n\n\ndef _apps_api_dir() -> str:\n import app # noqa: PLC0415\n\n return os.path.dirname(os.path.dirname(os.path.abspath(app.__file__)))\n\n\nasync def _backend_healthy(timeout_s: float = 2.0) -> bool:\n try:\n async with httpx.AsyncClient(timeout=timeout_s) as c:\n r = await c.get(_api_base() + \"/api/intel/sources\", headers=_headers())\n return r.status_code == 200\n except Exception:\n return False\n\n\ndef _spawn_uvicorn(cmd: list[str]) -> subprocess.Popen[bytes]:\n \"\"\"Sync spawn (Popen returns immediately; isolated from the async path).\"\"\"\n return subprocess.Popen(\n cmd,\n cwd=_apps_api_dir(),\n stdout=subprocess.DEVNULL,\n stderr=subprocess.DEVNULL,\n env=dict(os.environ),\n )\n\n\ndef _terminate_backend() -> None:\n proc = _BACKEND_PROC\n if proc is not None and proc.poll() is None:\n proc.terminate()\n\n\nasync def _prewarm() -> None:\n \"\"\"Kick the global ADS-B snapshot so the first get_situation isn't slow.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=120.0) as c:\n await c.get(_api_base() + \"/api/adsb/global\", headers=_headers())\n except Exception:\n pass\n\n\nasync def _ensure_backend() -> None:\n \"\"\"Idempotent: guarantee a reachable backend before a tool runs.\"\"\"\n global _BACKEND_READY, _BACKEND_PROC\n if _BACKEND_READY:\n return\n async with _BACKEND_LOCK:\n if _BACKEND_READY:\n return\n if await _backend_healthy():\n _BACKEND_READY = True\n return\n # Only auto-start a LOCAL backend, and only if not opted out.\n if os.environ.get(\"OSINT_MCP_NO_AUTOSTART\") or not _is_localhost(_api_base()):\n return\n # Spawn at most ONCE per process. If a prior attempt didn't come up,\n # don't re-spawn (avoids stacking uvicorns / re-waiting every call) —\n # just let later calls re-check health in case it's still warming.\n if _BACKEND_PROC is not None:\n return\n cmd = [\n sys.executable, \"-m\", \"uvicorn\", \"app.main:app\",\n \"--host\", \"127.0.0.1\", \"--port\", str(_api_port()), \"--log-level\", \"warning\",\n ]\n try:\n _BACKEND_PROC = _spawn_uvicorn(cmd)\n except Exception:\n return # can't spawn (e.g. uvicorn missing) — tools report cleanly\n atexit.register(_terminate_backend)\n for _ in range(_AUTOSTART_WAIT_S):\n await asyncio.sleep(1.0)\n if await _backend_healthy():\n _BACKEND_READY = True\n asyncio.create_task(_prewarm()) # warm snapshot off the hot path\n return\n # Did not come up in time; leave _BACKEND_READY False so the next tool\n # call retries once more rather than looping on a dead spawn.\n\n\nasync def _get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"GET an intel endpoint. Returns parsed JSON or a structured error dict —\n never raises, so a tool call always yields something the agent can read.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in (params or {}).items() if v is not None}\n # Generous ceiling: the backend's FIRST global call cold-starts the ADS-B\n # snapshot (firehose fetch + backoff) and can take up to ~75s. After that\n # every call is instant. A short timeout here would mis-report a healthy\n # backend as unreachable on the agent's very first query.\n try:\n async with httpx.AsyncClient(timeout=90.0) as client:\n r = await client.get(url, params=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\n \"error\": \"backend_unreachable\",\n \"detail\": str(exc),\n \"url\": url,\n \"hint\": \"Backend auto-start did not come up in time. It may still be \"\n \"warming — retry once. Or start it manually: uv run --project \"\n f\"{_apps_api_dir()} uvicorn app.main:app --port {_api_port()}\",\n }\n if r.status_code != 200:\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\n# ── tools ─────────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def get_situation() -> dict[str, Any]:\n \"\"\"Global situational snapshot — the cheap first call to orient.\n\n Returns total aircraft (airborne/ground, by category), GNSS-degraded count,\n active emergency squawks, the worst GPS-jamming cells worldwide, tracked\n vessel counts by category, and recent fusion-alert counts. A few hundred\n tokens describing the whole planet.\"\"\"\n return await _get(\"/api/intel/situation\")\n\n\n@mcp.tool()\nasync def focus_area(\n lat: float,\n lon: float,\n radius_nm: float = 200.0,\n label: str | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Load a region PRIMARY and return a full intel bundle for it in one call.\n\n This is the headline tool. It triggers a DEDICATED, always-fresh fetch for\n just this area (bypassing global rate limits) and registers it for ongoing\n priority refresh. The bundle contains: fresh aircraft summary + sample,\n aircraft-density grid, GPS-jamming assessment, vessels, and fused anomalies\n (emergencies, jamming hotspots, dark vessels, alerts) with a threat level.\n\n Args:\n lat, lon: centre of the area of interest.\n radius_nm: radius in nautical miles (1–250; default 200).\n label: optional human name for the AOI (e.g. \"Kaliningrad\").\n cell_deg: density grid cell size in degrees (0.1–10; default 1.0).\n \"\"\"\n return await _get(\n \"/api/intel/area\",\n {\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"label\": label,\n \"primary\": True,\n \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def aircraft_density(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Aircraft density over an area as a grid of cells (count, by category,\n GNSS-degraded per cell) plus totals, peak cell, and in-area vessel count.\n\n Give either a centre (lat, lon [, radius_nm]) or an explicit bbox\n (min_lon, min_lat, max_lon, max_lat). cell_deg sets grid resolution.\"\"\"\n return await _get(\n \"/api/intel/density\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat, \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def gps_jamming(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"GPS/GNSS jamming assessment (GPSJam method: ADS-B NACp<8 / NIC<7 binned\n into 1° cells). Returns flagged cells ranked by severity, counts of\n high/medium cells, and a sample of affected aircraft. Omit all coordinates\n for a global view; otherwise pass a centre or a bbox to scope it.\"\"\"\n return await _get(\n \"/api/intel/jamming\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def query_aircraft(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n category: str | None = None,\n squawk: str | None = None,\n callsign_contains: str | None = None,\n min_alt_m: float | None = None,\n max_alt_m: float | None = None,\n emergency: bool | None = None,\n gnss_degraded: bool | None = None,\n on_ground: bool | None = None,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Filtered aircraft query against the live snapshot. Returns matched_total\n + a capped list of compact records.\n\n category ∈ airliner|private|helicopter|glider|military|emergency.\n Combine with area (centre or bbox), squawk, callsign_contains, altitude\n band (metres), emergency / gnss_degraded / on_ground booleans. limit ≤200.\"\"\"\n return await _get(\n \"/api/intel/aircraft\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"category\": category, \"squawk\": squawk,\n \"callsign_contains\": callsign_contains,\n \"min_alt_m\": min_alt_m, \"max_alt_m\": max_alt_m,\n \"emergency\": emergency, \"gnss_degraded\": gnss_degraded,\n \"on_ground\": on_ground, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def lookup_aircraft(ident: str) -> dict[str, Any]:\n \"\"\"Look up one aircraft by ICAO24 hex (exact) or callsign (substring).\n Returns its current state, registration, an integrity/threat assessment,\n and the latest server-held fix.\"\"\"\n # quote(): ident is agent-supplied free text going into a URL path —\n # escape it so \"x/../y\" style values cannot re-route the request.\n return await _get(f\"/api/intel/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def query_vessels(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n dark_only: bool = False,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Vessels (AIS) in an area, classified (cargo/tanker/fishing/passenger/\n military/sailing/pleasure/tug). dark_only=True returns only dark-vessel\n candidates (moving with no static identity). Scoped by centre or bbox.\"\"\"\n return await _get(\n \"/api/intel/vessels\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"dark_only\": dark_only, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def anomalies(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"Fused anomaly report for an area (or global if no coords): emergency\n aircraft, GPS-jamming hotspots, dark-vessel candidates, recent fusion\n alerts, plus a triage threat_level (low|elevated|high) and score.\"\"\"\n return await _get(\n \"/api/intel/anomalies\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def intel_brief(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n link_km: float = 50.0,\n window_hours: float = 6.0,\n) -> dict[str, Any]:\n \"\"\"Cross-domain INCIDENT brief — the headline analytic tool.\n\n Fuses the raw layers into ranked, cited INCIDENTS instead of returning\n signals you have to correlate yourself. An incident is a CONVERGENCE: signals\n within ``link_km`` across >=2 domains (GPS-jamming, dark/AIS-off vessels,\n military air, AIS gaps, emergencies, geocoded events, quakes) — or a single\n critical/high signal. Each incident carries a rule-based narrative, a\n threat_level, the contributing evidence (with IDs), and recommended follow-up\n queries. Omit coordinates for a global brief; pass a centre+radius or bbox to\n scope it. Start here, then drill into an incident's centroid with\n query_vessels / gps_jamming / deep_analyze.\n \"\"\"\n return await _get(\n \"/api/intel/brief\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"link_km\": link_km, \"window_hours\": window_hours,\n },\n )\n\n\n@mcp.tool()\nasync def list_focus_areas() -> dict[str, Any]:\n \"\"\"List the priority areas currently loaded PRIMARY (with fetch stats and\n whether each is served by a dedicated fetch or the degraded snapshot\n fallback).\"\"\"\n return await _get(\"/api/intel/aois\")\n\n\n@mcp.tool()\nasync def data_sources() -> dict[str, Any]:\n \"\"\"Which feeds are always-on vs key-gated, and the configured Ollama\n host/model. Use to explain coverage gaps (e.g. fires need a FIRMS key).\"\"\"\n return await _get(\"/api/intel/sources\")\n\n\n# ── deep analysis (DeepSeek reasoner, Ollama fallback) ────────────────────────\n\n_SYS_PROMPT = (\n \"You are a GEOINT analyst working a live open-source intelligence console. \"\n \"You are given distilled JSON from live ADS-B (aircraft), AIS (vessels), a \"\n \"GPS-jamming layer (ADS-B NACp/NIC, GPSJam method), and a cross-domain \"\n \"fusion engine whose `incident_brief` already chains co-located signals into \"\n \"ranked, cited INCIDENTS — lead your analysis from those incidents, then use \"\n \"the raw situation/area data to corroborate or challenge them. \"\n \"Reason ONLY over the provided JSON. Cite concrete numbers and IDs. Flag: \"\n \"GPS jamming/spoofing footprints, emergency squawks (7500 hijack / 7600 \"\n \"radio-fail / 7700 general), dark vessels, abnormal traffic density, and \"\n \"military activity. Be concise and structured: (1) ASSESSMENT (2 lines), \"\n \"(2) KEY FINDINGS (bullets with numbers), (3) RECOMMENDED FOLLOW-UP \"\n \"queries. If the data is thin, say so plainly — do not invent.\"\n)\n\n\n@mcp.tool()\nasync def deep_analyze(\n question: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 250.0,\n model: str | None = None,\n tier: str = \"reason\",\n) -> dict[str, Any]:\n \"\"\"Have a reasoning model reason deeply over the live intel.\n\n Gathers the relevant data (global situation, plus a focused area bundle +\n jamming + anomalies when lat/lon given) and feeds it to a real reasoning\n model — DeepSeek (``deepseek-reasoner``) when configured, else a local\n Ollama model. Heavy reasoning runs off-context; only the conclusion enters\n the agent's context. Falls back to returning the raw structured data\n (analysis=null) if no LLM backend is reachable.\n\n Args:\n question: what to analyse (e.g. \"Is there coordinated GPS jamming near\n the Baltic, and which aircraft are affected?\").\n lat, lon, radius_nm: optional area to focus on (loaded PRIMARY).\n model: optional Ollama model override used only on the Ollama fallback.\n tier: ``reason`` (deepseek-reasoner, default — judgement) or ``fast``\n (deepseek-chat — quicker, shallower).\n \"\"\"\n import json as _json\n\n from app import llm # noqa: PLC0415\n\n # 1) gather context (already compact). The cross-domain incident BRIEF is the\n # centrepiece — the reasoner judges fused, cited incidents rather than\n # re-correlating raw layers in its head.\n context: dict[str, Any] = {\"question\": question, \"situation\": await get_situation()}\n context[\"incident_brief\"] = await intel_brief(\n lat=lat, lon=lon, radius_nm=radius_nm,\n window_hours=12.0 if lat is None else 6.0,\n )\n if lat is not None and lon is not None:\n context[\"focus_area\"] = await focus_area(lat, lon, radius_nm)\n\n # 2) reason off-context (DeepSeek → Ollama → raw data)\n res = await llm.complete(\n _SYS_PROMPT,\n f\"QUESTION: {question}\\n\\nLIVE INTEL JSON:\\n\"\n + _json.dumps(context, separators=(\",\", \":\"))[:60000],\n tier=tier,\n temperature=0.2,\n max_tokens=2048,\n ollama_model=model or \"\",\n )\n if not res.ok:\n return {\n \"analysis\": None,\n \"model\": res.model,\n \"backend\": res.backend,\n \"note\": f\"No LLM backend reachable ({res.error}). Returning structured \"\n \"intel for you to analyse directly. Configure DEEPSEEK_API_KEY / the \"\n \"opencode DeepSeek key, or `ollama pull qwen2.5:3b`.\",\n \"data\": context,\n }\n\n return {\n \"analysis\": res.text,\n \"model\": res.model,\n \"backend\": res.backend,\n \"focused_on\": (\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm} if lat is not None else \"global\"\n ),\n \"data_summary\": {\n \"aircraft_total\": context[\"situation\"].get(\"aircraft\", {}).get(\"total\"),\n \"jamming_high\": context[\"situation\"].get(\"gps_jamming\", {}).get(\"high\"),\n },\n }\n\n\n# ── news intelligence (debias / fact-check) ──────────────────────────────────\n\n\n@mcp.tool()\nasync def news_analysis() -> dict[str, Any]:\n \"\"\"Cross-source, debiased world-news intelligence.\n\n Scrapes ~12 outlets (BBC, Reuters, AP, Al Jazeera, Guardian, CNN, Fox, …),\n then a reasoning model strips bias/propaganda and separates VERIFIED FACTS\n (corroborated by ≥2 independent outlets) from ATTRIBUTED CLAIMS and\n rhetoric. A leader promising \"the war will end soon\" is flagged as rhetoric,\n never reported as fact. Returns events with neutral_summary, verified_facts,\n attributed_claims, bias_flags, propaganda_techniques, rhetoric_flags and a\n confidence. May take ~30s when the cache is cold (it reasons over the feed).\n \"\"\"\n return await _get(\"/api/news/analysis\")\n\n\n@mcp.tool()\nasync def fact_check(claim: str) -> dict[str, Any]:\n \"\"\"Adjudicate one free-text claim against current world-news headlines.\n\n Returns ``{verdict: true|false|misleading|unverified, reasoning,\n supporting_sources, confidence}``. Use it to sanity-check a statement before\n treating it as fact.\n \"\"\"\n return await _get(\"/api/news/factcheck\", {\"claim\": claim})\n\n\n# ── entrypoint ─────────────────────────────────────────────────────────────────\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=\"OSINT GEOINT MCP server\")\n parser.add_argument(\"--http\", action=\"store_true\", help=\"serve over streamable-HTTP\")\n parser.add_argument(\"--port\", type=int, default=8765, help=\"HTTP port (with --http)\")\n parser.add_argument(\"--host\", default=\"127.0.0.1\", help=\"HTTP host (with --http)\")\n parser.add_argument(\n \"--list-tools\", action=\"store_true\", help=\"print registered tools and exit\"\n )\n args = parser.parse_args()\n\n if args.list_tools:\n tools = asyncio.run(mcp.list_tools())\n print(f\"osint-geoint MCP — {len(tools)} tools:\")\n for t in tools:\n summary = (t.description or \"\").split(\"\\n\", 1)[0]\n print(f\" • {t.name}: {summary}\")\n return\n\n if args.http:\n mcp.settings.host = args.host\n mcp.settings.port = args.port\n mcp.run(transport=\"streamable-http\")\n else:\n mcp.run() # stdio\n\n\nif __name__ == \"__main__\":\n main()\n"}, "6cdfeffb744fabf39f2d342470178eea4ed1e4bb": {"t": 1785765063.3032248, "n": 21066, "h": "58ebd716bf08e9a4", "c": "\"\"\"OSINT GEOINT — Model Context Protocol server.\n\nExposes the live OSINT console as tools an AI agent can call. Every tool is a\nthin async wrapper over the ``/api/intel/*`` HTTP surface (``app.routes.intel``)\nso the agent shares the SAME warm in-process snapshot, AOI priority loader, and\nfusion engine the globe uses — no second upstream fan-out, no rate-limit blowup.\n\nDesign principles (the MCP brief):\n- **Context-safe.** Tools return distilled JSON (counts, grids, ≤50-item\n samples), never raw feature dumps. An agent can sweep the whole planet for a\n few hundred tokens.\n- **Area-primary.** ``focus_area`` marks a region PRIMARY: a dedicated fresh\n fetch + ongoing priority refresh, independent of global rate limits. Other\n regions keep streaming from the global snapshot.\n- **Think deeper, off-context.** ``deep_analyze`` pulls the relevant intel JSON\n and hands it to a real reasoning model — DeepSeek (``deepseek-reasoner``) when\n configured, else a local Ollama model — so heavy analysis happens off the\n agent's context and only the conclusion returns.\n\nRun:\n python -m app.mcp_server # stdio (Claude Code / Desktop / SDK)\n python -m app.mcp_server --http --port 8765 # streamable-HTTP\n python -m app.mcp_server --list-tools # introspect, for CI / verification\n\nConfig (env or apps/api/.env): API_BASE, API_KEY, DEEPSEEK_API_KEY (or the\nopencode DeepSeek key), OLLAMA_HOST, OLLAMA_MODEL.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport atexit\nimport os\nimport subprocess\nimport sys\nfrom typing import Any\nfrom urllib.parse import quote, urlparse\n\nimport httpx\nfrom mcp.server.fastmcp import FastMCP\n\nfrom app.config import get_settings\n\nmcp = FastMCP(\n \"osint-geoint\",\n instructions=(\n \"Live geospatial intelligence over open ADS-B (aircraft), AIS (vessels), \"\n \"GPS-jamming (ADS-B NACp/NIC), and a fusion engine. Start with \"\n \"get_situation() to orient, then focus_area(lat,lon,radius_nm) to load a \"\n \"region PRIMARY and pull density / GPS jamming / anomalies for it. Use \"\n \"deep_analyze() to have a local model reason over the data.\"\n ),\n)\n\n\n# ── backend HTTP plumbing ─────────────────────────────────────────────────────\n\n\ndef _api_base() -> str:\n return (os.environ.get(\"API_BASE\") or get_settings().api_base).rstrip(\"/\")\n\n\ndef _headers() -> dict[str, str]:\n key = os.environ.get(\"API_KEY\") or get_settings().api_key\n return {\"X-API-Key\": key} if key else {}\n\n\n# ── backend auto-start ────────────────────────────────────────────────────────\n# The MCP server is a thin client over the OSINT backend. If that backend is\n# not running, every tool errors and a driving agent can spin in a retry loop.\n# So on the first tool call we make sure a backend exists: reuse one already\n# listening, else spawn uvicorn ourselves (localhost only) and wait — bounded —\n# for it to come up. Set OSINT_MCP_NO_AUTOSTART=1 to disable.\n\n_BACKEND_LOCK = asyncio.Lock()\n_BACKEND_READY = False\n_BACKEND_PROC: subprocess.Popen[bytes] | None = None\n_AUTOSTART_WAIT_S = 60\n\n\ndef _is_localhost(url: str) -> bool:\n host = (urlparse(url).hostname or \"\").lower()\n return host in {\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"::1\"}\n\n\ndef _api_port() -> int:\n return urlparse(_api_base()).port or 8000\n\n\ndef _apps_api_dir() -> str:\n import app # noqa: PLC0415\n\n return os.path.dirname(os.path.dirname(os.path.abspath(app.__file__)))\n\n\nasync def _backend_healthy(timeout_s: float = 2.0) -> bool:\n try:\n async with httpx.AsyncClient(timeout=timeout_s) as c:\n r = await c.get(_api_base() + \"/api/intel/sources\", headers=_headers())\n return r.status_code == 200\n except Exception:\n return False\n\n\ndef _spawn_uvicorn(cmd: list[str]) -> subprocess.Popen[bytes]:\n \"\"\"Sync spawn (Popen returns immediately; isolated from the async path).\"\"\"\n return subprocess.Popen(\n cmd,\n cwd=_apps_api_dir(),\n stdout=subprocess.DEVNULL,\n stderr=subprocess.DEVNULL,\n env=dict(os.environ),\n )\n\n\ndef _terminate_backend() -> None:\n proc = _BACKEND_PROC\n if proc is not None and proc.poll() is None:\n proc.terminate()\n\n\nasync def _prewarm() -> None:\n \"\"\"Kick the global ADS-B snapshot so the first get_situation isn't slow.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=120.0) as c:\n await c.get(_api_base() + \"/api/adsb/global\", headers=_headers())\n except Exception:\n pass\n\n\nasync def _ensure_backend() -> None:\n \"\"\"Idempotent: guarantee a reachable backend before a tool runs.\"\"\"\n global _BACKEND_READY, _BACKEND_PROC\n if _BACKEND_READY:\n return\n async with _BACKEND_LOCK:\n if _BACKEND_READY:\n return\n if await _backend_healthy():\n _BACKEND_READY = True\n return\n # Only auto-start a LOCAL backend, and only if not opted out.\n if os.environ.get(\"OSINT_MCP_NO_AUTOSTART\") or not _is_localhost(_api_base()):\n return\n # Spawn at most ONCE per process. If a prior attempt didn't come up,\n # don't re-spawn (avoids stacking uvicorns / re-waiting every call) —\n # just let later calls re-check health in case it's still warming.\n if _BACKEND_PROC is not None:\n return\n cmd = [\n sys.executable, \"-m\", \"uvicorn\", \"app.main:app\",\n \"--host\", \"127.0.0.1\", \"--port\", str(_api_port()), \"--log-level\", \"warning\",\n ]\n try:\n _BACKEND_PROC = _spawn_uvicorn(cmd)\n except Exception:\n return # can't spawn (e.g. uvicorn missing) — tools report cleanly\n atexit.register(_terminate_backend)\n for _ in range(_AUTOSTART_WAIT_S):\n await asyncio.sleep(1.0)\n if await _backend_healthy():\n _BACKEND_READY = True\n asyncio.create_task(_prewarm()) # warm snapshot off the hot path\n return\n # Did not come up in time; leave _BACKEND_READY False so the next tool\n # call retries once more rather than looping on a dead spawn.\n\n\nasync def _get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"GET an intel endpoint. Returns parsed JSON or a structured error dict —\n never raises, so a tool call always yields something the agent can read.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in (params or {}).items() if v is not None}\n # Generous ceiling: the backend's FIRST global call cold-starts the ADS-B\n # snapshot (firehose fetch + backoff) and can take up to ~75s. After that\n # every call is instant. A short timeout here would mis-report a healthy\n # backend as unreachable on the agent's very first query.\n try:\n async with httpx.AsyncClient(timeout=90.0) as client:\n r = await client.get(url, params=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\n \"error\": \"backend_unreachable\",\n \"detail\": str(exc),\n \"url\": url,\n \"hint\": \"Backend auto-start did not come up in time. It may still be \"\n \"warming — retry once. Or start it manually: uv run --project \"\n f\"{_apps_api_dir()} uvicorn app.main:app --port {_api_port()}\",\n }\n if r.status_code != 200:\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\n# ── tools ─────────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def get_situation() -> dict[str, Any]:\n \"\"\"Global situational snapshot — the cheap first call to orient.\n\n Returns total aircraft (airborne/ground, by category), GNSS-degraded count,\n active emergency squawks, the worst GPS-jamming cells worldwide, tracked\n vessel counts by category, and recent fusion-alert counts. A few hundred\n tokens describing the whole planet.\"\"\"\n return await _get(\"/api/intel/situation\")\n\n\n@mcp.tool()\nasync def focus_area(\n lat: float,\n lon: float,\n radius_nm: float = 200.0,\n label: str | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Load a region PRIMARY and return a full intel bundle for it in one call.\n\n This is the headline tool. It triggers a DEDICATED, always-fresh fetch for\n just this area (bypassing global rate limits) and registers it for ongoing\n priority refresh. The bundle contains: fresh aircraft summary + sample,\n aircraft-density grid, GPS-jamming assessment, vessels, and fused anomalies\n (emergencies, jamming hotspots, dark vessels, alerts) with a threat level.\n\n Args:\n lat, lon: centre of the area of interest.\n radius_nm: radius in nautical miles (1–250; default 200).\n label: optional human name for the AOI (e.g. \"Kaliningrad\").\n cell_deg: density grid cell size in degrees (0.1–10; default 1.0).\n \"\"\"\n return await _get(\n \"/api/intel/area\",\n {\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"label\": label,\n \"primary\": True,\n \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def aircraft_density(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Aircraft density over an area as a grid of cells (count, by category,\n GNSS-degraded per cell) plus totals, peak cell, and in-area vessel count.\n\n Give either a centre (lat, lon [, radius_nm]) or an explicit bbox\n (min_lon, min_lat, max_lon, max_lat). cell_deg sets grid resolution.\"\"\"\n return await _get(\n \"/api/intel/density\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat, \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def gps_jamming(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"GPS/GNSS jamming assessment (GPSJam method: ADS-B NACp<8 / NIC<7 binned\n into 1° cells). Returns flagged cells ranked by severity, counts of\n high/medium cells, and a sample of affected aircraft. Omit all coordinates\n for a global view; otherwise pass a centre or a bbox to scope it.\"\"\"\n return await _get(\n \"/api/intel/jamming\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def query_aircraft(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n category: str | None = None,\n squawk: str | None = None,\n callsign_contains: str | None = None,\n min_alt_m: float | None = None,\n max_alt_m: float | None = None,\n emergency: bool | None = None,\n gnss_degraded: bool | None = None,\n on_ground: bool | None = None,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Filtered aircraft query against the live snapshot. Returns matched_total\n + a capped list of compact records.\n\n category ∈ airliner|private|helicopter|glider|military|emergency.\n Combine with area (centre or bbox), squawk, callsign_contains, altitude\n band (metres), emergency / gnss_degraded / on_ground booleans. limit ≤200.\"\"\"\n return await _get(\n \"/api/intel/aircraft\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"category\": category, \"squawk\": squawk,\n \"callsign_contains\": callsign_contains,\n \"min_alt_m\": min_alt_m, \"max_alt_m\": max_alt_m,\n \"emergency\": emergency, \"gnss_degraded\": gnss_degraded,\n \"on_ground\": on_ground, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def lookup_aircraft(ident: str) -> dict[str, Any]:\n \"\"\"Look up one aircraft by ICAO24 hex (exact) or callsign (substring).\n Returns its current state, registration, an integrity/threat assessment,\n and the latest server-held fix.\"\"\"\n # quote(): ident is agent-supplied free text going into a URL path —\n # escape it so \"x/../y\" style values cannot re-route the request.\n return await _get(f\"/api/intel/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def query_vessels(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n dark_only: bool = False,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Vessels (AIS) in an area, classified (cargo/tanker/fishing/passenger/\n military/sailing/pleasure/tug). dark_only=True returns only dark-vessel\n candidates (moving with no static identity). Scoped by centre or bbox.\"\"\"\n return await _get(\n \"/api/intel/vessels\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"dark_only\": dark_only, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def anomalies(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"Fused anomaly report for an area (or global if no coords): emergency\n aircraft, GPS-jamming hotspots, dark-vessel candidates, recent fusion\n alerts, plus a triage threat_level (low|elevated|high) and score.\"\"\"\n return await _get(\n \"/api/intel/anomalies\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def list_focus_areas() -> dict[str, Any]:\n \"\"\"List the priority areas currently loaded PRIMARY (with fetch stats and\n whether each is served by a dedicated fetch or the degraded snapshot\n fallback).\"\"\"\n return await _get(\"/api/intel/aois\")\n\n\n@mcp.tool()\nasync def data_sources() -> dict[str, Any]:\n \"\"\"Which feeds are always-on vs key-gated, and the configured Ollama\n host/model. Use to explain coverage gaps (e.g. fires need a FIRMS key).\"\"\"\n return await _get(\"/api/intel/sources\")\n\n\n# ── deep analysis (DeepSeek reasoner, Ollama fallback) ────────────────────────\n\n_SYS_PROMPT = (\n \"You are a GEOINT analyst working a live open-source intelligence console. \"\n \"You are given distilled JSON from live ADS-B (aircraft), AIS (vessels), a \"\n \"GPS-jamming layer (ADS-B NACp/NIC, GPSJam method), and a fusion engine. \"\n \"Reason ONLY over the provided JSON. Cite concrete numbers and IDs. Flag: \"\n \"GPS jamming/spoofing footprints, emergency squawks (7500 hijack / 7600 \"\n \"radio-fail / 7700 general), dark vessels, abnormal traffic density, and \"\n \"military activity. Be concise and structured: (1) ASSESSMENT (2 lines), \"\n \"(2) KEY FINDINGS (bullets with numbers), (3) RECOMMENDED FOLLOW-UP \"\n \"queries. If the data is thin, say so plainly — do not invent.\"\n)\n\n\n@mcp.tool()\nasync def deep_analyze(\n question: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 250.0,\n model: str | None = None,\n tier: str = \"reason\",\n) -> dict[str, Any]:\n \"\"\"Have a reasoning model reason deeply over the live intel.\n\n Gathers the relevant data (global situation, plus a focused area bundle +\n jamming + anomalies when lat/lon given) and feeds it to a real reasoning\n model — DeepSeek (``deepseek-reasoner``) when configured, else a local\n Ollama model. Heavy reasoning runs off-context; only the conclusion enters\n the agent's context. Falls back to returning the raw structured data\n (analysis=null) if no LLM backend is reachable.\n\n Args:\n question: what to analyse (e.g. \"Is there coordinated GPS jamming near\n the Baltic, and which aircraft are affected?\").\n lat, lon, radius_nm: optional area to focus on (loaded PRIMARY).\n model: optional Ollama model override used only on the Ollama fallback.\n tier: ``reason`` (deepseek-reasoner, default — judgement) or ``fast``\n (deepseek-chat — quicker, shallower).\n \"\"\"\n import json as _json\n\n from app import llm # noqa: PLC0415\n\n # 1) gather context (already compact)\n context: dict[str, Any] = {\"question\": question, \"situation\": await get_situation()}\n if lat is not None and lon is not None:\n context[\"focus_area\"] = await focus_area(lat, lon, radius_nm)\n else:\n context[\"global_jamming\"] = await gps_jamming()\n context[\"global_anomalies\"] = await anomalies()\n\n # 2) reason off-context (DeepSeek → Ollama → raw data)\n res = await llm.complete(\n _SYS_PROMPT,\n f\"QUESTION: {question}\\n\\nLIVE INTEL JSON:\\n\"\n + _json.dumps(context, separators=(\",\", \":\"))[:60000],\n tier=tier,\n temperature=0.2,\n max_tokens=2048,\n ollama_model=model or \"\",\n )\n if not res.ok:\n return {\n \"analysis\": None,\n \"model\": res.model,\n \"backend\": res.backend,\n \"note\": f\"No LLM backend reachable ({res.error}). Returning structured \"\n \"intel for you to analyse directly. Configure DEEPSEEK_API_KEY / the \"\n \"opencode DeepSeek key, or `ollama pull qwen2.5:3b`.\",\n \"data\": context,\n }\n\n return {\n \"analysis\": res.text,\n \"model\": res.model,\n \"backend\": res.backend,\n \"focused_on\": (\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm} if lat is not None else \"global\"\n ),\n \"data_summary\": {\n \"aircraft_total\": context[\"situation\"].get(\"aircraft\", {}).get(\"total\"),\n \"jamming_high\": context[\"situation\"].get(\"gps_jamming\", {}).get(\"high\"),\n },\n }\n\n\n# ── news intelligence (debias / fact-check) ──────────────────────────────────\n\n\n@mcp.tool()\nasync def news_analysis() -> dict[str, Any]:\n \"\"\"Cross-source, debiased world-news intelligence.\n\n Scrapes ~12 outlets (BBC, Reuters, AP, Al Jazeera, Guardian, CNN, Fox, …),\n then a reasoning model strips bias/propaganda and separates VERIFIED FACTS\n (corroborated by ≥2 independent outlets) from ATTRIBUTED CLAIMS and\n rhetoric. A leader promising \"the war will end soon\" is flagged as rhetoric,\n never reported as fact. Returns events with neutral_summary, verified_facts,\n attributed_claims, bias_flags, propaganda_techniques, rhetoric_flags and a\n confidence. May take ~30s when the cache is cold (it reasons over the feed).\n \"\"\"\n return await _get(\"/api/news/analysis\")\n\n\n@mcp.tool()\nasync def fact_check(claim: str) -> dict[str, Any]:\n \"\"\"Adjudicate one free-text claim against current world-news headlines.\n\n Returns ``{verdict: true|false|misleading|unverified, reasoning,\n supporting_sources, confidence}``. Use it to sanity-check a statement before\n treating it as fact.\n \"\"\"\n return await _get(\"/api/news/factcheck\", {\"claim\": claim})\n\n\n# ── entrypoint ─────────────────────────────────────────────────────────────────\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=\"OSINT GEOINT MCP server\")\n parser.add_argument(\"--http\", action=\"store_true\", help=\"serve over streamable-HTTP\")\n parser.add_argument(\"--port\", type=int, default=8765, help=\"HTTP port (with --http)\")\n parser.add_argument(\"--host\", default=\"127.0.0.1\", help=\"HTTP host (with --http)\")\n parser.add_argument(\n \"--list-tools\", action=\"store_true\", help=\"print registered tools and exit\"\n )\n args = parser.parse_args()\n\n if args.list_tools:\n tools = asyncio.run(mcp.list_tools())\n print(f\"osint-geoint MCP — {len(tools)} tools:\")\n for t in tools:\n summary = (t.description or \"\").split(\"\\n\", 1)[0]\n print(f\" • {t.name}: {summary}\")\n return\n\n if args.http:\n mcp.settings.host = args.host\n mcp.settings.port = args.port\n mcp.run(transport=\"streamable-http\")\n else:\n mcp.run() # stdio\n\n\nif __name__ == \"__main__\":\n main()\n"}, "d4516159359c26101dd1161a76fb99869747df08": {"t": 1785765065.1717505, "n": 19876, "h": "910c7e3da3c682c0", "c": "\"\"\"OSINT GEOINT — Model Context Protocol server.\n\nExposes the live OSINT console as tools an AI agent can call. Every tool is a\nthin async wrapper over the ``/api/intel/*`` HTTP surface (``app.routes.intel``)\nso the agent shares the SAME warm in-process snapshot, AOI priority loader, and\nfusion engine the globe uses — no second upstream fan-out, no rate-limit blowup.\n\nDesign principles (the MCP brief):\n- **Context-safe.** Tools return distilled JSON (counts, grids, ≤50-item\n samples), never raw feature dumps. An agent can sweep the whole planet for a\n few hundred tokens.\n- **Area-primary.** ``focus_area`` marks a region PRIMARY: a dedicated fresh\n fetch + ongoing priority refresh, independent of global rate limits. Other\n regions keep streaming from the global snapshot.\n- **Think deeper, off-context.** ``deep_analyze`` pulls the relevant intel JSON\n and hands it to a real reasoning model — DeepSeek (``deepseek-reasoner``) when\n configured, else a local Ollama model — so heavy analysis happens off the\n agent's context and only the conclusion returns.\n\nRun:\n python -m app.mcp_server # stdio (Claude Code / Desktop / SDK)\n python -m app.mcp_server --http --port 8765 # streamable-HTTP\n python -m app.mcp_server --list-tools # introspect, for CI / verification\n\nConfig (env or apps/api/.env): API_BASE, API_KEY, DEEPSEEK_API_KEY (or the\nopencode DeepSeek key), OLLAMA_HOST, OLLAMA_MODEL.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport atexit\nimport os\nimport subprocess\nimport sys\nfrom typing import Any\nfrom urllib.parse import quote, urlparse\n\nimport httpx\nfrom mcp.server.fastmcp import FastMCP\n\nfrom app.config import get_settings\n\nmcp = FastMCP(\n \"osint-geoint\",\n instructions=(\n \"Live geospatial intelligence over open ADS-B (aircraft), AIS (vessels), \"\n \"GPS-jamming (ADS-B NACp/NIC), and a fusion engine. Start with \"\n \"get_situation() to orient, then focus_area(lat,lon,radius_nm) to load a \"\n \"region PRIMARY and pull density / GPS jamming / anomalies for it. Use \"\n \"deep_analyze() to have a local model reason over the data.\"\n ),\n)\n\n\n# ── backend HTTP plumbing ─────────────────────────────────────────────────────\n\n\ndef _api_base() -> str:\n return (os.environ.get(\"API_BASE\") or get_settings().api_base).rstrip(\"/\")\n\n\ndef _headers() -> dict[str, str]:\n key = os.environ.get(\"API_KEY\") or get_settings().api_key\n return {\"X-API-Key\": key} if key else {}\n\n\n# ── backend auto-start ────────────────────────────────────────────────────────\n# The MCP server is a thin client over the OSINT backend. If that backend is\n# not running, every tool errors and a driving agent can spin in a retry loop.\n# So on the first tool call we make sure a backend exists: reuse one already\n# listening, else spawn uvicorn ourselves (localhost only) and wait — bounded —\n# for it to come up. Set OSINT_MCP_NO_AUTOSTART=1 to disable.\n\n_BACKEND_LOCK = asyncio.Lock()\n_BACKEND_READY = False\n_BACKEND_PROC: subprocess.Popen[bytes] | None = None\n_AUTOSTART_WAIT_S = 60\n\n\ndef _is_localhost(url: str) -> bool:\n host = (urlparse(url).hostname or \"\").lower()\n return host in {\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"::1\"}\n\n\ndef _api_port() -> int:\n return urlparse(_api_base()).port or 8000\n\n\ndef _apps_api_dir() -> str:\n import app # noqa: PLC0415\n\n return os.path.dirname(os.path.dirname(os.path.abspath(app.__file__)))\n\n\nasync def _backend_healthy(timeout_s: float = 2.0) -> bool:\n try:\n async with httpx.AsyncClient(timeout=timeout_s) as c:\n r = await c.get(_api_base() + \"/api/intel/sources\", headers=_headers())\n return r.status_code == 200\n except Exception:\n return False\n\n\ndef _spawn_uvicorn(cmd: list[str]) -> subprocess.Popen[bytes]:\n \"\"\"Sync spawn (Popen returns immediately; isolated from the async path).\"\"\"\n return subprocess.Popen(\n cmd,\n cwd=_apps_api_dir(),\n stdout=subprocess.DEVNULL,\n stderr=subprocess.DEVNULL,\n env=dict(os.environ),\n )\n\n\ndef _terminate_backend() -> None:\n proc = _BACKEND_PROC\n if proc is not None and proc.poll() is None:\n proc.terminate()\n\n\nasync def _prewarm() -> None:\n \"\"\"Kick the global ADS-B snapshot so the first get_situation isn't slow.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=120.0) as c:\n await c.get(_api_base() + \"/api/adsb/global\", headers=_headers())\n except Exception:\n pass\n\n\nasync def _ensure_backend() -> None:\n \"\"\"Idempotent: guarantee a reachable backend before a tool runs.\"\"\"\n global _BACKEND_READY, _BACKEND_PROC\n if _BACKEND_READY:\n return\n async with _BACKEND_LOCK:\n if _BACKEND_READY:\n return\n if await _backend_healthy():\n _BACKEND_READY = True\n return\n # Only auto-start a LOCAL backend, and only if not opted out.\n if os.environ.get(\"OSINT_MCP_NO_AUTOSTART\") or not _is_localhost(_api_base()):\n return\n # Spawn at most ONCE per process. If a prior attempt didn't come up,\n # don't re-spawn (avoids stacking uvicorns / re-waiting every call) —\n # just let later calls re-check health in case it's still warming.\n if _BACKEND_PROC is not None:\n return\n cmd = [\n sys.executable, \"-m\", \"uvicorn\", \"app.main:app\",\n \"--host\", \"127.0.0.1\", \"--port\", str(_api_port()), \"--log-level\", \"warning\",\n ]\n try:\n _BACKEND_PROC = _spawn_uvicorn(cmd)\n except Exception:\n return # can't spawn (e.g. uvicorn missing) — tools report cleanly\n atexit.register(_terminate_backend)\n for _ in range(_AUTOSTART_WAIT_S):\n await asyncio.sleep(1.0)\n if await _backend_healthy():\n _BACKEND_READY = True\n asyncio.create_task(_prewarm()) # warm snapshot off the hot path\n return\n # Did not come up in time; leave _BACKEND_READY False so the next tool\n # call retries once more rather than looping on a dead spawn.\n\n\nasync def _get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"GET an intel endpoint. Returns parsed JSON or a structured error dict —\n never raises, so a tool call always yields something the agent can read.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in (params or {}).items() if v is not None}\n # Generous ceiling: the backend's FIRST global call cold-starts the ADS-B\n # snapshot (firehose fetch + backoff) and can take up to ~75s. After that\n # every call is instant. A short timeout here would mis-report a healthy\n # backend as unreachable on the agent's very first query.\n try:\n async with httpx.AsyncClient(timeout=90.0) as client:\n r = await client.get(url, params=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\n \"error\": \"backend_unreachable\",\n \"detail\": str(exc),\n \"url\": url,\n \"hint\": \"Backend auto-start did not come up in time. It may still be \"\n \"warming — retry once. Or start it manually: uv run --project \"\n f\"{_apps_api_dir()} uvicorn app.main:app --port {_api_port()}\",\n }\n if r.status_code != 200:\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\n# ── tools ─────────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def get_situation() -> dict[str, Any]:\n \"\"\"Global situational snapshot — the cheap first call to orient.\n\n Returns total aircraft (airborne/ground, by category), GNSS-degraded count,\n active emergency squawks, the worst GPS-jamming cells worldwide, tracked\n vessel counts by category, and recent fusion-alert counts. A few hundred\n tokens describing the whole planet.\"\"\"\n return await _get(\"/api/intel/situation\")\n\n\n@mcp.tool()\nasync def focus_area(\n lat: float,\n lon: float,\n radius_nm: float = 200.0,\n label: str | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Load a region PRIMARY and return a full intel bundle for it in one call.\n\n This is the headline tool. It triggers a DEDICATED, always-fresh fetch for\n just this area (bypassing global rate limits) and registers it for ongoing\n priority refresh. The bundle contains: fresh aircraft summary + sample,\n aircraft-density grid, GPS-jamming assessment, vessels, and fused anomalies\n (emergencies, jamming hotspots, dark vessels, alerts) with a threat level.\n\n Args:\n lat, lon: centre of the area of interest.\n radius_nm: radius in nautical miles (1–250; default 200).\n label: optional human name for the AOI (e.g. \"Kaliningrad\").\n cell_deg: density grid cell size in degrees (0.1–10; default 1.0).\n \"\"\"\n return await _get(\n \"/api/intel/area\",\n {\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"label\": label,\n \"primary\": True,\n \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def aircraft_density(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Aircraft density over an area as a grid of cells (count, by category,\n GNSS-degraded per cell) plus totals, peak cell, and in-area vessel count.\n\n Give either a centre (lat, lon [, radius_nm]) or an explicit bbox\n (min_lon, min_lat, max_lon, max_lat). cell_deg sets grid resolution.\"\"\"\n return await _get(\n \"/api/intel/density\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat, \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def gps_jamming(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"GPS/GNSS jamming assessment (GPSJam method: ADS-B NACp<8 / NIC<7 binned\n into 1° cells). Returns flagged cells ranked by severity, counts of\n high/medium cells, and a sample of affected aircraft. Omit all coordinates\n for a global view; otherwise pass a centre or a bbox to scope it.\"\"\"\n return await _get(\n \"/api/intel/jamming\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def query_aircraft(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n category: str | None = None,\n squawk: str | None = None,\n callsign_contains: str | None = None,\n min_alt_m: float | None = None,\n max_alt_m: float | None = None,\n emergency: bool | None = None,\n gnss_degraded: bool | None = None,\n on_ground: bool | None = None,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Filtered aircraft query against the live snapshot. Returns matched_total\n + a capped list of compact records.\n\n category ∈ airliner|private|helicopter|glider|military|emergency.\n Combine with area (centre or bbox), squawk, callsign_contains, altitude\n band (metres), emergency / gnss_degraded / on_ground booleans. limit ≤200.\"\"\"\n return await _get(\n \"/api/intel/aircraft\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"category\": category, \"squawk\": squawk,\n \"callsign_contains\": callsign_contains,\n \"min_alt_m\": min_alt_m, \"max_alt_m\": max_alt_m,\n \"emergency\": emergency, \"gnss_degraded\": gnss_degraded,\n \"on_ground\": on_ground, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def lookup_aircraft(ident: str) -> dict[str, Any]:\n \"\"\"Look up one aircraft by ICAO24 hex (exact) or callsign (substring).\n Returns its current state, registration, an integrity/threat assessment,\n and the latest server-held fix.\"\"\"\n # quote(): ident is agent-supplied free text going into a URL path —\n # escape it so \"x/../y\" style values cannot re-route the request.\n return await _get(f\"/api/intel/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def query_vessels(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n dark_only: bool = False,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Vessels (AIS) in an area, classified (cargo/tanker/fishing/passenger/\n military/sailing/pleasure/tug). dark_only=True returns only dark-vessel\n candidates (moving with no static identity). Scoped by centre or bbox.\"\"\"\n return await _get(\n \"/api/intel/vessels\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"dark_only\": dark_only, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def anomalies(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"Fused anomaly report for an area (or global if no coords): emergency\n aircraft, GPS-jamming hotspots, dark-vessel candidates, recent fusion\n alerts, plus a triage threat_level (low|elevated|high) and score.\"\"\"\n return await _get(\n \"/api/intel/anomalies\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def list_focus_areas() -> dict[str, Any]:\n \"\"\"List the priority areas currently loaded PRIMARY (with fetch stats and\n whether each is served by a dedicated fetch or the degraded snapshot\n fallback).\"\"\"\n return await _get(\"/api/intel/aois\")\n\n\n@mcp.tool()\nasync def data_sources() -> dict[str, Any]:\n \"\"\"Which feeds are always-on vs key-gated, and the configured Ollama\n host/model. Use to explain coverage gaps (e.g. fires need a FIRMS key).\"\"\"\n return await _get(\"/api/intel/sources\")\n\n\n# ── deep analysis (DeepSeek reasoner, Ollama fallback) ────────────────────────\n\n_SYS_PROMPT = (\n \"You are a GEOINT analyst working a live open-source intelligence console. \"\n \"You are given distilled JSON from live ADS-B (aircraft), AIS (vessels), a \"\n \"GPS-jamming layer (ADS-B NACp/NIC, GPSJam method), and a fusion engine. \"\n \"Reason ONLY over the provided JSON. Cite concrete numbers and IDs. Flag: \"\n \"GPS jamming/spoofing footprints, emergency squawks (7500 hijack / 7600 \"\n \"radio-fail / 7700 general), dark vessels, abnormal traffic density, and \"\n \"military activity. Be concise and structured: (1) ASSESSMENT (2 lines), \"\n \"(2) KEY FINDINGS (bullets with numbers), (3) RECOMMENDED FOLLOW-UP \"\n \"queries. If the data is thin, say so plainly — do not invent.\"\n)\n\n\n@mcp.tool()\nasync def deep_analyze(\n question: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 250.0,\n model: str | None = None,\n tier: str = \"reason\",\n) -> dict[str, Any]:\n \"\"\"Have a reasoning model reason deeply over the live intel.\n\n Gathers the relevant data (global situation, plus a focused area bundle +\n jamming + anomalies when lat/lon given) and feeds it to a real reasoning\n model — DeepSeek (``deepseek-reasoner``) when configured, else a local\n Ollama model. Heavy reasoning runs off-context; only the conclusion enters\n the agent's context. Falls back to returning the raw structured data\n (analysis=null) if no LLM backend is reachable.\n\n Args:\n question: what to analyse (e.g. \"Is there coordinated GPS jamming near\n the Baltic, and which aircraft are affected?\").\n lat, lon, radius_nm: optional area to focus on (loaded PRIMARY).\n model: optional Ollama model override used only on the Ollama fallback.\n tier: ``reason`` (deepseek-reasoner, default — judgement) or ``fast``\n (deepseek-chat — quicker, shallower).\n \"\"\"\n import json as _json\n\n from app import llm # noqa: PLC0415\n\n # 1) gather context (already compact)\n context: dict[str, Any] = {\"question\": question, \"situation\": await get_situation()}\n if lat is not None and lon is not None:\n context[\"focus_area\"] = await focus_area(lat, lon, radius_nm)\n else:\n context[\"global_jamming\"] = await gps_jamming()\n context[\"global_anomalies\"] = await anomalies()\n\n # 2) reason off-context (DeepSeek → Ollama → raw data)\n res = await llm.complete(\n _SYS_PROMPT,\n f\"QUESTION: {question}\\n\\nLIVE INTEL JSON:\\n\"\n + _json.dumps(context, separators=(\",\", \":\"))[:60000],\n tier=tier,\n temperature=0.2,\n max_tokens=2048,\n ollama_model=model or \"\",\n )\n if not res.ok:\n return {\n \"analysis\": None,\n \"model\": res.model,\n \"backend\": res.backend,\n \"note\": f\"No LLM backend reachable ({res.error}). Returning structured \"\n \"intel for you to analyse directly. Configure DEEPSEEK_API_KEY / the \"\n \"opencode DeepSeek key, or `ollama pull qwen2.5:3b`.\",\n \"data\": context,\n }\n\n return {\n \"analysis\": res.text,\n \"model\": res.model,\n \"backend\": res.backend,\n \"focused_on\": (\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm} if lat is not None else \"global\"\n ),\n \"data_summary\": {\n \"aircraft_total\": context[\"situation\"].get(\"aircraft\", {}).get(\"total\"),\n \"jamming_high\": context[\"situation\"].get(\"gps_jamming\", {}).get(\"high\"),\n },\n }\n\n\n# ── entrypoint ─────────────────────────────────────────────────────────────────\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=\"OSINT GEOINT MCP server\")\n parser.add_argument(\"--http\", action=\"store_true\", help=\"serve over streamable-HTTP\")\n parser.add_argument(\"--port\", type=int, default=8765, help=\"HTTP port (with --http)\")\n parser.add_argument(\"--host\", default=\"127.0.0.1\", help=\"HTTP host (with --http)\")\n parser.add_argument(\n \"--list-tools\", action=\"store_true\", help=\"print registered tools and exit\"\n )\n args = parser.parse_args()\n\n if args.list_tools:\n tools = asyncio.run(mcp.list_tools())\n print(f\"osint-geoint MCP — {len(tools)} tools:\")\n for t in tools:\n summary = (t.description or \"\").split(\"\\n\", 1)[0]\n print(f\" • {t.name}: {summary}\")\n return\n\n if args.http:\n mcp.settings.host = args.host\n mcp.settings.port = args.port\n mcp.run(transport=\"streamable-http\")\n else:\n mcp.run() # stdio\n\n\nif __name__ == \"__main__\":\n main()\n"}, "a1bc686aa4708df55d817a5168e117b352f72598": {"t": 1785765067.1721601, "n": 21417, "h": "b6792dbcccfc1f3e", "c": "\"\"\"OSINT GEOINT — Model Context Protocol server.\n\nExposes the live OSINT console as tools an AI agent can call. Every tool is a\nthin async wrapper over the ``/api/intel/*`` HTTP surface (``app.routes.intel``)\nso the agent shares the SAME warm in-process snapshot, AOI priority loader, and\nfusion engine the globe uses — no second upstream fan-out, no rate-limit blowup.\n\nDesign principles (the MCP brief):\n- **Context-safe.** Tools return distilled JSON (counts, grids, ≤50-item\n samples), never raw feature dumps. An agent can sweep the whole planet for a\n few hundred tokens.\n- **Area-primary.** ``focus_area`` marks a region PRIMARY: a dedicated fresh\n fetch + ongoing priority refresh, independent of global rate limits. Other\n regions keep streaming from the global snapshot.\n- **Think deeper, locally.** ``deep_analyze`` pulls the relevant intel JSON and\n hands it to a local Ollama model to reason over — heavy analysis happens on\n the box, only the conclusion returns to the agent's context.\n\nRun:\n python -m app.mcp_server # stdio (Claude Code / Desktop / SDK)\n python -m app.mcp_server --http --port 8765 # streamable-HTTP\n python -m app.mcp_server --list-tools # introspect, for CI / verification\n\nConfig (env or apps/api/.env): API_BASE, API_KEY, OLLAMA_HOST, OLLAMA_MODEL.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport atexit\nimport os\nimport subprocess\nimport sys\nfrom typing import Any\nfrom urllib.parse import quote, urlparse\n\nimport httpx\nfrom mcp.server.fastmcp import FastMCP\n\nfrom app.config import get_settings\n\nmcp = FastMCP(\n \"osint-geoint\",\n instructions=(\n \"Live geospatial intelligence over open ADS-B (aircraft), AIS (vessels), \"\n \"GPS-jamming (ADS-B NACp/NIC), and a fusion engine. Start with \"\n \"get_situation() to orient, then focus_area(lat,lon,radius_nm) to load a \"\n \"region PRIMARY and pull density / GPS jamming / anomalies for it. Use \"\n \"deep_analyze() to have a local model reason over the data.\"\n ),\n)\n\n\n# ── backend HTTP plumbing ─────────────────────────────────────────────────────\n\n\ndef _api_base() -> str:\n return (os.environ.get(\"API_BASE\") or get_settings().api_base).rstrip(\"/\")\n\n\ndef _headers() -> dict[str, str]:\n key = os.environ.get(\"API_KEY\") or get_settings().api_key\n return {\"X-API-Key\": key} if key else {}\n\n\n# ── backend auto-start ────────────────────────────────────────────────────────\n# The MCP server is a thin client over the OSINT backend. If that backend is\n# not running, every tool errors and a driving agent can spin in a retry loop.\n# So on the first tool call we make sure a backend exists: reuse one already\n# listening, else spawn uvicorn ourselves (localhost only) and wait — bounded —\n# for it to come up. Set OSINT_MCP_NO_AUTOSTART=1 to disable.\n\n_BACKEND_LOCK = asyncio.Lock()\n_BACKEND_READY = False\n_BACKEND_PROC: subprocess.Popen[bytes] | None = None\n_AUTOSTART_WAIT_S = 60\n\n\ndef _is_localhost(url: str) -> bool:\n host = (urlparse(url).hostname or \"\").lower()\n return host in {\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"::1\"}\n\n\ndef _api_port() -> int:\n return urlparse(_api_base()).port or 8000\n\n\ndef _apps_api_dir() -> str:\n import app # noqa: PLC0415\n\n return os.path.dirname(os.path.dirname(os.path.abspath(app.__file__)))\n\n\nasync def _backend_healthy(timeout_s: float = 2.0) -> bool:\n try:\n async with httpx.AsyncClient(timeout=timeout_s) as c:\n r = await c.get(_api_base() + \"/api/intel/sources\", headers=_headers())\n return r.status_code == 200\n except Exception:\n return False\n\n\ndef _spawn_uvicorn(cmd: list[str]) -> subprocess.Popen[bytes]:\n \"\"\"Sync spawn (Popen returns immediately; isolated from the async path).\"\"\"\n return subprocess.Popen(\n cmd,\n cwd=_apps_api_dir(),\n stdout=subprocess.DEVNULL,\n stderr=subprocess.DEVNULL,\n env=dict(os.environ),\n )\n\n\ndef _terminate_backend() -> None:\n proc = _BACKEND_PROC\n if proc is not None and proc.poll() is None:\n proc.terminate()\n\n\nasync def _prewarm() -> None:\n \"\"\"Kick the global ADS-B snapshot so the first get_situation isn't slow.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=120.0) as c:\n await c.get(_api_base() + \"/api/adsb/global\", headers=_headers())\n except Exception:\n pass\n\n\nasync def _ensure_backend() -> None:\n \"\"\"Idempotent: guarantee a reachable backend before a tool runs.\"\"\"\n global _BACKEND_READY, _BACKEND_PROC\n if _BACKEND_READY:\n return\n async with _BACKEND_LOCK:\n if _BACKEND_READY:\n return\n if await _backend_healthy():\n _BACKEND_READY = True\n return\n # Only auto-start a LOCAL backend, and only if not opted out.\n if os.environ.get(\"OSINT_MCP_NO_AUTOSTART\") or not _is_localhost(_api_base()):\n return\n # Spawn at most ONCE per process. If a prior attempt didn't come up,\n # don't re-spawn (avoids stacking uvicorns / re-waiting every call) —\n # just let later calls re-check health in case it's still warming.\n if _BACKEND_PROC is not None:\n return\n cmd = [\n sys.executable, \"-m\", \"uvicorn\", \"app.main:app\",\n \"--host\", \"127.0.0.1\", \"--port\", str(_api_port()), \"--log-level\", \"warning\",\n ]\n try:\n _BACKEND_PROC = _spawn_uvicorn(cmd)\n except Exception:\n return # can't spawn (e.g. uvicorn missing) — tools report cleanly\n atexit.register(_terminate_backend)\n for _ in range(_AUTOSTART_WAIT_S):\n await asyncio.sleep(1.0)\n if await _backend_healthy():\n _BACKEND_READY = True\n asyncio.create_task(_prewarm()) # warm snapshot off the hot path\n return\n # Did not come up in time; leave _BACKEND_READY False so the next tool\n # call retries once more rather than looping on a dead spawn.\n\n\nasync def _get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"GET an intel endpoint. Returns parsed JSON or a structured error dict —\n never raises, so a tool call always yields something the agent can read.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in (params or {}).items() if v is not None}\n # Generous ceiling: the backend's FIRST global call cold-starts the ADS-B\n # snapshot (firehose fetch + backoff) and can take up to ~75s. After that\n # every call is instant. A short timeout here would mis-report a healthy\n # backend as unreachable on the agent's very first query.\n try:\n async with httpx.AsyncClient(timeout=90.0) as client:\n r = await client.get(url, params=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\n \"error\": \"backend_unreachable\",\n \"detail\": str(exc),\n \"url\": url,\n \"hint\": \"Backend auto-start did not come up in time. It may still be \"\n \"warming — retry once. Or start it manually: uv run --project \"\n f\"{_apps_api_dir()} uvicorn app.main:app --port {_api_port()}\",\n }\n if r.status_code != 200:\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\n# ── tools ─────────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def get_situation() -> dict[str, Any]:\n \"\"\"Global situational snapshot — the cheap first call to orient.\n\n Returns total aircraft (airborne/ground, by category), GNSS-degraded count,\n active emergency squawks, the worst GPS-jamming cells worldwide, tracked\n vessel counts by category, and recent fusion-alert counts. A few hundred\n tokens describing the whole planet.\"\"\"\n return await _get(\"/api/intel/situation\")\n\n\n@mcp.tool()\nasync def focus_area(\n lat: float,\n lon: float,\n radius_nm: float = 200.0,\n label: str | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Load a region PRIMARY and return a full intel bundle for it in one call.\n\n This is the headline tool. It triggers a DEDICATED, always-fresh fetch for\n just this area (bypassing global rate limits) and registers it for ongoing\n priority refresh. The bundle contains: fresh aircraft summary + sample,\n aircraft-density grid, GPS-jamming assessment, vessels, and fused anomalies\n (emergencies, jamming hotspots, dark vessels, alerts) with a threat level.\n\n Args:\n lat, lon: centre of the area of interest.\n radius_nm: radius in nautical miles (1–250; default 200).\n label: optional human name for the AOI (e.g. \"Kaliningrad\").\n cell_deg: density grid cell size in degrees (0.1–10; default 1.0).\n \"\"\"\n return await _get(\n \"/api/intel/area\",\n {\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"label\": label,\n \"primary\": True,\n \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def aircraft_density(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Aircraft density over an area as a grid of cells (count, by category,\n GNSS-degraded per cell) plus totals, peak cell, and in-area vessel count.\n\n Give either a centre (lat, lon [, radius_nm]) or an explicit bbox\n (min_lon, min_lat, max_lon, max_lat). cell_deg sets grid resolution.\"\"\"\n return await _get(\n \"/api/intel/density\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat, \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def gps_jamming(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"GPS/GNSS jamming assessment (GPSJam method: ADS-B NACp<8 / NIC<7 binned\n into 1° cells). Returns flagged cells ranked by severity, counts of\n high/medium cells, and a sample of affected aircraft. Omit all coordinates\n for a global view; otherwise pass a centre or a bbox to scope it.\"\"\"\n return await _get(\n \"/api/intel/jamming\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def query_aircraft(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n category: str | None = None,\n squawk: str | None = None,\n callsign_contains: str | None = None,\n min_alt_m: float | None = None,\n max_alt_m: float | None = None,\n emergency: bool | None = None,\n gnss_degraded: bool | None = None,\n on_ground: bool | None = None,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Filtered aircraft query against the live snapshot. Returns matched_total\n + a capped list of compact records.\n\n category ∈ airliner|private|helicopter|glider|military|emergency.\n Combine with area (centre or bbox), squawk, callsign_contains, altitude\n band (metres), emergency / gnss_degraded / on_ground booleans. limit ≤200.\"\"\"\n return await _get(\n \"/api/intel/aircraft\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"category\": category, \"squawk\": squawk,\n \"callsign_contains\": callsign_contains,\n \"min_alt_m\": min_alt_m, \"max_alt_m\": max_alt_m,\n \"emergency\": emergency, \"gnss_degraded\": gnss_degraded,\n \"on_ground\": on_ground, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def lookup_aircraft(ident: str) -> dict[str, Any]:\n \"\"\"Look up one aircraft by ICAO24 hex (exact) or callsign (substring).\n Returns its current state, registration, an integrity/threat assessment,\n and the latest server-held fix.\"\"\"\n # quote(): ident is agent-supplied free text going into a URL path —\n # escape it so \"x/../y\" style values cannot re-route the request.\n return await _get(f\"/api/intel/aircraft/{quote(ident, safe='')}\")\n\n\n@mcp.tool()\nasync def query_vessels(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n dark_only: bool = False,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Vessels (AIS) in an area, classified (cargo/tanker/fishing/passenger/\n military/sailing/pleasure/tug). dark_only=True returns only dark-vessel\n candidates (moving with no static identity). Scoped by centre or bbox.\"\"\"\n return await _get(\n \"/api/intel/vessels\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"dark_only\": dark_only, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def anomalies(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"Fused anomaly report for an area (or global if no coords): emergency\n aircraft, GPS-jamming hotspots, dark-vessel candidates, recent fusion\n alerts, plus a triage threat_level (low|elevated|high) and score.\"\"\"\n return await _get(\n \"/api/intel/anomalies\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def list_focus_areas() -> dict[str, Any]:\n \"\"\"List the priority areas currently loaded PRIMARY (with fetch stats and\n whether each is served by a dedicated fetch or the degraded snapshot\n fallback).\"\"\"\n return await _get(\"/api/intel/aois\")\n\n\n@mcp.tool()\nasync def data_sources() -> dict[str, Any]:\n \"\"\"Which feeds are always-on vs key-gated, and the configured Ollama\n host/model. Use to explain coverage gaps (e.g. fires need a FIRMS key).\"\"\"\n return await _get(\"/api/intel/sources\")\n\n\n# ── Ollama deep analysis ──────────────────────────────────────────────────────\n\n_SYS_PROMPT = (\n \"You are a GEOINT analyst working a live open-source intelligence console. \"\n \"You are given distilled JSON from live ADS-B (aircraft), AIS (vessels), a \"\n \"GPS-jamming layer (ADS-B NACp/NIC, GPSJam method), and a fusion engine. \"\n \"Reason ONLY over the provided JSON. Cite concrete numbers and IDs. Flag: \"\n \"GPS jamming/spoofing footprints, emergency squawks (7500 hijack / 7600 \"\n \"radio-fail / 7700 general), dark vessels, abnormal traffic density, and \"\n \"military activity. Be concise and structured: (1) ASSESSMENT (2 lines), \"\n \"(2) KEY FINDINGS (bullets with numbers), (3) RECOMMENDED FOLLOW-UP \"\n \"queries. If the data is thin, say so plainly — do not invent.\"\n)\n\n_SMALL_HINTS = (\"a3b\", \"1b\", \"2b\", \"3b\", \"mini\", \"small\", \"phi\", \"gemma2:2b\", \"qwen2.5:3b\")\n\n\nasync def _ollama_models(host: str) -> list[str]:\n try:\n async with httpx.AsyncClient(timeout=8.0) as c:\n r = await c.get(host.rstrip(\"/\") + \"/api/tags\")\n if r.status_code != 200:\n return []\n return [m.get(\"name\", \"\") for m in (r.json().get(\"models\") or []) if m.get(\"name\")]\n except Exception:\n return []\n\n\ndef _pick_model(models: list[str], prefer: str) -> str | None:\n if prefer:\n # exact or prefix match against an installed tag\n for m in models:\n if m == prefer or m.startswith(prefer):\n return m\n return prefer # let Ollama try; it may pull/resolve\n if not models:\n return None\n # Prefer a small/fast model so deep_analyze stays responsive.\n for hint in _SMALL_HINTS:\n for m in models:\n if hint in m.lower():\n return m\n return models[0]\n\n\n@mcp.tool()\nasync def deep_analyze(\n question: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 250.0,\n model: str | None = None,\n) -> dict[str, Any]:\n \"\"\"Have a LOCAL Ollama model reason deeply over the live intel.\n\n Gathers the relevant data (global situation, plus a focused area bundle +\n jamming + anomalies when lat/lon given), feeds it to a small local model,\n and returns the model's analysis. Heavy reasoning runs on the box; only the\n conclusion enters your context. Falls back to returning the raw structured\n data (analysis=null) if Ollama is unreachable.\n\n Args:\n question: what to analyse (e.g. \"Is there coordinated GPS jamming near\n the Baltic, and which aircraft are affected?\").\n lat, lon, radius_nm: optional area to focus on (loaded PRIMARY).\n model: optional Ollama model override; defaults to the smallest\n installed model.\n \"\"\"\n settings = get_settings()\n host = os.environ.get(\"OLLAMA_HOST\") or settings.ollama_host\n\n # 1) gather context (already compact)\n context: dict[str, Any] = {\"question\": question, \"situation\": await get_situation()}\n if lat is not None and lon is not None:\n context[\"focus_area\"] = await focus_area(lat, lon, radius_nm)\n else:\n context[\"global_jamming\"] = await gps_jamming()\n context[\"global_anomalies\"] = await anomalies()\n\n # 2) choose a model\n models = await _ollama_models(host)\n chosen = _pick_model(models, model or os.environ.get(\"OLLAMA_MODEL\") or settings.ollama_model)\n if not chosen:\n return {\n \"analysis\": None,\n \"model\": None,\n \"note\": f\"Ollama unreachable at {host} or no models installed. \"\n \"Returning the structured intel for you to analyse directly. \"\n \"Install a small model, e.g. `ollama pull qwen2.5:3b`.\",\n \"available_models\": models,\n \"data\": context,\n }\n\n # 3) reason locally\n import json as _json\n\n payload = {\n \"model\": chosen,\n \"stream\": False,\n \"options\": {\"temperature\": 0.2},\n \"messages\": [\n {\"role\": \"system\", \"content\": _SYS_PROMPT},\n {\n \"role\": \"user\",\n \"content\": f\"QUESTION: {question}\\n\\nLIVE INTEL JSON:\\n\"\n + _json.dumps(context, separators=(\",\", \":\"))[:60000],\n },\n ],\n }\n try:\n async with httpx.AsyncClient(timeout=300.0) as c:\n r = await c.post(host.rstrip(\"/\") + \"/api/chat\", json=payload)\n if r.status_code != 200:\n raise RuntimeError(f\"ollama {r.status_code}: {r.text[:200]}\")\n body = r.json()\n analysis = (body.get(\"message\") or {}).get(\"content\", \"\").strip()\n except Exception as exc: # noqa: BLE001\n return {\n \"analysis\": None,\n \"model\": chosen,\n \"note\": f\"Ollama call failed ({exc}). Returning structured intel instead.\",\n \"data\": context,\n }\n\n return {\n \"analysis\": analysis,\n \"model\": chosen,\n \"focused_on\": (\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm} if lat is not None else \"global\"\n ),\n \"data_summary\": {\n \"aircraft_total\": context[\"situation\"].get(\"aircraft\", {}).get(\"total\"),\n \"jamming_high\": context[\"situation\"].get(\"gps_jamming\", {}).get(\"high\"),\n },\n }\n\n\n# ── entrypoint ─────────────────────────────────────────────────────────────────\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=\"OSINT GEOINT MCP server\")\n parser.add_argument(\"--http\", action=\"store_true\", help=\"serve over streamable-HTTP\")\n parser.add_argument(\"--port\", type=int, default=8765, help=\"HTTP port (with --http)\")\n parser.add_argument(\"--host\", default=\"127.0.0.1\", help=\"HTTP host (with --http)\")\n parser.add_argument(\n \"--list-tools\", action=\"store_true\", help=\"print registered tools and exit\"\n )\n args = parser.parse_args()\n\n if args.list_tools:\n tools = asyncio.run(mcp.list_tools())\n print(f\"osint-geoint MCP — {len(tools)} tools:\")\n for t in tools:\n summary = (t.description or \"\").split(\"\\n\", 1)[0]\n print(f\" • {t.name}: {summary}\")\n return\n\n if args.http:\n mcp.settings.host = args.host\n mcp.settings.port = args.port\n mcp.run(transport=\"streamable-http\")\n else:\n mcp.run() # stdio\n\n\nif __name__ == \"__main__\":\n main()\n"}, "7f7e7174cd1db22d2026a0adf0a41aab541b24ef": {"t": 1785765068.5802095, "n": 21251, "h": "b37df331c82dcb5f", "c": "\"\"\"OSINT GEOINT — Model Context Protocol server.\n\nExposes the live OSINT console as tools an AI agent can call. Every tool is a\nthin async wrapper over the ``/api/intel/*`` HTTP surface (``app.routes.intel``)\nso the agent shares the SAME warm in-process snapshot, AOI priority loader, and\nfusion engine the globe uses — no second upstream fan-out, no rate-limit blowup.\n\nDesign principles (the MCP brief):\n- **Context-safe.** Tools return distilled JSON (counts, grids, ≤50-item\n samples), never raw feature dumps. An agent can sweep the whole planet for a\n few hundred tokens.\n- **Area-primary.** ``focus_area`` marks a region PRIMARY: a dedicated fresh\n fetch + ongoing priority refresh, independent of global rate limits. Other\n regions keep streaming from the global snapshot.\n- **Think deeper, locally.** ``deep_analyze`` pulls the relevant intel JSON and\n hands it to a local Ollama model to reason over — heavy analysis happens on\n the box, only the conclusion returns to the agent's context.\n\nRun:\n python -m app.mcp_server # stdio (Claude Code / Desktop / SDK)\n python -m app.mcp_server --http --port 8765 # streamable-HTTP\n python -m app.mcp_server --list-tools # introspect, for CI / verification\n\nConfig (env or apps/api/.env): API_BASE, API_KEY, OLLAMA_HOST, OLLAMA_MODEL.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport atexit\nimport os\nimport subprocess\nimport sys\nfrom typing import Any\nfrom urllib.parse import urlparse\n\nimport httpx\nfrom mcp.server.fastmcp import FastMCP\n\nfrom app.config import get_settings\n\nmcp = FastMCP(\n \"osint-geoint\",\n instructions=(\n \"Live geospatial intelligence over open ADS-B (aircraft), AIS (vessels), \"\n \"GPS-jamming (ADS-B NACp/NIC), and a fusion engine. Start with \"\n \"get_situation() to orient, then focus_area(lat,lon,radius_nm) to load a \"\n \"region PRIMARY and pull density / GPS jamming / anomalies for it. Use \"\n \"deep_analyze() to have a local model reason over the data.\"\n ),\n)\n\n\n# ── backend HTTP plumbing ─────────────────────────────────────────────────────\n\n\ndef _api_base() -> str:\n return (os.environ.get(\"API_BASE\") or get_settings().api_base).rstrip(\"/\")\n\n\ndef _headers() -> dict[str, str]:\n key = os.environ.get(\"API_KEY\") or get_settings().api_key\n return {\"X-API-Key\": key} if key else {}\n\n\n# ── backend auto-start ────────────────────────────────────────────────────────\n# The MCP server is a thin client over the OSINT backend. If that backend is\n# not running, every tool errors and a driving agent can spin in a retry loop.\n# So on the first tool call we make sure a backend exists: reuse one already\n# listening, else spawn uvicorn ourselves (localhost only) and wait — bounded —\n# for it to come up. Set OSINT_MCP_NO_AUTOSTART=1 to disable.\n\n_BACKEND_LOCK = asyncio.Lock()\n_BACKEND_READY = False\n_BACKEND_PROC: subprocess.Popen[bytes] | None = None\n_AUTOSTART_WAIT_S = 60\n\n\ndef _is_localhost(url: str) -> bool:\n host = (urlparse(url).hostname or \"\").lower()\n return host in {\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \"::1\"}\n\n\ndef _api_port() -> int:\n return urlparse(_api_base()).port or 8000\n\n\ndef _apps_api_dir() -> str:\n import app # noqa: PLC0415\n\n return os.path.dirname(os.path.dirname(os.path.abspath(app.__file__)))\n\n\nasync def _backend_healthy(timeout_s: float = 2.0) -> bool:\n try:\n async with httpx.AsyncClient(timeout=timeout_s) as c:\n r = await c.get(_api_base() + \"/api/intel/sources\", headers=_headers())\n return r.status_code == 200\n except Exception:\n return False\n\n\ndef _spawn_uvicorn(cmd: list[str]) -> subprocess.Popen[bytes]:\n \"\"\"Sync spawn (Popen returns immediately; isolated from the async path).\"\"\"\n return subprocess.Popen(\n cmd,\n cwd=_apps_api_dir(),\n stdout=subprocess.DEVNULL,\n stderr=subprocess.DEVNULL,\n env=dict(os.environ),\n )\n\n\ndef _terminate_backend() -> None:\n proc = _BACKEND_PROC\n if proc is not None and proc.poll() is None:\n proc.terminate()\n\n\nasync def _prewarm() -> None:\n \"\"\"Kick the global ADS-B snapshot so the first get_situation isn't slow.\"\"\"\n try:\n async with httpx.AsyncClient(timeout=120.0) as c:\n await c.get(_api_base() + \"/api/adsb/global\", headers=_headers())\n except Exception:\n pass\n\n\nasync def _ensure_backend() -> None:\n \"\"\"Idempotent: guarantee a reachable backend before a tool runs.\"\"\"\n global _BACKEND_READY, _BACKEND_PROC\n if _BACKEND_READY:\n return\n async with _BACKEND_LOCK:\n if _BACKEND_READY:\n return\n if await _backend_healthy():\n _BACKEND_READY = True\n return\n # Only auto-start a LOCAL backend, and only if not opted out.\n if os.environ.get(\"OSINT_MCP_NO_AUTOSTART\") or not _is_localhost(_api_base()):\n return\n # Spawn at most ONCE per process. If a prior attempt didn't come up,\n # don't re-spawn (avoids stacking uvicorns / re-waiting every call) —\n # just let later calls re-check health in case it's still warming.\n if _BACKEND_PROC is not None:\n return\n cmd = [\n sys.executable, \"-m\", \"uvicorn\", \"app.main:app\",\n \"--host\", \"127.0.0.1\", \"--port\", str(_api_port()), \"--log-level\", \"warning\",\n ]\n try:\n _BACKEND_PROC = _spawn_uvicorn(cmd)\n except Exception:\n return # can't spawn (e.g. uvicorn missing) — tools report cleanly\n atexit.register(_terminate_backend)\n for _ in range(_AUTOSTART_WAIT_S):\n await asyncio.sleep(1.0)\n if await _backend_healthy():\n _BACKEND_READY = True\n asyncio.create_task(_prewarm()) # warm snapshot off the hot path\n return\n # Did not come up in time; leave _BACKEND_READY False so the next tool\n # call retries once more rather than looping on a dead spawn.\n\n\nasync def _get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"GET an intel endpoint. Returns parsed JSON or a structured error dict —\n never raises, so a tool call always yields something the agent can read.\"\"\"\n await _ensure_backend()\n url = _api_base() + path\n clean = {k: v for k, v in (params or {}).items() if v is not None}\n # Generous ceiling: the backend's FIRST global call cold-starts the ADS-B\n # snapshot (firehose fetch + backoff) and can take up to ~75s. After that\n # every call is instant. A short timeout here would mis-report a healthy\n # backend as unreachable on the agent's very first query.\n try:\n async with httpx.AsyncClient(timeout=90.0) as client:\n r = await client.get(url, params=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\n \"error\": \"backend_unreachable\",\n \"detail\": str(exc),\n \"url\": url,\n \"hint\": \"Backend auto-start did not come up in time. It may still be \"\n \"warming — retry once. Or start it manually: uv run --project \"\n f\"{_apps_api_dir()} uvicorn app.main:app --port {_api_port()}\",\n }\n if r.status_code != 200:\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\n# ── tools ─────────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def get_situation() -> dict[str, Any]:\n \"\"\"Global situational snapshot — the cheap first call to orient.\n\n Returns total aircraft (airborne/ground, by category), GNSS-degraded count,\n active emergency squawks, the worst GPS-jamming cells worldwide, tracked\n vessel counts by category, and recent fusion-alert counts. A few hundred\n tokens describing the whole planet.\"\"\"\n return await _get(\"/api/intel/situation\")\n\n\n@mcp.tool()\nasync def focus_area(\n lat: float,\n lon: float,\n radius_nm: float = 200.0,\n label: str | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Load a region PRIMARY and return a full intel bundle for it in one call.\n\n This is the headline tool. It triggers a DEDICATED, always-fresh fetch for\n just this area (bypassing global rate limits) and registers it for ongoing\n priority refresh. The bundle contains: fresh aircraft summary + sample,\n aircraft-density grid, GPS-jamming assessment, vessels, and fused anomalies\n (emergencies, jamming hotspots, dark vessels, alerts) with a threat level.\n\n Args:\n lat, lon: centre of the area of interest.\n radius_nm: radius in nautical miles (1–250; default 200).\n label: optional human name for the AOI (e.g. \"Kaliningrad\").\n cell_deg: density grid cell size in degrees (0.1–10; default 1.0).\n \"\"\"\n return await _get(\n \"/api/intel/area\",\n {\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"label\": label,\n \"primary\": True,\n \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def aircraft_density(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Aircraft density over an area as a grid of cells (count, by category,\n GNSS-degraded per cell) plus totals, peak cell, and in-area vessel count.\n\n Give either a centre (lat, lon [, radius_nm]) or an explicit bbox\n (min_lon, min_lat, max_lon, max_lat). cell_deg sets grid resolution.\"\"\"\n return await _get(\n \"/api/intel/density\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat, \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def gps_jamming(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"GPS/GNSS jamming assessment (GPSJam method: ADS-B NACp<8 / NIC<7 binned\n into 1° cells). Returns flagged cells ranked by severity, counts of\n high/medium cells, and a sample of affected aircraft. Omit all coordinates\n for a global view; otherwise pass a centre or a bbox to scope it.\"\"\"\n return await _get(\n \"/api/intel/jamming\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def query_aircraft(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n category: str | None = None,\n squawk: str | None = None,\n callsign_contains: str | None = None,\n min_alt_m: float | None = None,\n max_alt_m: float | None = None,\n emergency: bool | None = None,\n gnss_degraded: bool | None = None,\n on_ground: bool | None = None,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Filtered aircraft query against the live snapshot. Returns matched_total\n + a capped list of compact records.\n\n category ∈ airliner|private|helicopter|glider|military|emergency.\n Combine with area (centre or bbox), squawk, callsign_contains, altitude\n band (metres), emergency / gnss_degraded / on_ground booleans. limit ≤200.\"\"\"\n return await _get(\n \"/api/intel/aircraft\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"category\": category, \"squawk\": squawk,\n \"callsign_contains\": callsign_contains,\n \"min_alt_m\": min_alt_m, \"max_alt_m\": max_alt_m,\n \"emergency\": emergency, \"gnss_degraded\": gnss_degraded,\n \"on_ground\": on_ground, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def lookup_aircraft(ident: str) -> dict[str, Any]:\n \"\"\"Look up one aircraft by ICAO24 hex (exact) or callsign (substring).\n Returns its current state, registration, an integrity/threat assessment,\n and the latest server-held fix.\"\"\"\n return await _get(f\"/api/intel/aircraft/{ident}\")\n\n\n@mcp.tool()\nasync def query_vessels(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n dark_only: bool = False,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Vessels (AIS) in an area, classified (cargo/tanker/fishing/passenger/\n military/sailing/pleasure/tug). dark_only=True returns only dark-vessel\n candidates (moving with no static identity). Scoped by centre or bbox.\"\"\"\n return await _get(\n \"/api/intel/vessels\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"dark_only\": dark_only, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def anomalies(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"Fused anomaly report for an area (or global if no coords): emergency\n aircraft, GPS-jamming hotspots, dark-vessel candidates, recent fusion\n alerts, plus a triage threat_level (low|elevated|high) and score.\"\"\"\n return await _get(\n \"/api/intel/anomalies\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def list_focus_areas() -> dict[str, Any]:\n \"\"\"List the priority areas currently loaded PRIMARY (with fetch stats and\n whether each is served by a dedicated fetch or the degraded snapshot\n fallback).\"\"\"\n return await _get(\"/api/intel/aois\")\n\n\n@mcp.tool()\nasync def data_sources() -> dict[str, Any]:\n \"\"\"Which feeds are always-on vs key-gated, and the configured Ollama\n host/model. Use to explain coverage gaps (e.g. fires need a FIRMS key).\"\"\"\n return await _get(\"/api/intel/sources\")\n\n\n# ── Ollama deep analysis ──────────────────────────────────────────────────────\n\n_SYS_PROMPT = (\n \"You are a GEOINT analyst working a live open-source intelligence console. \"\n \"You are given distilled JSON from live ADS-B (aircraft), AIS (vessels), a \"\n \"GPS-jamming layer (ADS-B NACp/NIC, GPSJam method), and a fusion engine. \"\n \"Reason ONLY over the provided JSON. Cite concrete numbers and IDs. Flag: \"\n \"GPS jamming/spoofing footprints, emergency squawks (7500 hijack / 7600 \"\n \"radio-fail / 7700 general), dark vessels, abnormal traffic density, and \"\n \"military activity. Be concise and structured: (1) ASSESSMENT (2 lines), \"\n \"(2) KEY FINDINGS (bullets with numbers), (3) RECOMMENDED FOLLOW-UP \"\n \"queries. If the data is thin, say so plainly — do not invent.\"\n)\n\n_SMALL_HINTS = (\"a3b\", \"1b\", \"2b\", \"3b\", \"mini\", \"small\", \"phi\", \"gemma2:2b\", \"qwen2.5:3b\")\n\n\nasync def _ollama_models(host: str) -> list[str]:\n try:\n async with httpx.AsyncClient(timeout=8.0) as c:\n r = await c.get(host.rstrip(\"/\") + \"/api/tags\")\n if r.status_code != 200:\n return []\n return [m.get(\"name\", \"\") for m in (r.json().get(\"models\") or []) if m.get(\"name\")]\n except Exception:\n return []\n\n\ndef _pick_model(models: list[str], prefer: str) -> str | None:\n if prefer:\n # exact or prefix match against an installed tag\n for m in models:\n if m == prefer or m.startswith(prefer):\n return m\n return prefer # let Ollama try; it may pull/resolve\n if not models:\n return None\n # Prefer a small/fast model so deep_analyze stays responsive.\n for hint in _SMALL_HINTS:\n for m in models:\n if hint in m.lower():\n return m\n return models[0]\n\n\n@mcp.tool()\nasync def deep_analyze(\n question: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 250.0,\n model: str | None = None,\n) -> dict[str, Any]:\n \"\"\"Have a LOCAL Ollama model reason deeply over the live intel.\n\n Gathers the relevant data (global situation, plus a focused area bundle +\n jamming + anomalies when lat/lon given), feeds it to a small local model,\n and returns the model's analysis. Heavy reasoning runs on the box; only the\n conclusion enters your context. Falls back to returning the raw structured\n data (analysis=null) if Ollama is unreachable.\n\n Args:\n question: what to analyse (e.g. \"Is there coordinated GPS jamming near\n the Baltic, and which aircraft are affected?\").\n lat, lon, radius_nm: optional area to focus on (loaded PRIMARY).\n model: optional Ollama model override; defaults to the smallest\n installed model.\n \"\"\"\n settings = get_settings()\n host = os.environ.get(\"OLLAMA_HOST\") or settings.ollama_host\n\n # 1) gather context (already compact)\n context: dict[str, Any] = {\"question\": question, \"situation\": await get_situation()}\n if lat is not None and lon is not None:\n context[\"focus_area\"] = await focus_area(lat, lon, radius_nm)\n else:\n context[\"global_jamming\"] = await gps_jamming()\n context[\"global_anomalies\"] = await anomalies()\n\n # 2) choose a model\n models = await _ollama_models(host)\n chosen = _pick_model(models, model or os.environ.get(\"OLLAMA_MODEL\") or settings.ollama_model)\n if not chosen:\n return {\n \"analysis\": None,\n \"model\": None,\n \"note\": f\"Ollama unreachable at {host} or no models installed. \"\n \"Returning the structured intel for you to analyse directly. \"\n \"Install a small model, e.g. `ollama pull qwen2.5:3b`.\",\n \"available_models\": models,\n \"data\": context,\n }\n\n # 3) reason locally\n import json as _json\n\n payload = {\n \"model\": chosen,\n \"stream\": False,\n \"options\": {\"temperature\": 0.2},\n \"messages\": [\n {\"role\": \"system\", \"content\": _SYS_PROMPT},\n {\n \"role\": \"user\",\n \"content\": f\"QUESTION: {question}\\n\\nLIVE INTEL JSON:\\n\"\n + _json.dumps(context, separators=(\",\", \":\"))[:60000],\n },\n ],\n }\n try:\n async with httpx.AsyncClient(timeout=300.0) as c:\n r = await c.post(host.rstrip(\"/\") + \"/api/chat\", json=payload)\n if r.status_code != 200:\n raise RuntimeError(f\"ollama {r.status_code}: {r.text[:200]}\")\n body = r.json()\n analysis = (body.get(\"message\") or {}).get(\"content\", \"\").strip()\n except Exception as exc: # noqa: BLE001\n return {\n \"analysis\": None,\n \"model\": chosen,\n \"note\": f\"Ollama call failed ({exc}). Returning structured intel instead.\",\n \"data\": context,\n }\n\n return {\n \"analysis\": analysis,\n \"model\": chosen,\n \"focused_on\": (\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm} if lat is not None else \"global\"\n ),\n \"data_summary\": {\n \"aircraft_total\": context[\"situation\"].get(\"aircraft\", {}).get(\"total\"),\n \"jamming_high\": context[\"situation\"].get(\"gps_jamming\", {}).get(\"high\"),\n },\n }\n\n\n# ── entrypoint ─────────────────────────────────────────────────────────────────\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=\"OSINT GEOINT MCP server\")\n parser.add_argument(\"--http\", action=\"store_true\", help=\"serve over streamable-HTTP\")\n parser.add_argument(\"--port\", type=int, default=8765, help=\"HTTP port (with --http)\")\n parser.add_argument(\"--host\", default=\"127.0.0.1\", help=\"HTTP host (with --http)\")\n parser.add_argument(\n \"--list-tools\", action=\"store_true\", help=\"print registered tools and exit\"\n )\n args = parser.parse_args()\n\n if args.list_tools:\n tools = asyncio.run(mcp.list_tools())\n print(f\"osint-geoint MCP — {len(tools)} tools:\")\n for t in tools:\n summary = (t.description or \"\").split(\"\\n\", 1)[0]\n print(f\" • {t.name}: {summary}\")\n return\n\n if args.http:\n mcp.settings.host = args.host\n mcp.settings.port = args.port\n mcp.run(transport=\"streamable-http\")\n else:\n mcp.run() # stdio\n\n\nif __name__ == \"__main__\":\n main()\n"}, "40a29bc77491e692da9886dc141d742797c2e6a9": {"t": 1785765070.56704, "n": 17391, "h": "fe156a96e5146658", "c": "\"\"\"OSINT GEOINT — Model Context Protocol server.\n\nExposes the live OSINT console as tools an AI agent can call. Every tool is a\nthin async wrapper over the ``/api/intel/*`` HTTP surface (``app.routes.intel``)\nso the agent shares the SAME warm in-process snapshot, AOI priority loader, and\nfusion engine the globe uses — no second upstream fan-out, no rate-limit blowup.\n\nDesign principles (the MCP brief):\n- **Context-safe.** Tools return distilled JSON (counts, grids, ≤50-item\n samples), never raw feature dumps. An agent can sweep the whole planet for a\n few hundred tokens.\n- **Area-primary.** ``focus_area`` marks a region PRIMARY: a dedicated fresh\n fetch + ongoing priority refresh, independent of global rate limits. Other\n regions keep streaming from the global snapshot.\n- **Think deeper, locally.** ``deep_analyze`` pulls the relevant intel JSON and\n hands it to a local Ollama model to reason over — heavy analysis happens on\n the box, only the conclusion returns to the agent's context.\n\nRun:\n python -m app.mcp_server # stdio (Claude Code / Desktop / SDK)\n python -m app.mcp_server --http --port 8765 # streamable-HTTP\n python -m app.mcp_server --list-tools # introspect, for CI / verification\n\nConfig (env or apps/api/.env): API_BASE, API_KEY, OLLAMA_HOST, OLLAMA_MODEL.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport os\nfrom typing import Any\n\nimport httpx\nfrom mcp.server.fastmcp import FastMCP\n\nfrom app.config import get_settings\n\nmcp = FastMCP(\n \"osint-geoint\",\n instructions=(\n \"Live geospatial intelligence over open ADS-B (aircraft), AIS (vessels), \"\n \"GPS-jamming (ADS-B NACp/NIC), and a fusion engine. Start with \"\n \"get_situation() to orient, then focus_area(lat,lon,radius_nm) to load a \"\n \"region PRIMARY and pull density / GPS jamming / anomalies for it. Use \"\n \"deep_analyze() to have a local model reason over the data.\"\n ),\n)\n\n\n# ── backend HTTP plumbing ─────────────────────────────────────────────────────\n\n\ndef _api_base() -> str:\n return (os.environ.get(\"API_BASE\") or get_settings().api_base).rstrip(\"/\")\n\n\ndef _headers() -> dict[str, str]:\n key = os.environ.get(\"API_KEY\") or get_settings().api_key\n return {\"X-API-Key\": key} if key else {}\n\n\nasync def _get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:\n \"\"\"GET an intel endpoint. Returns parsed JSON or a structured error dict —\n never raises, so a tool call always yields something the agent can read.\"\"\"\n url = _api_base() + path\n clean = {k: v for k, v in (params or {}).items() if v is not None}\n # Generous ceiling: the backend's FIRST global call cold-starts the ADS-B\n # snapshot (firehose fetch + backoff) and can take up to ~75s. After that\n # every call is instant. A short timeout here would mis-report a healthy\n # backend as unreachable on the agent's very first query.\n try:\n async with httpx.AsyncClient(timeout=90.0) as client:\n r = await client.get(url, params=clean, headers=_headers())\n except Exception as exc: # noqa: BLE001 — surface, don't crash the tool\n return {\n \"error\": \"backend_unreachable\",\n \"detail\": str(exc),\n \"url\": url,\n \"hint\": \"Start the API: cd apps/api && .venv/bin/uvicorn app.main:app --port 8000\",\n }\n if r.status_code != 200:\n return {\"error\": f\"backend_{r.status_code}\", \"detail\": r.text[:400], \"url\": url}\n try:\n data: Any = r.json()\n except ValueError:\n return {\"error\": \"bad_json\", \"detail\": r.text[:400], \"url\": url}\n return data if isinstance(data, dict) else {\"result\": data}\n\n\n# ── tools ─────────────────────────────────────────────────────────────────────\n\n\n@mcp.tool()\nasync def get_situation() -> dict[str, Any]:\n \"\"\"Global situational snapshot — the cheap first call to orient.\n\n Returns total aircraft (airborne/ground, by category), GNSS-degraded count,\n active emergency squawks, the worst GPS-jamming cells worldwide, tracked\n vessel counts by category, and recent fusion-alert counts. A few hundred\n tokens describing the whole planet.\"\"\"\n return await _get(\"/api/intel/situation\")\n\n\n@mcp.tool()\nasync def focus_area(\n lat: float,\n lon: float,\n radius_nm: float = 200.0,\n label: str | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Load a region PRIMARY and return a full intel bundle for it in one call.\n\n This is the headline tool. It triggers a DEDICATED, always-fresh fetch for\n just this area (bypassing global rate limits) and registers it for ongoing\n priority refresh. The bundle contains: fresh aircraft summary + sample,\n aircraft-density grid, GPS-jamming assessment, vessels, and fused anomalies\n (emergencies, jamming hotspots, dark vessels, alerts) with a threat level.\n\n Args:\n lat, lon: centre of the area of interest.\n radius_nm: radius in nautical miles (1–250; default 200).\n label: optional human name for the AOI (e.g. \"Kaliningrad\").\n cell_deg: density grid cell size in degrees (0.1–10; default 1.0).\n \"\"\"\n return await _get(\n \"/api/intel/area\",\n {\n \"lat\": lat,\n \"lon\": lon,\n \"radius_nm\": radius_nm,\n \"label\": label,\n \"primary\": True,\n \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def aircraft_density(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n cell_deg: float = 1.0,\n) -> dict[str, Any]:\n \"\"\"Aircraft density over an area as a grid of cells (count, by category,\n GNSS-degraded per cell) plus totals, peak cell, and in-area vessel count.\n\n Give either a centre (lat, lon [, radius_nm]) or an explicit bbox\n (min_lon, min_lat, max_lon, max_lat). cell_deg sets grid resolution.\"\"\"\n return await _get(\n \"/api/intel/density\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat, \"cell_deg\": cell_deg,\n },\n )\n\n\n@mcp.tool()\nasync def gps_jamming(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"GPS/GNSS jamming assessment (GPSJam method: ADS-B NACp<8 / NIC<7 binned\n into 1° cells). Returns flagged cells ranked by severity, counts of\n high/medium cells, and a sample of affected aircraft. Omit all coordinates\n for a global view; otherwise pass a centre or a bbox to scope it.\"\"\"\n return await _get(\n \"/api/intel/jamming\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def query_aircraft(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 200.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n category: str | None = None,\n squawk: str | None = None,\n callsign_contains: str | None = None,\n min_alt_m: float | None = None,\n max_alt_m: float | None = None,\n emergency: bool | None = None,\n gnss_degraded: bool | None = None,\n on_ground: bool | None = None,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Filtered aircraft query against the live snapshot. Returns matched_total\n + a capped list of compact records.\n\n category ∈ airliner|private|helicopter|glider|military|emergency.\n Combine with area (centre or bbox), squawk, callsign_contains, altitude\n band (metres), emergency / gnss_degraded / on_ground booleans. limit ≤200.\"\"\"\n return await _get(\n \"/api/intel/aircraft\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"category\": category, \"squawk\": squawk,\n \"callsign_contains\": callsign_contains,\n \"min_alt_m\": min_alt_m, \"max_alt_m\": max_alt_m,\n \"emergency\": emergency, \"gnss_degraded\": gnss_degraded,\n \"on_ground\": on_ground, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def lookup_aircraft(ident: str) -> dict[str, Any]:\n \"\"\"Look up one aircraft by ICAO24 hex (exact) or callsign (substring).\n Returns its current state, registration, an integrity/threat assessment,\n and the latest server-held fix.\"\"\"\n return await _get(f\"/api/intel/aircraft/{ident}\")\n\n\n@mcp.tool()\nasync def query_vessels(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n dark_only: bool = False,\n limit: int = 50,\n) -> dict[str, Any]:\n \"\"\"Vessels (AIS) in an area, classified (cargo/tanker/fishing/passenger/\n military/sailing/pleasure/tug). dark_only=True returns only dark-vessel\n candidates (moving with no static identity). Scoped by centre or bbox.\"\"\"\n return await _get(\n \"/api/intel/vessels\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n \"dark_only\": dark_only, \"limit\": limit,\n },\n )\n\n\n@mcp.tool()\nasync def anomalies(\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 500.0,\n min_lon: float | None = None,\n min_lat: float | None = None,\n max_lon: float | None = None,\n max_lat: float | None = None,\n) -> dict[str, Any]:\n \"\"\"Fused anomaly report for an area (or global if no coords): emergency\n aircraft, GPS-jamming hotspots, dark-vessel candidates, recent fusion\n alerts, plus a triage threat_level (low|elevated|high) and score.\"\"\"\n return await _get(\n \"/api/intel/anomalies\",\n {\n \"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm,\n \"min_lon\": min_lon, \"min_lat\": min_lat,\n \"max_lon\": max_lon, \"max_lat\": max_lat,\n },\n )\n\n\n@mcp.tool()\nasync def list_focus_areas() -> dict[str, Any]:\n \"\"\"List the priority areas currently loaded PRIMARY (with fetch stats and\n whether each is served by a dedicated fetch or the degraded snapshot\n fallback).\"\"\"\n return await _get(\"/api/intel/aois\")\n\n\n@mcp.tool()\nasync def data_sources() -> dict[str, Any]:\n \"\"\"Which feeds are always-on vs key-gated, and the configured Ollama\n host/model. Use to explain coverage gaps (e.g. fires need a FIRMS key).\"\"\"\n return await _get(\"/api/intel/sources\")\n\n\n# ── Ollama deep analysis ──────────────────────────────────────────────────────\n\n_SYS_PROMPT = (\n \"You are a GEOINT analyst working a live open-source intelligence console. \"\n \"You are given distilled JSON from live ADS-B (aircraft), AIS (vessels), a \"\n \"GPS-jamming layer (ADS-B NACp/NIC, GPSJam method), and a fusion engine. \"\n \"Reason ONLY over the provided JSON. Cite concrete numbers and IDs. Flag: \"\n \"GPS jamming/spoofing footprints, emergency squawks (7500 hijack / 7600 \"\n \"radio-fail / 7700 general), dark vessels, abnormal traffic density, and \"\n \"military activity. Be concise and structured: (1) ASSESSMENT (2 lines), \"\n \"(2) KEY FINDINGS (bullets with numbers), (3) RECOMMENDED FOLLOW-UP \"\n \"queries. If the data is thin, say so plainly — do not invent.\"\n)\n\n_SMALL_HINTS = (\"a3b\", \"1b\", \"2b\", \"3b\", \"mini\", \"small\", \"phi\", \"gemma2:2b\", \"qwen2.5:3b\")\n\n\nasync def _ollama_models(host: str) -> list[str]:\n try:\n async with httpx.AsyncClient(timeout=8.0) as c:\n r = await c.get(host.rstrip(\"/\") + \"/api/tags\")\n if r.status_code != 200:\n return []\n return [m.get(\"name\", \"\") for m in (r.json().get(\"models\") or []) if m.get(\"name\")]\n except Exception:\n return []\n\n\ndef _pick_model(models: list[str], prefer: str) -> str | None:\n if prefer:\n # exact or prefix match against an installed tag\n for m in models:\n if m == prefer or m.startswith(prefer):\n return m\n return prefer # let Ollama try; it may pull/resolve\n if not models:\n return None\n # Prefer a small/fast model so deep_analyze stays responsive.\n for hint in _SMALL_HINTS:\n for m in models:\n if hint in m.lower():\n return m\n return models[0]\n\n\n@mcp.tool()\nasync def deep_analyze(\n question: str,\n lat: float | None = None,\n lon: float | None = None,\n radius_nm: float = 250.0,\n model: str | None = None,\n) -> dict[str, Any]:\n \"\"\"Have a LOCAL Ollama model reason deeply over the live intel.\n\n Gathers the relevant data (global situation, plus a focused area bundle +\n jamming + anomalies when lat/lon given), feeds it to a small local model,\n and returns the model's analysis. Heavy reasoning runs on the box; only the\n conclusion enters your context. Falls back to returning the raw structured\n data (analysis=null) if Ollama is unreachable.\n\n Args:\n question: what to analyse (e.g. \"Is there coordinated GPS jamming near\n the Baltic, and which aircraft are affected?\").\n lat, lon, radius_nm: optional area to focus on (loaded PRIMARY).\n model: optional Ollama model override; defaults to the smallest\n installed model.\n \"\"\"\n settings = get_settings()\n host = os.environ.get(\"OLLAMA_HOST\") or settings.ollama_host\n\n # 1) gather context (already compact)\n context: dict[str, Any] = {\"question\": question, \"situation\": await get_situation()}\n if lat is not None and lon is not None:\n context[\"focus_area\"] = await focus_area(lat, lon, radius_nm)\n else:\n context[\"global_jamming\"] = await gps_jamming()\n context[\"global_anomalies\"] = await anomalies()\n\n # 2) choose a model\n models = await _ollama_models(host)\n chosen = _pick_model(models, model or os.environ.get(\"OLLAMA_MODEL\") or settings.ollama_model)\n if not chosen:\n return {\n \"analysis\": None,\n \"model\": None,\n \"note\": f\"Ollama unreachable at {host} or no models installed. \"\n \"Returning the structured intel for you to analyse directly. \"\n \"Install a small model, e.g. `ollama pull qwen2.5:3b`.\",\n \"available_models\": models,\n \"data\": context,\n }\n\n # 3) reason locally\n import json as _json\n\n payload = {\n \"model\": chosen,\n \"stream\": False,\n \"options\": {\"temperature\": 0.2},\n \"messages\": [\n {\"role\": \"system\", \"content\": _SYS_PROMPT},\n {\n \"role\": \"user\",\n \"content\": f\"QUESTION: {question}\\n\\nLIVE INTEL JSON:\\n\"\n + _json.dumps(context, separators=(\",\", \":\"))[:60000],\n },\n ],\n }\n try:\n async with httpx.AsyncClient(timeout=300.0) as c:\n r = await c.post(host.rstrip(\"/\") + \"/api/chat\", json=payload)\n if r.status_code != 200:\n raise RuntimeError(f\"ollama {r.status_code}: {r.text[:200]}\")\n body = r.json()\n analysis = (body.get(\"message\") or {}).get(\"content\", \"\").strip()\n except Exception as exc: # noqa: BLE001\n return {\n \"analysis\": None,\n \"model\": chosen,\n \"note\": f\"Ollama call failed ({exc}). Returning structured intel instead.\",\n \"data\": context,\n }\n\n return {\n \"analysis\": analysis,\n \"model\": chosen,\n \"focused_on\": (\n {\"lat\": lat, \"lon\": lon, \"radius_nm\": radius_nm} if lat is not None else \"global\"\n ),\n \"data_summary\": {\n \"aircraft_total\": context[\"situation\"].get(\"aircraft\", {}).get(\"total\"),\n \"jamming_high\": context[\"situation\"].get(\"gps_jamming\", {}).get(\"high\"),\n },\n }\n\n\n# ── entrypoint ─────────────────────────────────────────────────────────────────\n\n\ndef main() -> None:\n parser = argparse.ArgumentParser(description=\"OSINT GEOINT MCP server\")\n parser.add_argument(\"--http\", action=\"store_true\", help=\"serve over streamable-HTTP\")\n parser.add_argument(\"--port\", type=int, default=8765, help=\"HTTP port (with --http)\")\n parser.add_argument(\"--host\", default=\"127.0.0.1\", help=\"HTTP host (with --http)\")\n parser.add_argument(\n \"--list-tools\", action=\"store_true\", help=\"print registered tools and exit\"\n )\n args = parser.parse_args()\n\n if args.list_tools:\n tools = asyncio.run(mcp.list_tools())\n print(f\"osint-geoint MCP — {len(tools)} tools:\")\n for t in tools:\n summary = (t.description or \"\").split(\"\\n\", 1)[0]\n print(f\" • {t.name}: {summary}\")\n return\n\n if args.http:\n mcp.settings.host = args.host\n mcp.settings.port = args.port\n mcp.run(transport=\"streamable-http\")\n else:\n mcp.run() # stdio\n\n\nif __name__ == \"__main__\":\n main()\n"}}, ".env.example": {"590305e0f85cf7adb5b88d9b6f848c41c1aa2b45": {"t": 1785747124.3260195, "n": 7375, "h": "b193c66e36ac9135", "c": "# OSINT console environment. Copy to .env and fill in what you have:\n# cp .env.example .env\n# EVERY key is optional — the platform boots and shows aircraft, vessels,\n# quakes, and the dark basemap with this file completely empty (CLAUDE.md\n# \"layers that must always work without any API key\").\n\n# ── Visual / runtime config exposed to the browser ──────────────────────────\n# Cesium ion token (free Community tier) — only needed for OSM 3D Buildings.\nCESIUM_ION_TOKEN=\n# Google Photorealistic 3D Tiles feature flag (needs ion token; off = free).\nENABLE_GOOGLE_3D=false\nCLASSIFICATION=UNCLAS\nBUILD_ID=dev\n\n# ── Upstream keys (all optional; layers degrade gracefully when unset) ──────\n# OpenSky OAuth2 client-credentials. UNSET still works — the breadth tier\n# falls back to anonymous /states/all (~400 credits/day per source IP).\n# Creds raise the daily budget and unlock the cred-gated correlate ingest.\nOPENSKY_CLIENT_ID=\nOPENSKY_CLIENT_SECRET=\n# Keyless full-feed ADS-B — open global readsb/tar1090 instances that serve\n# their FULL aircraft.json with no key (the \"tar1090 way\"). Unioned with OpenSky\n# (measured ~14k vs ~12.7k OpenSky-only). Pulled ONE feed per cycle, round-robin,\n# every ADSB_FEED_INTERVAL_S so no single host is rate-limited. Add your own\n# ultrafeeder/tar1090 URL here to fold in its coverage. To pull a Cloudflare-\n# gated aggregator (airplanes.live, ~14.6k) run tools/adsb-globe-feeder and add\n# http://127.0.0.1:8090/aircraft.json to this list.\nADSB_FEED_URLS=https://globe.theairtraffic.com/data/aircraft.json,https://skylink.hpradar.com/data/aircraft.json,https://api.adsb.lol/v2/point/0/0/20000\nADSB_FEED_INTERVAL_S=30\n# Keyless AIS (no key, Northern Europe). Always-on Kystverket NMEA firehose +\n# Norway Kystdatahuset (REST poll) + Finland Digitraffic (live MQTT). There is\n# NO keyless GLOBAL AIS; worldwide vessels need AISStream (below), engaged\n# on-demand only. Toggle the regional sources off here if undesired.\nAIS_FIREHOSE_ENABLED=true\nAIS_KYSTDATAHUSET_ENABLED=true\nAIS_KYSTDATAHUSET_INTERVAL_S=60\nAIS_DIGITRAFFIC_MQTT_ENABLED=true\n# AISStream.io key — the only GLOBAL live vessel layer. Engaged on demand (when\n# a browser opens /ws/ais) and dropped when the last viewer leaves, to conserve\n# its message cap. Keyless regional feeds above work without it.\nAISSTREAM_KEY=\n# NASA FIRMS MAP_KEY — active-fire layer.\nFIRMS_MAP_KEY=\n# Global Fishing Watch token — vessel identity enrichment / dark vessels.\nGFW_TOKEN=\n# ACLED conflict events (key + registered email, both required together).\nACLED_KEY=\nACLED_EMAIL=\n# Cloudflare Radar token — internet-outage annotations.\nCLOUDFLARE_TOKEN=\n# Copernicus Data Space (Sentinel) — reserved for imagery tasking.\nCDSE_CLIENT_ID=\nCDSE_CLIENT_SECRET=\nGMAPS_KEY=\nOPENAIP_KEY=\n\n# ── Server ───────────────────────────────────────────────────────────────────\nAPI_HOST=0.0.0.0\nAPI_PORT=8000\nLOG_LEVEL=info\nCORS_ORIGINS=http://localhost:8080\n# When set, every REST + WS route requires X-API-Key (or ?key=) to match.\n# The web bundle must then be built with VITE_API_KEY set to the same value.\nAPI_KEY=\n# When NO credential is configured (API_KEY + Supabase all blank) the keyless\n# DATA layers stay open, but the COST/COMPUTE endpoints (LLM analysis, recon,\n# OSINT, imagery-detect) FAIL CLOSED (503) — they spend money/GPU. Set this to 1\n# to serve them keyless on a TRUSTED local/dev box. Ignored once auth is on.\nALLOW_UNAUTHENTICATED=\n# Per-client-IP rate limit (requests/min) on those cost/compute endpoints.\n# 0 disables the limiter. Also caps concurrently-running recon jobs.\nRATELIMIT_COMPUTE_PER_MIN=60\nRECON_MAX_ACTIVE_JOBS=4\n# Recon job retention: evict finished jobs (memory record + on-disk artifacts)\n# oldest-first past this count / age, so a long-running box does not leak disk.\nRECON_MAX_JOBS=40\nRECON_JOB_TTL_S=86400\n# Disk tile cache root — safe to delete anytime, refills on demand.\nTILE_CACHE_DIR=./data/tilecache\n\n# ── Outbound proxy pool (optional; empty = off) ──────────────────────────────\n# Comma-separated proxies the shared upstream client rotates over. Useful\n# because several upstreams gate on the CALLER'S IP rather than a key:\n# airplanes.live 403s a datacenter address, adsb.lol rate-limits per source IP,\n# and OpenSky's anonymous credit budget is per IP.\n# UPSTREAM_PROXIES=http://user:pass@host:8080,socks5://host:1080\n# Point this ONLY at egress you own or pay for. There is deliberately no\n# discovery and no bundled list: public \"free proxy\" pools are largely consumer\n# machines enrolled by malware whose owners never agreed to carry traffic, plus\n# interception nodes that would read every query this console makes.\n# An explicit pool here outranks HTTPS_PROXY; NO_PROXY exclusions still apply,\n# and the localhost sidecars (:8090, :8093) always bypass the pool.\nUPSTREAM_PROXIES=\n# A proxy that errors is skipped for this long before being retried.\nUPSTREAM_PROXY_COOLDOWN_S=300\n# How many pool members one request may try before giving up.\nUPSTREAM_PROXY_MAX_TRIES=3\n# When every proxy is cooling down: 0 (default) fails the request, 1 sends it\n# directly. Default 0 on purpose — configuring a proxy usually means \"not from\n# my own address\", and a silent direct fallback would leak exactly that.\nUPSTREAM_PROXY_FALLBACK_DIRECT=\n\n# ── MCP server + local AI (Ollama) ───────────────────────────────────────────\nAPI_BASE=http://localhost:8000\nOLLAMA_HOST=http://localhost:11434\n# \"\" → auto-detect the smallest installed model.\nOLLAMA_MODEL=\n\n# ── DeepSeek (OpenAI-compatible) — primary reasoning backend ─────────────────\n# Used by deep_analyze + the news debias/fact-check engine; falls back to\n# Ollama, then to raw structured data, when unset/unreachable.\nDEEPSEEK_API_KEY=\nDEEPSEEK_BASE_URL=\n# OFF by default: only when set to 1 will the server, with DEEPSEEK_API_KEY\n# blank, resolve the key + base URL from the opencode dev-tool config\n# (~/.config/opencode/opencode.jsonc → provider.deepseek.options). Leave off on\n# any shared/multi-tenant host — it would spend whatever key sits in that home\n# dir. The selected credential source is logged once at first use.\nDEEPSEEK_FROM_OPENCODE=\n# Model ids per task tier (fast=extraction/classification, reason=judgement).\nDEEPSEEK_MODEL_FAST=deepseek-chat\nDEEPSEEK_MODEL_REASON=deepseek-reasoner\n\n# ── News debias / fact-check engine ──────────────────────────────────────────\nNEWS_ENABLED=true\nNEWS_REFRESH_SEC=600\nNEWS_MAX_ITEMS=400\n\n# ── Historical playback (3D replay/scrub) ────────────────────────────────────\nHISTORY_ENABLED=true\nHISTORY_DB_PATH=./data/history.db\nHISTORY_RETENTION_HOURS=48\n\n# ── Supabase auth (web bundle) ───────────────────────────────────────────────\n# Powers /login + /signup. Vite inlines VITE_* into the bundle at BUILD time,\n# so the deploy (e.g. projectvelocity.org) must be built with both set. Auth\n# degrades to disabled if absent — the globe and keyless layers still boot.\n# Put these in apps/web/.env for local dev (that is Vite's env dir). The\n# publishable key (sb_publishable_…) is browser-safe; NEVER use the\n# service_role/secret key in the web bundle.\nVITE_SUPABASE_URL=\nVITE_SUPABASE_ANON_KEY=\n\n# MiniMax-M3 (reasoning) via NVIDIA NIM — primary LLM backend (app.llm).\n# Get an nvapi-… bearer at build.nvidia.com. MINIMAX_API_KEY is an accepted alias.\nNVIDIA_API_KEY=\n# MINIMAX_BASE_URL=https://integrate.api.nvidia.com/v1\n# MINIMAX_MODEL=minimaxai/minimax-m3\n"}, "1d4bdd1f4f6eac9e784281a5eac8a4c197bb8977": {"t": 1785765041.8538945, "n": 7375, "h": "b193c66e36ac9135", "c": "# OSINT console environment. Copy to .env and fill in what you have:\n# cp .env.example .env\n# EVERY key is optional — the platform boots and shows aircraft, vessels,\n# quakes, and the dark basemap with this file completely empty (CLAUDE.md\n# \"layers that must always work without any API key\").\n\n# ── Visual / runtime config exposed to the browser ──────────────────────────\n# Cesium ion token (free Community tier) — only needed for OSM 3D Buildings.\nCESIUM_ION_TOKEN=\n# Google Photorealistic 3D Tiles feature flag (needs ion token; off = free).\nENABLE_GOOGLE_3D=false\nCLASSIFICATION=UNCLAS\nBUILD_ID=dev\n\n# ── Upstream keys (all optional; layers degrade gracefully when unset) ──────\n# OpenSky OAuth2 client-credentials. UNSET still works — the breadth tier\n# falls back to anonymous /states/all (~400 credits/day per source IP).\n# Creds raise the daily budget and unlock the cred-gated correlate ingest.\nOPENSKY_CLIENT_ID=\nOPENSKY_CLIENT_SECRET=\n# Keyless full-feed ADS-B — open global readsb/tar1090 instances that serve\n# their FULL aircraft.json with no key (the \"tar1090 way\"). Unioned with OpenSky\n# (measured ~14k vs ~12.7k OpenSky-only). Pulled ONE feed per cycle, round-robin,\n# every ADSB_FEED_INTERVAL_S so no single host is rate-limited. Add your own\n# ultrafeeder/tar1090 URL here to fold in its coverage. To pull a Cloudflare-\n# gated aggregator (airplanes.live, ~14.6k) run tools/adsb-globe-feeder and add\n# http://127.0.0.1:8090/aircraft.json to this list.\nADSB_FEED_URLS=https://globe.theairtraffic.com/data/aircraft.json,https://skylink.hpradar.com/data/aircraft.json,https://api.adsb.lol/v2/point/0/0/20000\nADSB_FEED_INTERVAL_S=30\n# Keyless AIS (no key, Northern Europe). Always-on Kystverket NMEA firehose +\n# Norway Kystdatahuset (REST poll) + Finland Digitraffic (live MQTT). There is\n# NO keyless GLOBAL AIS; worldwide vessels need AISStream (below), engaged\n# on-demand only. Toggle the regional sources off here if undesired.\nAIS_FIREHOSE_ENABLED=true\nAIS_KYSTDATAHUSET_ENABLED=true\nAIS_KYSTDATAHUSET_INTERVAL_S=60\nAIS_DIGITRAFFIC_MQTT_ENABLED=true\n# AISStream.io key — the only GLOBAL live vessel layer. Engaged on demand (when\n# a browser opens /ws/ais) and dropped when the last viewer leaves, to conserve\n# its message cap. Keyless regional feeds above work without it.\nAISSTREAM_KEY=\n# NASA FIRMS MAP_KEY — active-fire layer.\nFIRMS_MAP_KEY=\n# Global Fishing Watch token — vessel identity enrichment / dark vessels.\nGFW_TOKEN=\n# ACLED conflict events (key + registered email, both required together).\nACLED_KEY=\nACLED_EMAIL=\n# Cloudflare Radar token — internet-outage annotations.\nCLOUDFLARE_TOKEN=\n# Copernicus Data Space (Sentinel) — reserved for imagery tasking.\nCDSE_CLIENT_ID=\nCDSE_CLIENT_SECRET=\nGMAPS_KEY=\nOPENAIP_KEY=\n\n# ── Server ───────────────────────────────────────────────────────────────────\nAPI_HOST=0.0.0.0\nAPI_PORT=8000\nLOG_LEVEL=info\nCORS_ORIGINS=http://localhost:8080\n# When set, every REST + WS route requires X-API-Key (or ?key=) to match.\n# The web bundle must then be built with VITE_API_KEY set to the same value.\nAPI_KEY=\n# When NO credential is configured (API_KEY + Supabase all blank) the keyless\n# DATA layers stay open, but the COST/COMPUTE endpoints (LLM analysis, recon,\n# OSINT, imagery-detect) FAIL CLOSED (503) — they spend money/GPU. Set this to 1\n# to serve them keyless on a TRUSTED local/dev box. Ignored once auth is on.\nALLOW_UNAUTHENTICATED=\n# Per-client-IP rate limit (requests/min) on those cost/compute endpoints.\n# 0 disables the limiter. Also caps concurrently-running recon jobs.\nRATELIMIT_COMPUTE_PER_MIN=60\nRECON_MAX_ACTIVE_JOBS=4\n# Recon job retention: evict finished jobs (memory record + on-disk artifacts)\n# oldest-first past this count / age, so a long-running box does not leak disk.\nRECON_MAX_JOBS=40\nRECON_JOB_TTL_S=86400\n# Disk tile cache root — safe to delete anytime, refills on demand.\nTILE_CACHE_DIR=./data/tilecache\n\n# ── Outbound proxy pool (optional; empty = off) ──────────────────────────────\n# Comma-separated proxies the shared upstream client rotates over. Useful\n# because several upstreams gate on the CALLER'S IP rather than a key:\n# airplanes.live 403s a datacenter address, adsb.lol rate-limits per source IP,\n# and OpenSky's anonymous credit budget is per IP.\n# UPSTREAM_PROXIES=http://user:pass@host:8080,socks5://host:1080\n# Point this ONLY at egress you own or pay for. There is deliberately no\n# discovery and no bundled list: public \"free proxy\" pools are largely consumer\n# machines enrolled by malware whose owners never agreed to carry traffic, plus\n# interception nodes that would read every query this console makes.\n# An explicit pool here outranks HTTPS_PROXY; NO_PROXY exclusions still apply,\n# and the localhost sidecars (:8090, :8093) always bypass the pool.\nUPSTREAM_PROXIES=\n# A proxy that errors is skipped for this long before being retried.\nUPSTREAM_PROXY_COOLDOWN_S=300\n# How many pool members one request may try before giving up.\nUPSTREAM_PROXY_MAX_TRIES=3\n# When every proxy is cooling down: 0 (default) fails the request, 1 sends it\n# directly. Default 0 on purpose — configuring a proxy usually means \"not from\n# my own address\", and a silent direct fallback would leak exactly that.\nUPSTREAM_PROXY_FALLBACK_DIRECT=\n\n# ── MCP server + local AI (Ollama) ───────────────────────────────────────────\nAPI_BASE=http://localhost:8000\nOLLAMA_HOST=http://localhost:11434\n# \"\" → auto-detect the smallest installed model.\nOLLAMA_MODEL=\n\n# ── DeepSeek (OpenAI-compatible) — primary reasoning backend ─────────────────\n# Used by deep_analyze + the news debias/fact-check engine; falls back to\n# Ollama, then to raw structured data, when unset/unreachable.\nDEEPSEEK_API_KEY=\nDEEPSEEK_BASE_URL=\n# OFF by default: only when set to 1 will the server, with DEEPSEEK_API_KEY\n# blank, resolve the key + base URL from the opencode dev-tool config\n# (~/.config/opencode/opencode.jsonc → provider.deepseek.options). Leave off on\n# any shared/multi-tenant host — it would spend whatever key sits in that home\n# dir. The selected credential source is logged once at first use.\nDEEPSEEK_FROM_OPENCODE=\n# Model ids per task tier (fast=extraction/classification, reason=judgement).\nDEEPSEEK_MODEL_FAST=deepseek-chat\nDEEPSEEK_MODEL_REASON=deepseek-reasoner\n\n# ── News debias / fact-check engine ──────────────────────────────────────────\nNEWS_ENABLED=true\nNEWS_REFRESH_SEC=600\nNEWS_MAX_ITEMS=400\n\n# ── Historical playback (3D replay/scrub) ────────────────────────────────────\nHISTORY_ENABLED=true\nHISTORY_DB_PATH=./data/history.db\nHISTORY_RETENTION_HOURS=48\n\n# ── Supabase auth (web bundle) ───────────────────────────────────────────────\n# Powers /login + /signup. Vite inlines VITE_* into the bundle at BUILD time,\n# so the deploy (e.g. projectvelocity.org) must be built with both set. Auth\n# degrades to disabled if absent — the globe and keyless layers still boot.\n# Put these in apps/web/.env for local dev (that is Vite's env dir). The\n# publishable key (sb_publishable_…) is browser-safe; NEVER use the\n# service_role/secret key in the web bundle.\nVITE_SUPABASE_URL=\nVITE_SUPABASE_ANON_KEY=\n\n# MiniMax-M3 (reasoning) via NVIDIA NIM — primary LLM backend (app.llm).\n# Get an nvapi-… bearer at build.nvidia.com. MINIMAX_API_KEY is an accepted alias.\nNVIDIA_API_KEY=\n# MINIMAX_BASE_URL=https://integrate.api.nvidia.com/v1\n# MINIMAX_MODEL=minimaxai/minimax-m3\n"}, "9abef5c99cac84e8a247f65fedd26925bc144e70": {"t": 1785765043.0562544, "n": 6034, "h": "1cec4252afe3ed51", "c": "# OSINT console environment. Copy to .env and fill in what you have:\n# cp .env.example .env\n# EVERY key is optional — the platform boots and shows aircraft, vessels,\n# quakes, and the dark basemap with this file completely empty (CLAUDE.md\n# \"layers that must always work without any API key\").\n\n# ── Visual / runtime config exposed to the browser ──────────────────────────\n# Cesium ion token (free Community tier) — only needed for OSM 3D Buildings.\nCESIUM_ION_TOKEN=\n# Google Photorealistic 3D Tiles feature flag (needs ion token; off = free).\nENABLE_GOOGLE_3D=false\nCLASSIFICATION=UNCLAS\nBUILD_ID=dev\n\n# ── Upstream keys (all optional; layers degrade gracefully when unset) ──────\n# OpenSky OAuth2 client-credentials. UNSET still works — the breadth tier\n# falls back to anonymous /states/all (~400 credits/day per source IP).\n# Creds raise the daily budget and unlock the cred-gated correlate ingest.\nOPENSKY_CLIENT_ID=\nOPENSKY_CLIENT_SECRET=\n# Keyless full-feed ADS-B — open global readsb/tar1090 instances that serve\n# their FULL aircraft.json with no key (the \"tar1090 way\"). Unioned with OpenSky\n# (measured ~14k vs ~12.7k OpenSky-only). Pulled ONE feed per cycle, round-robin,\n# every ADSB_FEED_INTERVAL_S so no single host is rate-limited. Add your own\n# ultrafeeder/tar1090 URL here to fold in its coverage. To pull a Cloudflare-\n# gated aggregator (airplanes.live, ~14.6k) run tools/adsb-globe-feeder and add\n# http://127.0.0.1:8090/aircraft.json to this list.\nADSB_FEED_URLS=https://globe.theairtraffic.com/data/aircraft.json,https://skylink.hpradar.com/data/aircraft.json,https://api.adsb.lol/v2/point/0/0/20000\nADSB_FEED_INTERVAL_S=30\n# Keyless AIS (no key, Northern Europe). Always-on Kystverket NMEA firehose +\n# Norway Kystdatahuset (REST poll) + Finland Digitraffic (live MQTT). There is\n# NO keyless GLOBAL AIS; worldwide vessels need AISStream (below), engaged\n# on-demand only. Toggle the regional sources off here if undesired.\nAIS_FIREHOSE_ENABLED=true\nAIS_KYSTDATAHUSET_ENABLED=true\nAIS_KYSTDATAHUSET_INTERVAL_S=60\nAIS_DIGITRAFFIC_MQTT_ENABLED=true\n# AISStream.io key — the only GLOBAL live vessel layer. Engaged on demand (when\n# a browser opens /ws/ais) and dropped when the last viewer leaves, to conserve\n# its message cap. Keyless regional feeds above work without it.\nAISSTREAM_KEY=\n# NASA FIRMS MAP_KEY — active-fire layer.\nFIRMS_MAP_KEY=\n# Global Fishing Watch token — vessel identity enrichment / dark vessels.\nGFW_TOKEN=\n# ACLED conflict events (key + registered email, both required together).\nACLED_KEY=\nACLED_EMAIL=\n# Cloudflare Radar token — internet-outage annotations.\nCLOUDFLARE_TOKEN=\n# Copernicus Data Space (Sentinel) — reserved for imagery tasking.\nCDSE_CLIENT_ID=\nCDSE_CLIENT_SECRET=\nGMAPS_KEY=\nOPENAIP_KEY=\n\n# ── Server ───────────────────────────────────────────────────────────────────\nAPI_HOST=0.0.0.0\nAPI_PORT=8000\nLOG_LEVEL=info\nCORS_ORIGINS=http://localhost:8080\n# When set, every REST + WS route requires X-API-Key (or ?key=) to match.\n# The web bundle must then be built with VITE_API_KEY set to the same value.\nAPI_KEY=\n# When NO credential is configured (API_KEY + Supabase all blank) the keyless\n# DATA layers stay open, but the COST/COMPUTE endpoints (LLM analysis, recon,\n# OSINT, imagery-detect) FAIL CLOSED (503) — they spend money/GPU. Set this to 1\n# to serve them keyless on a TRUSTED local/dev box. Ignored once auth is on.\nALLOW_UNAUTHENTICATED=\n# Per-client-IP rate limit (requests/min) on those cost/compute endpoints.\n# 0 disables the limiter. Also caps concurrently-running recon jobs.\nRATELIMIT_COMPUTE_PER_MIN=60\nRECON_MAX_ACTIVE_JOBS=4\n# Recon job retention: evict finished jobs (memory record + on-disk artifacts)\n# oldest-first past this count / age, so a long-running box does not leak disk.\nRECON_MAX_JOBS=40\nRECON_JOB_TTL_S=86400\n# Disk tile cache root — safe to delete anytime, refills on demand.\nTILE_CACHE_DIR=./data/tilecache\n\n# ── MCP server + local AI (Ollama) ───────────────────────────────────────────\nAPI_BASE=http://localhost:8000\nOLLAMA_HOST=http://localhost:11434\n# \"\" → auto-detect the smallest installed model.\nOLLAMA_MODEL=\n\n# ── DeepSeek (OpenAI-compatible) — primary reasoning backend ─────────────────\n# Used by deep_analyze + the news debias/fact-check engine; falls back to\n# Ollama, then to raw structured data, when unset/unreachable.\nDEEPSEEK_API_KEY=\nDEEPSEEK_BASE_URL=\n# OFF by default: only when set to 1 will the server, with DEEPSEEK_API_KEY\n# blank, resolve the key + base URL from the opencode dev-tool config\n# (~/.config/opencode/opencode.jsonc → provider.deepseek.options). Leave off on\n# any shared/multi-tenant host — it would spend whatever key sits in that home\n# dir. The selected credential source is logged once at first use.\nDEEPSEEK_FROM_OPENCODE=\n# Model ids per task tier (fast=extraction/classification, reason=judgement).\nDEEPSEEK_MODEL_FAST=deepseek-chat\nDEEPSEEK_MODEL_REASON=deepseek-reasoner\n\n# ── News debias / fact-check engine ──────────────────────────────────────────\nNEWS_ENABLED=true\nNEWS_REFRESH_SEC=600\nNEWS_MAX_ITEMS=400\n\n# ── Historical playback (3D replay/scrub) ────────────────────────────────────\nHISTORY_ENABLED=true\nHISTORY_DB_PATH=./data/history.db\nHISTORY_RETENTION_HOURS=48\n\n# ── Supabase auth (web bundle) ───────────────────────────────────────────────\n# Powers /login + /signup. Vite inlines VITE_* into the bundle at BUILD time,\n# so the deploy (e.g. projectvelocity.org) must be built with both set. Auth\n# degrades to disabled if absent — the globe and keyless layers still boot.\n# Put these in apps/web/.env for local dev (that is Vite's env dir). The\n# publishable key (sb_publishable_…) is browser-safe; NEVER use the\n# service_role/secret key in the web bundle.\nVITE_SUPABASE_URL=\nVITE_SUPABASE_ANON_KEY=\n\n# MiniMax-M3 (reasoning) via NVIDIA NIM — primary LLM backend (app.llm).\n# Get an nvapi-… bearer at build.nvidia.com. MINIMAX_API_KEY is an accepted alias.\nNVIDIA_API_KEY=\n# MINIMAX_BASE_URL=https://integrate.api.nvidia.com/v1\n# MINIMAX_MODEL=minimaxai/minimax-m3\n"}, "76e5d77f854bb6c2a761ed48dee49e0baba77fba": {"t": 1785765044.7099688, "n": 5047, "h": "21596dda277849ef", "c": "# OSINT console environment. Copy to .env and fill in what you have:\n# cp .env.example .env\n# EVERY key is optional — the platform boots and shows aircraft, vessels,\n# quakes, and the dark basemap with this file completely empty (CLAUDE.md\n# \"layers that must always work without any API key\").\n\n# ── Visual / runtime config exposed to the browser ──────────────────────────\n# Cesium ion token (free Community tier) — only needed for OSM 3D Buildings.\nCESIUM_ION_TOKEN=\n# Google Photorealistic 3D Tiles feature flag (needs ion token; off = free).\nENABLE_GOOGLE_3D=false\nCLASSIFICATION=UNCLAS\nBUILD_ID=dev\n\n# ── Upstream keys (all optional; layers degrade gracefully when unset) ──────\n# OpenSky OAuth2 client-credentials. UNSET still works — the breadth tier\n# falls back to anonymous /states/all (~400 credits/day per source IP).\n# Creds raise the daily budget and unlock the cred-gated correlate ingest.\nOPENSKY_CLIENT_ID=\nOPENSKY_CLIENT_SECRET=\n# Keyless full-feed ADS-B — open global readsb/tar1090 instances that serve\n# their FULL aircraft.json with no key (the \"tar1090 way\"). Unioned with OpenSky\n# (measured ~14k vs ~12.7k OpenSky-only). Pulled ONE feed per cycle, round-robin,\n# every ADSB_FEED_INTERVAL_S so no single host is rate-limited. Add your own\n# ultrafeeder/tar1090 URL here to fold in its coverage. To pull a Cloudflare-\n# gated aggregator (airplanes.live, ~14.6k) run tools/adsb-globe-feeder and add\n# http://127.0.0.1:8090/aircraft.json to this list.\nADSB_FEED_URLS=https://globe.theairtraffic.com/data/aircraft.json,https://skylink.hpradar.com/data/aircraft.json,https://api.adsb.lol/v2/point/0/0/20000\nADSB_FEED_INTERVAL_S=30\n# Keyless AIS (no key, Northern Europe). Always-on Kystverket NMEA firehose +\n# Norway Kystdatahuset (REST poll) + Finland Digitraffic (live MQTT). There is\n# NO keyless GLOBAL AIS; worldwide vessels need AISStream (below), engaged\n# on-demand only. Toggle the regional sources off here if undesired.\nAIS_FIREHOSE_ENABLED=true\nAIS_KYSTDATAHUSET_ENABLED=true\nAIS_KYSTDATAHUSET_INTERVAL_S=60\nAIS_DIGITRAFFIC_MQTT_ENABLED=true\n# AISStream.io key — the only GLOBAL live vessel layer. Engaged on demand (when\n# a browser opens /ws/ais) and dropped when the last viewer leaves, to conserve\n# its message cap. Keyless regional feeds above work without it.\nAISSTREAM_KEY=\n# NASA FIRMS MAP_KEY — active-fire layer.\nFIRMS_MAP_KEY=\n# Global Fishing Watch token — vessel identity enrichment / dark vessels.\nGFW_TOKEN=\n# ACLED conflict events (key + registered email, both required together).\nACLED_KEY=\nACLED_EMAIL=\n# Cloudflare Radar token — internet-outage annotations.\nCLOUDFLARE_TOKEN=\n# Copernicus Data Space (Sentinel) — reserved for imagery tasking.\nCDSE_CLIENT_ID=\nCDSE_CLIENT_SECRET=\nGMAPS_KEY=\nOPENAIP_KEY=\n\n# ── Server ───────────────────────────────────────────────────────────────────\nAPI_HOST=0.0.0.0\nAPI_PORT=8000\nLOG_LEVEL=info\nCORS_ORIGINS=http://localhost:8080\n# When set, every REST + WS route requires X-API-Key (or ?key=) to match.\n# The web bundle must then be built with VITE_API_KEY set to the same value.\nAPI_KEY=\n# Disk tile cache root — safe to delete anytime, refills on demand.\nTILE_CACHE_DIR=./data/tilecache\n\n# ── MCP server + local AI (Ollama) ───────────────────────────────────────────\nAPI_BASE=http://localhost:8000\nOLLAMA_HOST=http://localhost:11434\n# \"\" → auto-detect the smallest installed model.\nOLLAMA_MODEL=\n\n# ── DeepSeek (OpenAI-compatible) — primary reasoning backend ─────────────────\n# Used by deep_analyze + the news debias/fact-check engine; falls back to\n# Ollama, then to raw structured data, when unset/unreachable. When blank,\n# app.llm reads the key + base from the user's opencode config\n# (~/.config/opencode/opencode.jsonc → provider.deepseek.options).\nDEEPSEEK_API_KEY=\nDEEPSEEK_BASE_URL=\n# Model ids per task tier (fast=extraction/classification, reason=judgement).\nDEEPSEEK_MODEL_FAST=deepseek-chat\nDEEPSEEK_MODEL_REASON=deepseek-reasoner\n\n# ── News debias / fact-check engine ──────────────────────────────────────────\nNEWS_ENABLED=true\nNEWS_REFRESH_SEC=600\nNEWS_MAX_ITEMS=400\n\n# ── Historical playback (3D replay/scrub) ────────────────────────────────────\nHISTORY_ENABLED=true\nHISTORY_DB_PATH=./data/history.db\nHISTORY_RETENTION_HOURS=48\n\n# ── Supabase auth (web bundle) ───────────────────────────────────────────────\n# Powers /login + /signup. Vite inlines VITE_* into the bundle at BUILD time,\n# so the deploy (e.g. projectvelocity.org) must be built with both set. Auth\n# degrades to disabled if absent — the globe and keyless layers still boot.\n# Put these in apps/web/.env for local dev (that is Vite's env dir). The\n# publishable key (sb_publishable_…) is browser-safe; NEVER use the\n# service_role/secret key in the web bundle.\nVITE_SUPABASE_URL=\nVITE_SUPABASE_ANON_KEY=\n\n# MiniMax-M3 (reasoning) via NVIDIA NIM — primary LLM backend (app.llm).\n# Get an nvapi-… bearer at build.nvidia.com. MINIMAX_API_KEY is an accepted alias.\nNVIDIA_API_KEY=\n# MINIMAX_BASE_URL=https://integrate.api.nvidia.com/v1\n# MINIMAX_MODEL=minimaxai/minimax-m3\n"}, "8b8f1b2f420c840dcc3440c4b824d031eef130f3": {"t": 1785765045.89905, "n": 4241, "h": "3ef8477226ca04ed", "c": "# OSINT console environment. Copy to .env and fill in what you have:\n# cp .env.example .env\n# EVERY key is optional — the platform boots and shows aircraft, vessels,\n# quakes, and the dark basemap with this file completely empty (CLAUDE.md\n# \"layers that must always work without any API key\").\n\n# ── Visual / runtime config exposed to the browser ──────────────────────────\n# Cesium ion token (free Community tier) — only needed for OSM 3D Buildings.\nCESIUM_ION_TOKEN=\n# Google Photorealistic 3D Tiles feature flag (needs ion token; off = free).\nENABLE_GOOGLE_3D=false\nCLASSIFICATION=UNCLAS\nBUILD_ID=dev\n\n# ── Upstream keys (all optional; layers degrade gracefully when unset) ──────\n# OpenSky OAuth2 client-credentials. UNSET still works — the breadth tier\n# falls back to anonymous /states/all (~400 credits/day per source IP).\n# Creds raise the daily budget and unlock the cred-gated correlate ingest.\nOPENSKY_CLIENT_ID=\nOPENSKY_CLIENT_SECRET=\n# Keyless full-feed ADS-B — open global readsb/tar1090 instances that serve\n# their FULL aircraft.json with no key (the \"tar1090 way\"). Unioned with OpenSky\n# (measured ~14k vs ~12.7k OpenSky-only). Pulled ONE feed per cycle, round-robin,\n# every ADSB_FEED_INTERVAL_S so no single host is rate-limited. Add your own\n# ultrafeeder/tar1090 URL here to fold in its coverage. To pull a Cloudflare-\n# gated aggregator (airplanes.live, ~14.6k) run tools/adsb-globe-feeder and add\n# http://127.0.0.1:8090/aircraft.json to this list.\nADSB_FEED_URLS=https://globe.theairtraffic.com/data/aircraft.json,https://skylink.hpradar.com/data/aircraft.json,https://api.adsb.lol/v2/point/0/0/20000\nADSB_FEED_INTERVAL_S=30\n# Keyless AIS (no key, Northern Europe). Always-on Kystverket NMEA firehose +\n# Norway Kystdatahuset (REST poll) + Finland Digitraffic (live MQTT). There is\n# NO keyless GLOBAL AIS; worldwide vessels need AISStream (below), engaged\n# on-demand only. Toggle the regional sources off here if undesired.\nAIS_FIREHOSE_ENABLED=true\nAIS_KYSTDATAHUSET_ENABLED=true\nAIS_KYSTDATAHUSET_INTERVAL_S=60\nAIS_DIGITRAFFIC_MQTT_ENABLED=true\n# AISStream.io key — the only GLOBAL live vessel layer. Engaged on demand (when\n# a browser opens /ws/ais) and dropped when the last viewer leaves, to conserve\n# its message cap. Keyless regional feeds above work without it.\nAISSTREAM_KEY=\n# NASA FIRMS MAP_KEY — active-fire layer.\nFIRMS_MAP_KEY=\n# Global Fishing Watch token — vessel identity enrichment / dark vessels.\nGFW_TOKEN=\n# ACLED conflict events (key + registered email, both required together).\nACLED_KEY=\nACLED_EMAIL=\n# Cloudflare Radar token — internet-outage annotations.\nCLOUDFLARE_TOKEN=\n# Copernicus Data Space (Sentinel) — reserved for imagery tasking.\nCDSE_CLIENT_ID=\nCDSE_CLIENT_SECRET=\nGMAPS_KEY=\nOPENAIP_KEY=\n\n# ── Server ───────────────────────────────────────────────────────────────────\nAPI_HOST=0.0.0.0\nAPI_PORT=8000\nLOG_LEVEL=info\nCORS_ORIGINS=http://localhost:8080\n# When set, every REST + WS route requires X-API-Key (or ?key=) to match.\n# The web bundle must then be built with VITE_API_KEY set to the same value.\nAPI_KEY=\n# Disk tile cache root — safe to delete anytime, refills on demand.\nTILE_CACHE_DIR=./data/tilecache\n\n# ── MCP server + local AI (Ollama) ───────────────────────────────────────────\nAPI_BASE=http://localhost:8000\nOLLAMA_HOST=http://localhost:11434\n# \"\" → auto-detect the smallest installed model.\nOLLAMA_MODEL=\n\n# ── DeepSeek (OpenAI-compatible) — primary reasoning backend ─────────────────\n# Used by deep_analyze + the news debias/fact-check engine; falls back to\n# Ollama, then to raw structured data, when unset/unreachable. When blank,\n# app.llm reads the key + base from the user's opencode config\n# (~/.config/opencode/opencode.jsonc → provider.deepseek.options).\nDEEPSEEK_API_KEY=\nDEEPSEEK_BASE_URL=\n# Model ids per task tier (fast=extraction/classification, reason=judgement).\nDEEPSEEK_MODEL_FAST=deepseek-chat\nDEEPSEEK_MODEL_REASON=deepseek-reasoner\n\n# ── News debias / fact-check engine ──────────────────────────────────────────\nNEWS_ENABLED=true\nNEWS_REFRESH_SEC=600\nNEWS_MAX_ITEMS=400\n\n# ── Historical playback (3D replay/scrub) ────────────────────────────────────\nHISTORY_ENABLED=true\nHISTORY_DB_PATH=./data/history.db\nHISTORY_RETENTION_HOURS=48\n"}, "3d7c8d30eb2c6fda831ec4b177acf148cf4a6d91": {"t": 1785765047.2325892, "n": 3682, "h": "b4d2b847a5eb54d2", "c": "# OSINT console environment. Copy to .env and fill in what you have:\n# cp .env.example .env\n# EVERY key is optional — the platform boots and shows aircraft, vessels,\n# quakes, and the dark basemap with this file completely empty (CLAUDE.md\n# \"layers that must always work without any API key\").\n\n# ── Visual / runtime config exposed to the browser ──────────────────────────\n# Cesium ion token (free Community tier) — only needed for OSM 3D Buildings.\nCESIUM_ION_TOKEN=\n# Google Photorealistic 3D Tiles feature flag (needs ion token; off = free).\nENABLE_GOOGLE_3D=false\nCLASSIFICATION=UNCLAS\nBUILD_ID=dev\n\n# ── Upstream keys (all optional; layers degrade gracefully when unset) ──────\n# OpenSky OAuth2 client-credentials. UNSET still works — the breadth tier\n# falls back to anonymous /states/all (~400 credits/day per source IP).\n# Creds raise the daily budget and unlock the cred-gated correlate ingest.\nOPENSKY_CLIENT_ID=\nOPENSKY_CLIENT_SECRET=\n# Keyless full-feed ADS-B — open global readsb/tar1090 instances that serve\n# their FULL aircraft.json with no key (the \"tar1090 way\"). Unioned with OpenSky\n# (measured ~14k vs ~12.7k OpenSky-only). Pulled ONE feed per cycle, round-robin,\n# every ADSB_FEED_INTERVAL_S so no single host is rate-limited. Add your own\n# ultrafeeder/tar1090 URL here to fold in its coverage. To pull a Cloudflare-\n# gated aggregator (airplanes.live, ~14.6k) run tools/adsb-globe-feeder and add\n# http://127.0.0.1:8090/aircraft.json to this list.\nADSB_FEED_URLS=https://globe.theairtraffic.com/data/aircraft.json,https://skylink.hpradar.com/data/aircraft.json,https://api.adsb.lol/v2/point/0/0/20000\nADSB_FEED_INTERVAL_S=30\n# AISStream.io key — global live vessel WS layer (Digitraffic Baltic works\n# without it).\nAISSTREAM_KEY=\n# NASA FIRMS MAP_KEY — active-fire layer.\nFIRMS_MAP_KEY=\n# Global Fishing Watch token — vessel identity enrichment / dark vessels.\nGFW_TOKEN=\n# ACLED conflict events (key + registered email, both required together).\nACLED_KEY=\nACLED_EMAIL=\n# Cloudflare Radar token — internet-outage annotations.\nCLOUDFLARE_TOKEN=\n# Copernicus Data Space (Sentinel) — reserved for imagery tasking.\nCDSE_CLIENT_ID=\nCDSE_CLIENT_SECRET=\nGMAPS_KEY=\nOPENAIP_KEY=\n\n# ── Server ───────────────────────────────────────────────────────────────────\nAPI_HOST=0.0.0.0\nAPI_PORT=8000\nLOG_LEVEL=info\nCORS_ORIGINS=http://localhost:8080\n# When set, every REST + WS route requires X-API-Key (or ?key=) to match.\n# The web bundle must then be built with VITE_API_KEY set to the same value.\nAPI_KEY=\n# Disk tile cache root — safe to delete anytime, refills on demand.\nTILE_CACHE_DIR=./data/tilecache\n\n# ── MCP server + local AI (Ollama) ───────────────────────────────────────────\nAPI_BASE=http://localhost:8000\nOLLAMA_HOST=http://localhost:11434\n# \"\" → auto-detect the smallest installed model.\nOLLAMA_MODEL=\n\n# ── DeepSeek (OpenAI-compatible) — primary reasoning backend ─────────────────\n# Used by deep_analyze + the news debias/fact-check engine; falls back to\n# Ollama, then to raw structured data, when unset/unreachable. When blank,\n# app.llm reads the key + base from the user's opencode config\n# (~/.config/opencode/opencode.jsonc → provider.deepseek.options).\nDEEPSEEK_API_KEY=\nDEEPSEEK_BASE_URL=\n# Model ids per task tier (fast=extraction/classification, reason=judgement).\nDEEPSEEK_MODEL_FAST=deepseek-chat\nDEEPSEEK_MODEL_REASON=deepseek-reasoner\n\n# ── News debias / fact-check engine ──────────────────────────────────────────\nNEWS_ENABLED=true\nNEWS_REFRESH_SEC=600\nNEWS_MAX_ITEMS=400\n\n# ── Historical playback (3D replay/scrub) ────────────────────────────────────\nHISTORY_ENABLED=true\nHISTORY_DB_PATH=./data/history.db\nHISTORY_RETENTION_HOURS=48\n"}, "87e9d55699aca17bd933c260e0bcc9cbbace1aa7": {"t": 1785765048.6130273, "n": 3488, "h": "3b130154fe7378ed", "c": "# OSINT console environment. Copy to .env and fill in what you have:\n# cp .env.example .env\n# EVERY key is optional — the platform boots and shows aircraft, vessels,\n# quakes, and the dark basemap with this file completely empty (CLAUDE.md\n# \"layers that must always work without any API key\").\n\n# ── Visual / runtime config exposed to the browser ──────────────────────────\n# Cesium ion token (free Community tier) — only needed for OSM 3D Buildings.\nCESIUM_ION_TOKEN=\n# Google Photorealistic 3D Tiles feature flag (needs ion token; off = free).\nENABLE_GOOGLE_3D=false\nCLASSIFICATION=UNCLAS\nBUILD_ID=dev\n\n# ── Upstream keys (all optional; layers degrade gracefully when unset) ──────\n# OpenSky OAuth2 client-credentials. UNSET still works — the breadth tier\n# falls back to anonymous /states/all (~400 credits/day per source IP).\n# Creds raise the daily budget and unlock the cred-gated correlate ingest.\nOPENSKY_CLIENT_ID=\nOPENSKY_CLIENT_SECRET=\n# Keyless full-feed ADS-B — open global readsb/tar1090 instances that serve\n# their FULL aircraft.json with no key (the \"tar1090 way\"). Unioned with OpenSky\n# (measured ~14k vs ~12.7k OpenSky-only). Pulled ONE feed per cycle, round-robin,\n# every ADSB_FEED_INTERVAL_S so no single host is rate-limited. Add your own\n# ultrafeeder/tar1090 URL here to fold in its coverage.\nADSB_FEED_URLS=https://globe.theairtraffic.com/data/aircraft.json,https://skylink.hpradar.com/data/aircraft.json\nADSB_FEED_INTERVAL_S=30\n# AISStream.io key — global live vessel WS layer (Digitraffic Baltic works\n# without it).\nAISSTREAM_KEY=\n# NASA FIRMS MAP_KEY — active-fire layer.\nFIRMS_MAP_KEY=\n# Global Fishing Watch token — vessel identity enrichment / dark vessels.\nGFW_TOKEN=\n# ACLED conflict events (key + registered email, both required together).\nACLED_KEY=\nACLED_EMAIL=\n# Cloudflare Radar token — internet-outage annotations.\nCLOUDFLARE_TOKEN=\n# Copernicus Data Space (Sentinel) — reserved for imagery tasking.\nCDSE_CLIENT_ID=\nCDSE_CLIENT_SECRET=\nGMAPS_KEY=\nOPENAIP_KEY=\n\n# ── Server ───────────────────────────────────────────────────────────────────\nAPI_HOST=0.0.0.0\nAPI_PORT=8000\nLOG_LEVEL=info\nCORS_ORIGINS=http://localhost:8080\n# When set, every REST + WS route requires X-API-Key (or ?key=) to match.\n# The web bundle must then be built with VITE_API_KEY set to the same value.\nAPI_KEY=\n# Disk tile cache root — safe to delete anytime, refills on demand.\nTILE_CACHE_DIR=./data/tilecache\n\n# ── MCP server + local AI (Ollama) ───────────────────────────────────────────\nAPI_BASE=http://localhost:8000\nOLLAMA_HOST=http://localhost:11434\n# \"\" → auto-detect the smallest installed model.\nOLLAMA_MODEL=\n\n# ── DeepSeek (OpenAI-compatible) — primary reasoning backend ─────────────────\n# Used by deep_analyze + the news debias/fact-check engine; falls back to\n# Ollama, then to raw structured data, when unset/unreachable. When blank,\n# app.llm reads the key + base from the user's opencode config\n# (~/.config/opencode/opencode.jsonc → provider.deepseek.options).\nDEEPSEEK_API_KEY=\nDEEPSEEK_BASE_URL=\n# Model ids per task tier (fast=extraction/classification, reason=judgement).\nDEEPSEEK_MODEL_FAST=deepseek-chat\nDEEPSEEK_MODEL_REASON=deepseek-reasoner\n\n# ── News debias / fact-check engine ──────────────────────────────────────────\nNEWS_ENABLED=true\nNEWS_REFRESH_SEC=600\nNEWS_MAX_ITEMS=400\n\n# ── Historical playback (3D replay/scrub) ────────────────────────────────────\nHISTORY_ENABLED=true\nHISTORY_DB_PATH=./data/history.db\nHISTORY_RETENTION_HOURS=48\n"}, "d4516159359c26101dd1161a76fb99869747df08": {"t": 1785765050.068177, "n": 2979, "h": "f6cf73df32af272f", "c": "# OSINT console environment. Copy to .env and fill in what you have:\n# cp .env.example .env\n# EVERY key is optional — the platform boots and shows aircraft, vessels,\n# quakes, and the dark basemap with this file completely empty (CLAUDE.md\n# \"layers that must always work without any API key\").\n\n# ── Visual / runtime config exposed to the browser ──────────────────────────\n# Cesium ion token (free Community tier) — only needed for OSM 3D Buildings.\nCESIUM_ION_TOKEN=\n# Google Photorealistic 3D Tiles feature flag (needs ion token; off = free).\nENABLE_GOOGLE_3D=false\nCLASSIFICATION=UNCLAS\nBUILD_ID=dev\n\n# ── Upstream keys (all optional; layers degrade gracefully when unset) ──────\n# OpenSky OAuth2 client-credentials. UNSET still works — the breadth tier\n# falls back to anonymous /states/all (~400 credits/day per source IP).\n# Creds raise the daily budget and unlock the cred-gated correlate ingest.\nOPENSKY_CLIENT_ID=\nOPENSKY_CLIENT_SECRET=\n# AISStream.io key — global live vessel WS layer (Digitraffic Baltic works\n# without it).\nAISSTREAM_KEY=\n# NASA FIRMS MAP_KEY — active-fire layer.\nFIRMS_MAP_KEY=\n# Global Fishing Watch token — vessel identity enrichment / dark vessels.\nGFW_TOKEN=\n# ACLED conflict events (key + registered email, both required together).\nACLED_KEY=\nACLED_EMAIL=\n# Cloudflare Radar token — internet-outage annotations.\nCLOUDFLARE_TOKEN=\n# Copernicus Data Space (Sentinel) — reserved for imagery tasking.\nCDSE_CLIENT_ID=\nCDSE_CLIENT_SECRET=\nGMAPS_KEY=\nOPENAIP_KEY=\n\n# ── Server ───────────────────────────────────────────────────────────────────\nAPI_HOST=0.0.0.0\nAPI_PORT=8000\nLOG_LEVEL=info\nCORS_ORIGINS=http://localhost:8080\n# When set, every REST + WS route requires X-API-Key (or ?key=) to match.\n# The web bundle must then be built with VITE_API_KEY set to the same value.\nAPI_KEY=\n# Disk tile cache root — safe to delete anytime, refills on demand.\nTILE_CACHE_DIR=./data/tilecache\n\n# ── MCP server + local AI (Ollama) ───────────────────────────────────────────\nAPI_BASE=http://localhost:8000\nOLLAMA_HOST=http://localhost:11434\n# \"\" → auto-detect the smallest installed model.\nOLLAMA_MODEL=\n\n# ── DeepSeek (OpenAI-compatible) — primary reasoning backend ─────────────────\n# Used by deep_analyze + the news debias/fact-check engine; falls back to\n# Ollama, then to raw structured data, when unset/unreachable. When blank,\n# app.llm reads the key + base from the user's opencode config\n# (~/.config/opencode/opencode.jsonc → provider.deepseek.options).\nDEEPSEEK_API_KEY=\nDEEPSEEK_BASE_URL=\n# Model ids per task tier (fast=extraction/classification, reason=judgement).\nDEEPSEEK_MODEL_FAST=deepseek-chat\nDEEPSEEK_MODEL_REASON=deepseek-reasoner\n\n# ── News debias / fact-check engine ──────────────────────────────────────────\nNEWS_ENABLED=true\nNEWS_REFRESH_SEC=600\nNEWS_MAX_ITEMS=400\n\n# ── Historical playback (3D replay/scrub) ────────────────────────────────────\nHISTORY_ENABLED=true\nHISTORY_DB_PATH=./data/history.db\nHISTORY_RETENTION_HOURS=48\n"}, "a1bc686aa4708df55d817a5168e117b352f72598": {"t": 1785765051.3214412, "n": 2128, "h": "edb787c8bc46b17a", "c": "# OSINT console environment. Copy to .env and fill in what you have:\n# cp .env.example .env\n# EVERY key is optional — the platform boots and shows aircraft, vessels,\n# quakes, and the dark basemap with this file completely empty (CLAUDE.md\n# \"layers that must always work without any API key\").\n\n# ── Visual / runtime config exposed to the browser ──────────────────────────\n# Cesium ion token (free Community tier) — only needed for OSM 3D Buildings.\nCESIUM_ION_TOKEN=\n# Google Photorealistic 3D Tiles feature flag (needs ion token; off = free).\nENABLE_GOOGLE_3D=false\nCLASSIFICATION=UNCLAS\nBUILD_ID=dev\n\n# ── Upstream keys (all optional; layers degrade gracefully when unset) ──────\n# OpenSky OAuth2 client-credentials. UNSET still works — the breadth tier\n# falls back to anonymous /states/all (~400 credits/day per source IP).\n# Creds raise the daily budget and unlock the cred-gated correlate ingest.\nOPENSKY_CLIENT_ID=\nOPENSKY_CLIENT_SECRET=\n# AISStream.io key — global live vessel WS layer (Digitraffic Baltic works\n# without it).\nAISSTREAM_KEY=\n# NASA FIRMS MAP_KEY — active-fire layer.\nFIRMS_MAP_KEY=\n# Global Fishing Watch token — vessel identity enrichment / dark vessels.\nGFW_TOKEN=\n# ACLED conflict events (key + registered email, both required together).\nACLED_KEY=\nACLED_EMAIL=\n# Cloudflare Radar token — internet-outage annotations.\nCLOUDFLARE_TOKEN=\n# Copernicus Data Space (Sentinel) — reserved for imagery tasking.\nCDSE_CLIENT_ID=\nCDSE_CLIENT_SECRET=\nGMAPS_KEY=\nOPENAIP_KEY=\n\n# ── Server ───────────────────────────────────────────────────────────────────\nAPI_HOST=0.0.0.0\nAPI_PORT=8000\nLOG_LEVEL=info\nCORS_ORIGINS=http://localhost:8080\n# When set, every REST + WS route requires X-API-Key (or ?key=) to match.\n# The web bundle must then be built with VITE_API_KEY set to the same value.\nAPI_KEY=\n# Disk tile cache root — safe to delete anytime, refills on demand.\nTILE_CACHE_DIR=./data/tilecache\n\n# ── MCP server + local AI (Ollama) ───────────────────────────────────────────\nAPI_BASE=http://localhost:8000\nOLLAMA_HOST=http://localhost:11434\n# \"\" → auto-detect the smallest installed model.\nOLLAMA_MODEL=\n"}}}} |