kyiv-alerts
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@kyiv-alertswere there any air raid alerts during my sleep window last night?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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.
Related MCP server: mcp-prozorro
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 .envSet MCP_AUTH_TOKEN (the only required value), add the two optional API keys
if you have them, then:
docker compose up -d --buildGenerate an MCP token with:
python -c "import secrets; print(secrets.token_urlsafe(32))"Check it came up:
curl -s localhost:8000/healthFollow the structured logs:
docker compose logs -f kyiv-alertsThe four sources
Source | Key | Transport | Backfill | Contributes |
neptun.in.ua | none | WebSocket push | no | alerts, |
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 | That name resolves to Cloudflare and times out. The Fly app answers on |
| It publicly serves only |
Keepalives are | They are |
Packets end with "ASCII 0x10 ( | 0x10 is DLE; the wire uses 0x0A. |
| 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 |
alerts.in.ua history | Accepts only |
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 |
|
|
|
|
|
both classes seen during one alert |
|
no tracks observed |
|
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 + datatransitionsis 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.fly.dev key. Enables that source. |
| — | alerts.in.ua token. Enables that source. |
| — | Bearer token for |
|
|
|
|
| SQLite file, on the mounted volume. |
|
| Tracked region. |
|
| Server binding. |
|
| Live stream endpoint. |
|
| HTTP API base. |
|
| Dead-link threshold. |
|
| Local floor on full-dump fetches. |
|
| alerts.in.ua poll cadence. |
|
| History reconciliation cadence. |
|
| Keyless WebSocket source; also supplies |
|
| Threat proximity to the city centre. |
|
| Opt-in explosion reports (Telegram text over HTTP). |
|
| Count oblast explosions — audible in the city. |
|
| Keyless third observer. |
|
| Gaps below this are heartbeat jitter, not data loss. |
|
| Structured JSON logs to stdout. |
|
| 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]"
pytestThe 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 dataStartup 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
Respect their rate limits and terms. The defaults here are deliberately polite.
Licence
MIT — see LICENSE.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- FlicenseBqualityCmaintenanceRead-only MCP tools for TROCCO API, enabling workflow and BigQuery datamart audit information retrieval.2
- Alicense-qualityCmaintenanceProvides access to Ukraine's public procurement data (ProZorro) via MCP, keyless and integrated with Pipeworx gateway for AI agents.13MIT
- Alicense-qualityCmaintenanceMCP server that reads Telegram groups and channels via MTProto to produce source-linked summaries with participant contributions, decisions, and risks over specified time ranges.100MIT
- FlicenseAqualityCmaintenanceProvides read-only access to your Garmin Connect health data, including sleep, HRV, body battery, stress, training readiness, and activities, through an MCP server.14
Related MCP Connectors
A paid remote MCP for AI SDK data query MCP, built to return verdicts, receipts, usage logs, and aud
Ukraine Open Data (data.gov.ua) CKAN MCP.
Keyless open data for 84 German cities: 12 lean read-only MCP tools covering 67 data types.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/YoYoZ/kyiv-alerts'
If you have feedback or need assistance with the MCP directory API, please join our Discord server