initial: ru-regions MCP server (DaData + SQLite cache)
This commit is contained in:
commit
6b4066aff5
6 changed files with 298 additions and 0 deletions
1
.env.example
Normal file
1
.env.example
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DADATA_API_KEY=your-dadata-api-key-here
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
.env
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-journal
|
||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir "mcp[cli]>=1.2.0" "httpx>=0.27"
|
||||||
|
|
||||||
|
COPY server.py .
|
||||||
|
|
||||||
|
ENV MCP_TRANSPORT=streamable-http \
|
||||||
|
MCP_HOST=0.0.0.0 \
|
||||||
|
MCP_PORT=8311 \
|
||||||
|
RU_REGIONS_CACHE_PATH=/data/cache.sqlite
|
||||||
|
|
||||||
|
EXPOSE 8311
|
||||||
|
|
||||||
|
CMD ["python", "server.py"]
|
||||||
39
README.md
Normal file
39
README.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# ru-regions MCP server
|
||||||
|
|
||||||
|
MCP-сервер: по названию населённого пункта РФ или координатам отдаёт регион (через DaData).
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
- `get_region(city, limit=5, refresh=False)` — определить регион по названию НП.
|
||||||
|
- `get_region_by_coords(lat, lon, radius_meters=1000, limit=1)` — обратный геокодинг.
|
||||||
|
- `cache_stats()` / `cache_clear()` — статистика и сброс локального кеша.
|
||||||
|
|
||||||
|
Результаты кешируются в SQLite, повторные запросы не идут в DaData.
|
||||||
|
|
||||||
|
## Запуск локально (stdio)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export DADATA_API_KEY=...
|
||||||
|
./server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт shebang-нутый под `uv run` — зависимости подтянутся через PEP 723 inline metadata.
|
||||||
|
|
||||||
|
## Запуск в Docker (streamable-http)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
echo "DADATA_API_KEY=..." > .env
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
По умолчанию слушает `127.0.0.1:8311`, endpoint — `/mcp`. Перед ним обычно reverse-proxy с TLS (Caddy/nginx).
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `DADATA_API_KEY` | — (обязательно) | Токен DaData (бесплатный тариф — 10k запросов/сутки) |
|
||||||
|
| `MCP_TRANSPORT` | `stdio` | `stdio` или `streamable-http` |
|
||||||
|
| `MCP_HOST` | `127.0.0.1` | Хост для HTTP-режима |
|
||||||
|
| `MCP_PORT` | `8311` | Порт для HTTP-режима |
|
||||||
|
| `RU_REGIONS_CACHE_PATH` | `cache.sqlite` рядом со скриптом | Путь к SQLite-кешу |
|
||||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
services:
|
||||||
|
ru-regions:
|
||||||
|
container_name: ru-regions
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8311:8311"
|
||||||
|
environment:
|
||||||
|
DADATA_API_KEY: ${DADATA_API_KEY}
|
||||||
|
MCP_TRANSPORT: streamable-http
|
||||||
|
MCP_HOST: 0.0.0.0
|
||||||
|
MCP_PORT: 8311
|
||||||
|
RU_REGIONS_CACHE_PATH: /data/cache.sqlite
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
220
server.py
Executable file
220
server.py
Executable file
|
|
@ -0,0 +1,220 @@
|
||||||
|
#!/usr/bin/env -S uv run --quiet
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.11"
|
||||||
|
# dependencies = [
|
||||||
|
# "mcp[cli]>=1.2.0",
|
||||||
|
# "httpx>=0.27",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
"""MCP-сервер: по названию населённого пункта РФ отдаёт регион (через DaData).
|
||||||
|
|
||||||
|
Запросы кешируются в SQLite (cache.sqlite рядом со скриптом), повторные
|
||||||
|
вызовы с тем же названием не идут в API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
DADATA_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
||||||
|
DADATA_GEOLOCATE_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/geolocate/address"
|
||||||
|
API_KEY = os.environ.get("DADATA_API_KEY")
|
||||||
|
if not API_KEY:
|
||||||
|
raise RuntimeError("DADATA_API_KEY env var is required")
|
||||||
|
|
||||||
|
CACHE_PATH = Path(
|
||||||
|
os.environ.get("RU_REGIONS_CACHE_PATH")
|
||||||
|
or Path(__file__).resolve().parent / "cache.sqlite"
|
||||||
|
)
|
||||||
|
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
TRANSPORT = os.environ.get("MCP_TRANSPORT", "stdio")
|
||||||
|
HOST = os.environ.get("MCP_HOST", "127.0.0.1")
|
||||||
|
PORT = int(os.environ.get("MCP_PORT", "8311"))
|
||||||
|
|
||||||
|
mcp = FastMCP("ru-regions", host=HOST, port=PORT)
|
||||||
|
|
||||||
|
|
||||||
|
def _db() -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(CACHE_PATH)
|
||||||
|
conn.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS lookups (
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
limit_n INTEGER NOT NULL,
|
||||||
|
response TEXT NOT NULL,
|
||||||
|
cached_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (key, limit_n)
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(city: str) -> str:
|
||||||
|
return " ".join(city.lower().replace("ё", "е").split())
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_get(key: str, limit: int) -> dict | None:
|
||||||
|
with _db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT response FROM lookups WHERE key = ? AND limit_n >= ? ORDER BY limit_n ASC LIMIT 1",
|
||||||
|
(key, limit),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
data = json.loads(row[0])
|
||||||
|
data["matches"] = data["matches"][:limit]
|
||||||
|
data["count"] = len(data["matches"])
|
||||||
|
data["from_cache"] = True
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_put(key: str, limit: int, data: dict) -> None:
|
||||||
|
payload = {"count": data["count"], "matches": data["matches"]}
|
||||||
|
with _db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO lookups (key, limit_n, response) VALUES (?, ?, ?)",
|
||||||
|
(key, limit, json.dumps(payload, ensure_ascii=False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_suggestion(s: dict) -> dict:
|
||||||
|
d = s["data"]
|
||||||
|
place = d.get("settlement_with_type") or d.get("city_with_type") or s.get("value")
|
||||||
|
return {
|
||||||
|
"place": place,
|
||||||
|
"full_address": s.get("value"),
|
||||||
|
"region": d.get("region_with_type"),
|
||||||
|
"region_iso_code": d.get("region_iso_code"),
|
||||||
|
"federal_district": d.get("federal_district"),
|
||||||
|
"area": d.get("area_with_type"),
|
||||||
|
"region_kladr_id": d.get("region_kladr_id"),
|
||||||
|
"region_fias_id": d.get("region_fias_id"),
|
||||||
|
"fias_id": d.get("fias_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_region(city: str, limit: int = 5, refresh: bool = False) -> dict:
|
||||||
|
"""Определить регион РФ по названию населённого пункта.
|
||||||
|
|
||||||
|
Результаты кешируются локально в SQLite — повторный запрос по тому же
|
||||||
|
названию не расходует лимит DaData.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
city: Название города, посёлка, села или деревни (например, "Мытищи").
|
||||||
|
limit: Сколько вариантов вернуть, если есть омонимы (1–20).
|
||||||
|
refresh: Принудительно сходить в API, обновив кеш.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Словарь:
|
||||||
|
- matches: список найденных вариантов (region, federal_district, area, place, kladr/fias id).
|
||||||
|
- count: количество совпадений.
|
||||||
|
- from_cache: True, если ответ взят из кеша.
|
||||||
|
"""
|
||||||
|
limit = max(1, min(int(limit), 20))
|
||||||
|
key = _normalize(city)
|
||||||
|
|
||||||
|
if not refresh:
|
||||||
|
cached = _cache_get(key, limit)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"query": city,
|
||||||
|
"from_bound": {"value": "city"},
|
||||||
|
"to_bound": {"value": "settlement"},
|
||||||
|
"count": limit,
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": f"Token {API_KEY}",
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
r = await client.post(DADATA_URL, json=payload, headers=headers)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
|
matches = [_format_suggestion(s) for s in data.get("suggestions", [])]
|
||||||
|
result = {"count": len(matches), "matches": matches, "from_cache": False}
|
||||||
|
_cache_put(key, limit, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_region_by_coords(
|
||||||
|
lat: float, lon: float, radius_meters: int = 1000, limit: int = 1
|
||||||
|
) -> dict:
|
||||||
|
"""Определить регион РФ по координатам (обратный геокодинг через DaData).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lat: Широта (например, 55.7558).
|
||||||
|
lon: Долгота (например, 37.6173).
|
||||||
|
radius_meters: Радиус поиска ближайшего адреса в метрах (1–1000).
|
||||||
|
limit: Сколько ближайших объектов вернуть (1–20).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Словарь:
|
||||||
|
- matches: список найденных объектов (region, federal_district, place, kladr/fias id).
|
||||||
|
- count: количество совпадений.
|
||||||
|
- from_cache: True, если ответ взят из кеша.
|
||||||
|
"""
|
||||||
|
radius_meters = max(1, min(int(radius_meters), 1000))
|
||||||
|
limit = max(1, min(int(limit), 20))
|
||||||
|
key = f"geo:{round(float(lat), 4)},{round(float(lon), 4)}:r{radius_meters}"
|
||||||
|
|
||||||
|
cached = _cache_get(key, limit)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"radius_meters": radius_meters,
|
||||||
|
"count": limit,
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": f"Token {API_KEY}",
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
r = await client.post(DADATA_GEOLOCATE_URL, json=payload, headers=headers)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
|
matches = [_format_suggestion(s) for s in data.get("suggestions", [])]
|
||||||
|
result = {"count": len(matches), "matches": matches, "from_cache": False}
|
||||||
|
_cache_put(key, limit, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def cache_stats() -> dict:
|
||||||
|
"""Статистика локального кеша запросов."""
|
||||||
|
with _db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT COUNT(*), MIN(cached_at), MAX(cached_at) FROM lookups"
|
||||||
|
).fetchone()
|
||||||
|
return {
|
||||||
|
"entries": rows[0] or 0,
|
||||||
|
"oldest": rows[1],
|
||||||
|
"newest": rows[2],
|
||||||
|
"path": str(CACHE_PATH),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def cache_clear() -> dict:
|
||||||
|
"""Очистить локальный кеш запросов."""
|
||||||
|
with _db() as conn:
|
||||||
|
deleted = conn.execute("DELETE FROM lookups").rowcount
|
||||||
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run(transport=TRANSPORT)
|
||||||
Loading…
Add table
Reference in a new issue