kyiv-alerts
Provides read-only MCP tools to query historical air-raid alerts for Kyiv City, including alert windows, sleep window overlaps, and data coverage verdicts.
README
kyiv-alerts
A standalone recorder for Kyiv City air-raid alert history, with a read-only MCP query surface. Built to make air alerts a first-class variable in sleep and recovery analysis instead of an invisible confounder.
It owns its own SQLite database and reads nothing else. One container runs both halves: a collector and an MCP server.
Ingests from four independent sources — two of which need no API key, so it works the moment you start it. Answers questions like "was there an alert during this sleep window, and how many minutes of it overlapped?" — and, crucially, tells you when it doesn't know.
Not a warning system. This is a recorder for retrospective analysis. It has no notifications and makes no real-time guarantees. For actual air-raid warnings use official channels and sirens.
Contents: Quick start · Tools · Sources · Backups · Configuration · Tests
The property that matters
An absent alert record is not the same as absent data.
Strikes cause the power and internet outages that kill the collector, so downtime is correlated with exactly the nights whose data matters most. If a query surface cannot distinguish "no alert" from "we weren't listening", every statistic built on it is biased in a direction you cannot detect afterwards.
So every tool returns a coverage verdict — full, partial, or none —
with the gap intervals when partial. An empty alert list is evidence of a quiet
night only when coverage is full.
Quick start
No API keys are needed to start recording. Two of the four sources —
neptun.in.ua and ubilling.net.ua — are open and on by default.
cp .env.example .env
Set MCP_AUTH_TOKEN (the only required value), add the two optional API keys
if you have them, then:
docker compose up -d --build
Generate an MCP token with:
python -c "import secrets; print(secrets.token_urlsafe(32))"
Check it came up:
curl -s localhost:8000/health
Follow the structured logs:
docker compose logs -f kyiv-alerts
The four sources
| Source | Key | Transport | Backfill | Contributes |
|---|---|---|---|---|
| neptun.in.ua | none | WebSocket push | no | alerts, kind, explosions (opt-in) |
| ubilling.net.ua | none | poll 30s | no | alerts (third observer) |
| raid.fly.dev | on request | TCP push | yes — to 2022 | alerts, full history |
| alerts.in.ua | on request | poll 30s | ~30 days | alerts, shelling records |
Only raid.fly.dev can backfill. Without it the dataset begins the day you
start the collector, and earlier windows correctly report coverage: none.
- raid.fly.dev — email
a@dun.aior Telegram@andunai, include#api. - alerts.in.ua — form at https://alerts.in.ua/api-request. It explicitly rejects one-line and LLM-written requests; describe your actual use and request rate in your own words.
All four are run by volunteers. The defaults are deliberately polite: push connections where offered, 30s polling elsewhere, and the expensive full-history endpoint is locally floored at one request per 5 minutes with the last attempt persisted, so neither a crash-restart loop nor a flapping link can turn into a hammering loop.
NEPTUN's terms require a visible attribution link wherever its data is
displayed. Nothing here has a UI, but if you ever surface this data publicly:
Дані: Карта повітряних тривог — NEPTUN (https://neptun.in.ua/).
Connecting a client
Streamable HTTP at http://<host>:8000/mcp, bearer token in the Authorization
header.
{
"mcpServers": {
"kyiv-alerts": {
"type": "http",
"url": "http://localhost:8000/mcp",
"headers": { "Authorization": "Bearer YOUR_MCP_AUTH_TOKEN" }
}
}
}
The port is published on 127.0.0.1 by default. If you expose it to your LAN
via MCP_BIND, put TLS in front of it — the bearer token is otherwise sent in
clear text.
Running without a token
If the server is already protected at the network layer — a private path, geo/IP restriction, a VPN — set:
MCP_AUTH_MODE=none
/mcp then serves unauthenticated, and a warning is logged on every start so
an open server never becomes a forgotten surprise.
MCP_AUTH_MODE is the only way to disable auth. An empty or misspelt
MCP_AUTH_TOKEN makes the service refuse to start rather than fall open, so a
typo in .env cannot quietly expose your data. Worth knowing what an open
server discloses: when you are home, when you sleep, and when you were awake
at 3am — a presence-and-routine profile tied to a real address in a war zone.
That is the thing the network layer has to actually be stopping.
Tools
All read-only. All timestamps in and out are Europe/Kyiv local time; storage
is UTC.
alerts_window(from_local, to_local)
Alert intervals overlapping the window: start, end, duration, kind, explosions flag, contributing sources — plus the coverage verdict.
alerts_for_sleep(onset_local, wake_local)
The primary tool.
alerts_before_onset— alerts that ended within 6 hours before onset, each withminutes_before_onset. These are what delay bedtime.alerts_in_window— alerts overlapping the sleep itself, each withminutes_overlap,started_before_onset,ended_after_wake. These are what fragment sleep.total_min_in_window— summed overlap, de-duplicated across sources.explosions_reported—true,false, ornullwhen unknown.coverageandcoverage_pre_onset_lookback— verdicts plus gaps.
All overlap arithmetic is done server-side.
ingest_status()
Last successful ingest per source, current connection state, total disconnect minutes over the last 7 days, and coverage gaps over the last 30 days.
Data sources, as they actually are
Documentation for both services is out of date in ways that matter. What is implemented here reflects probing them directly (August 2026).
| Claim in the docs | Reality |
|---|---|
TCP at tcp.alerts.com.ua:1024 |
That name resolves to Cloudflare and times out. The Fly app answers on raid.fly.dev:1024. |
alerts.com.ua serves the API |
It publicly serves only /api/states; /api/history and /api/states/live 404 there. The full API is on raid.fly.dev. |
Keepalives are ping |
They are p:<random 0-9999>. |
Packets end with "ASCII 0x10 (\n)" |
0x10 is DLE; the wire uses 0x0A. |
/api/history gives history |
It is an unfiltered full dump of all 25 regions since 2022-03-15, no range parameters, 1 request/minute. |
| alerts.in.ua has media-derived explosion events | No such endpoint exists. The only signal is an alert whose alert_type is artillery_shelling or urban_fights. |
| alerts.in.ua history | Accepts only week_ago / month_ago — it cannot backfill deeper than ~30 days. |
Region identity is verified at startup rather than trusted: Kyiv City is
state_id 25 (name_en: "Kyiv") on raid.fly.dev and location_uid 31
on alerts.in.ua. Kyiv oblast is 9 / 14 and is a different place.
Health warning on raid.fly.dev: as of August 2026, 22 of its 25 regions have
changed timestamps frozen since October–December 2025 (Luhansk still reads
alert=true since 2023). Kyiv City is current, but the feed shows signs of
partial degradation, which is why both sources run concurrently with independent
coverage tracking rather than one being a passive backup.
kind and explosions
Neither raid.fly.dev nor alerts.in.ua can fill these: the first is a boolean, and the second's types distinguish air raid from shelling, not weapon class. NEPTUN is what makes both possible.
kind comes from NEPTUN threat tracks overlapping the alert, mapped from
its type field:
| NEPTUN type | kind |
|---|---|
uav, recon |
drone |
missile, ballistic, kab, mig31k |
ballistic |
| both classes seen during one alert | mixed |
| no tracks observed | unknown |
A track counts only if it is in м. Київ or within NEPTUN_THREAT_RADIUS_KM
(default 50) of the city centre. Kyiv oblast alone does not qualify — Біла
Церква is ~75 km out, and counting it would attribute a city alert to a threat
the other side of the region. advisory: true tracks (observations such as a
MiG-31K takeoff, not shelter signals) are excluded.
explosions is three-valued and only ever leaves NULL when a source that
actually reports explosions covered the whole alert:
1— a shelling record (alerts.in.ua) or an explosion report (NEPTUN messages) overlapped the alert.0— such a source was live for the entire alert and reported nothing.NULL— no explosion-reporting source covered it. Coverage from an alerts-only source does not count, because it is not evidence either way.
Kyiv oblast counts for explosions, not for alerts. EXPLOSIONS_INCLUDE_OBLAST
(default true) also accepts reports from Київщина / Київська обл., because
detonations and air-defence work out there are routinely audible across the
city and therefore do disturb sleep. Alert intervals stay strictly
city-level — an oblast alert is not a city alert, and merging them would
inflate total_min_in_window on nights Kyiv itself was quiet. Set it false
for strictly within-city events.
Populating it requires NEPTUN_MESSAGES_EXPLOSIONS=true, which is off by
default: it parses text from monitoring Telegram channels. NEPTUN republishes
those over HTTP so nothing scrapes Telegram, but it is still Telegram text and
the original brief excluded that — hence an explicit opt-in. Matching requires
both a Kyiv reference and an event word (вибух, приліт, працює ППО,
детонац); forecasts such as "курсом на Київ" and Київщина (the oblast) are
deliberately excluded.
How it works
raid.fly.dev TCP ──push──┐
├──▶ transitions (append-only truth)
alerts.in.ua poll ──30s───┘ │
▼
alerts (derived, idempotent)
│
history backfill ──▶ coverage ──────┴──▶ MCP tools ──▶ verdict + data
transitionsis append-only and the only source of truth. Idempotency comes fromUNIQUE(region, state, ts_utc, source).alertsis derived from it and can be rebuilt at any time. Consecutive same-state transitions collapse to the earliest, which is what lets a connect-time snapshot be superseded by the true start that backfill supplies.coveragerecords intervals where ingest was demonstrably live. Rows are written when a session opens and advanced in place on each heartbeat, so a power cut loses at most one heartbeat rather than the whole session.
Reconnect uses jittered exponential backoff capped at 60s and retries indefinitely. Silence beyond 45s (three missed pings) is treated as a dead link. Every startup and every reconnect triggers a backfill from the last recorded coverage timestamp to now.
Both sources see the same siren seconds apart and both rows are kept for
forensics; the query layer reads a merged view so total_min_in_window is never
double counted.
Configuration
| Variable | Default | Purpose |
|---|---|---|
RAID_API_KEY |
— | raid.fly.dev key. Enables that source. |
ALERTS_IN_UA_TOKEN |
— | alerts.in.ua token. Enables that source. |
MCP_AUTH_TOKEN |
— | Bearer token for /mcp. Required unless auth is off. |
MCP_AUTH_MODE |
token |
none serves unauthenticated (explicit opt-in only). |
DB_PATH |
/data/alerts.db |
SQLite file, on the mounted volume. |
REGION |
kyiv_city |
Tracked region. |
MCP_HOST / MCP_PORT / MCP_PATH |
0.0.0.0 / 8000 / /mcp |
Server binding. |
RAID_TCP_HOST / RAID_TCP_PORT |
raid.fly.dev / 1024 |
Live stream endpoint. |
RAID_HTTP_BASE |
https://raid.fly.dev |
HTTP API base. |
RAID_SILENCE_TIMEOUT_S |
45 |
Dead-link threshold. |
RAID_HISTORY_MIN_INTERVAL_S |
300 |
Local floor on full-dump fetches. |
AIU_POLL_INTERVAL_S |
30 |
alerts.in.ua poll cadence. |
AIU_HISTORY_INTERVAL_S |
1800 |
History reconciliation cadence. |
NEPTUN_ENABLED |
true |
Keyless WebSocket source; also supplies kind. |
NEPTUN_THREAT_RADIUS_KM |
50 |
Threat proximity to the city centre. |
NEPTUN_MESSAGES_EXPLOSIONS |
false |
Opt-in explosion reports (Telegram text over HTTP). |
EXPLOSIONS_INCLUDE_OBLAST |
true |
Count oblast explosions — audible in the city. |
UBILLING_ENABLED |
true |
Keyless third observer. |
COVERAGE_STITCH_S |
60 |
Gaps below this are heartbeat jitter, not data loss. |
LOG_LEVEL |
INFO |
Structured JSON logs to stdout. |
MCP_BIND |
127.0.0.1 |
Host interface the port is published on. |
Only MCP_AUTH_TOKEN is mandatory. The service refuses to start if every
source is disabled, rather than run as a healthy-looking recorder of nothing,
and warns at startup when no RAID_API_KEY is present because that is the
only source that can backfill.
Tests
pip install -e ".[dev]"
pytest
The suite covers the acceptance criteria directly, against a fake raid TCP server that speaks the real protocol and a real MCP client over HTTP:
test_reconnect_backfill.py— killing the connection mid-stream reconnects, backfills, and leaves the gap absent fromcoverage; the inverse case asserts an un-backfilled outage stays visible.test_backfill_idempotent.py— repeated backfills and re-derivations produce zero duplicate rows.test_sleep_overlap.py— partial overlap for an alert straddling onset, plus wake-straddling, ongoing alerts, and cross-source de-duplication.test_coverage_verdict.py— a window with no data returnsnone, never an empty list that reads as quiet.test_dst.py— local timestamps round-trip across the October transition; the repeated 03:00 hour stays two distinct instants and a transition night measures 9 real hours, not 8.
Operational notes
Bind-mount ownership. The container runs as uid 10001, but ./data
arrives owned by whoever created it — usually root — so the collector cannot
write to it. On a fresh host, before the first start:
mkdir -p data && sudo chown -R 10001:10001 data
Startup now preflights this and prints that exact command if the directory is not writable, instead of failing later with SQLite's opaque "unable to open database file".
- SQLite runs in WAL mode; the database lives on a bind mount at
./data, not in the container. - Restart policy is
unless-stopped. - Full history is kept forever — a few thousand rows per year.
/healthis unauthenticated; everything else requires the bearer token.- Back it up. The history cannot be re-fetched beyond ~29 days. See BACKUP.md.
Contributing
Issues and PRs welcome. Two things worth knowing before changing anything:
- Coverage is the load-bearing property. Any change that lets a query
return an empty result without an accurate verdict is a bug, however
convenient. The tests in
test_coverage_verdict.pyexist to stop exactly that, and they are mutation-checked. transitionsis append-only truth.alertsis derived and rebuildable. Keep it that way — idempotent derivation is what makes repeated backfills safe.
Run pytest before opening a PR; the suite needs no network or API keys.
Adapting to another region
The schema and query layer are region-agnostic; only REGION_IDS in
config.py is Kyiv-specific. Adding another Ukrainian region means supplying
its four identifiers (raid state_id, alerts.in.ua uid, ubilling name,
NEPTUN oblast key) and its coordinates. Note the collector records one region
per deployment — see the note in config.py on why region is configuration
rather than a per-request parameter.
Credits
Data comes from four volunteer-run services. If you use this, consider supporting them:
- NEPTUN — attribution required when displaying its
data:
Дані: Карта повітряних тривог — NEPTUN - alerts.in.ua
- raid.fly.dev / alerts.com.ua by Andrew Dunai
- ubilling.net.ua
Respect their rate limits and terms. The defaults here are deliberately polite.
Licence
MIT — see LICENSE.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。