tomsk-transport
MCP server providing real-time public transport data for Tomsk, including routes, schedules, stops with lazy OSM enrichment, nearby stop search, and a routing prompt for LLMs.
README
Transport MCP Server (Tomsk)
MCP-сервер, предоставляющий LLM актуальные данные о маршрутах общественного транспорта Томска. Дипломная работа: «Разработка MCP-сервера для интеграции больших языковых моделей в транспортные информационные системы».
Возможности
- Tools
get_stops(route_id)— упорядоченный список остановок маршрута. Каждая остановка содержит координаты (lat/lon), расстояние и оценку времени до следующей (distance_to_next_m,estimated_travel_time_sпри средней скорости 20 км/ч), плюс OSM-теги доступности (wheelchair/shelter/bench). Если в БД лежат только конечные, сервер лениво подтягивает полную топологию маршрута из Overpass API (OpenStreetMap) и кэширует — см. ниже.get_routes_schedules(route_id)— расписание маршрута в markdown (распознано через OCR из первоисточника); первый вызов скачивает источник, прогоняет через OCR-пайплайн и кэширует, последующие — отдают из БД (TTL по умолчанию 24 ч)find_nearby_stops(address, radius_m=500)— геокодит адрес через OSM Nominatim и возвращает остановки в радиусе с дистанцией в метрах
- Resource
transport://routes— список всех активных маршрутов - Prompt
find_route_prompt(from_location, to_location)— шаблон, инструктирующий LLM последовательно использовать tools/resource для построения маршрута
Покрытие маршрутов
Поддерживаются 44 маршрута Томска от трёх источников расписаний:
| Источник | Кол-во | Формат первоисточника | Парсер |
|---|---|---|---|
| пассажир.online (xn--80aasi5akda.online) | 3 + 6 | DOCX в cloud.mail.ru / изображения / PDF | EtvDocxScheduleParser (для 112С/Б/Д) + OCR-пайплайн (для пригородных) |
| rasptomsk.ru | 17 | JPG/PNG/PDF | OCR-пайплайн |
| tomskavtotrans.ru | 18 | WordPress-страница с набором <img> |
HTML-индексатор + vstack + OCR-пайплайн |
Полный список — в src/transport_mcp/db/seeds/_catalog.py.
| route_id | Маршрут | Особенности |
|---|---|---|
112S/B/D |
Томск — Серебряный бор / Борики / Дзержинское | DOCX-парсер (точное извлечение таблиц python-docx) |
26, 29 |
Кольцевая Алтайская — Авангард, Спичфабрика — Карандашная фабрика | OCR JPG |
4, 5, 11, 12, 13, 14, 19, 20, 23, 30, 33, 36, 37, 38, 53 |
Муниципальные маршруты с rasptomsk.ru | OCR (включая PDF для 19) |
118–510 |
Пригородные маршруты ТомскАвтоТранса | HTML→vstack→OCR |
101, 133, 134, 301, 401, 514 |
Пригородные маршруты ЕТВ | Multi-file (несколько файлов на маршрут, склейка) |
Архитектура
tools/, resources/, prompts/ # FastMCP обвязка
↓
services/ # бизнес-логика
↓
repositories/ parsers/ downloaders/ services/ocr_service
↓ ↓ ↓ ↓
domain/ OCR-table / httpx → rapidocr-onnxruntime
DOCX cloud.mail.ru, (CPU, ONNX, RU)
rasptomsk.ru,
tomskavtotrans.ru
↓
db/ (aiosqlite, SQLite)
OCR-пайплайн (для 38 маршрутов):
URL → Downloader → bytes (JPG/PNG/PDF) → OcrEngine.recognise()
│
▼
list[OcrBox]
│
▼
OcrTableScheduleParser
(кластеризация колонок по X,
строк по Y → markdown-таблицы)
│
▼
markdown
Для multi-file источников (пассажир.online 101/133/134/...) и tomskavtotrans (несколько <img> на странице) скачанные изображения склеиваются вертикально через utils.image_join.vstack_images до подачи в OCR — парсеру это выглядит как одна высокая картинка.
Lazy-обогащение остановками из OSM. OCR-расписания публикуют только конечные остановки маршрута, поэтому для не-legacy маршрутов в БД по умолчанию лежит лишь 2 точки (по seed-у из terminal_coordinates.json). При первом вызове get_stops(route_id) для такого маршрута StopsService идёт в Overpass API (OpenStreetMap), достаёт relation маршрута по ref и упорядоченный список stop-нод, делает upsert в таблицы stops+route_stops и записывает метку в overpass_sync. На последующих вызовах данные отдаются из БД мгновенно. Логика:
get_stops(route_id)
↓
StopsService.list_for_route
├── route exists? → нет → ToolError
├── stops в БД ≥ OVERPASS_MIN_STOPS (=3)? → вернуть
├── route в LEGACY_SEED_ONLY (112С/Б/Д/26/29)? → вернуть seed как есть
├── overpass_sync свежий (TTL=168ч)? → вернуть существующий
└── per-route asyncio.Lock + double-check
↓
OverpassRouteFetcher.fetch_route_stops(ref)
├── OK → upsert stops + route_stops, mark_ok
├── empty → mark_skipped (нет в OSM)
└── error → mark_failed (back-off через TTL)
↓
StopsRepository.list_by_route
↓ (distance/eta считаются налету через haversine)
list[Stop]
Расширяемость: добавление нового маршрута — это одна запись в db/seeds/_catalog.py (URL источника, конечные, source_kind). Координаты конечных подтягиваются из data/terminal_coordinates.json (заполняется однократно через scripts/seed_coordinates.py). Промежуточные остановки подтянутся из OSM автоматически при первом запросе. Сервисы и tools не меняются.
Запуск
uv sync --extra dev # установка зависимостей (включая OCR)
Copy-Item .env.example .env # вписать переменные окружения (опционально)
uv run transport-mcp-seed # инициализация БД + seed всех маршрутов
uv run transport-mcp # сервер на http://127.0.0.1:8000/mcp
Первый вызов get_routes_schedules для OCR-маршрута займёт ~10-30 секунд: rapidocr-onnxruntime загружает свои модели (~50 МБ) при первом инференсе, далее — мгновенно из кэша.
Подключение к Claude Desktop
В claude_desktop_config.json:
{
"mcpServers": {
"tomsk-transport": {
"url": "http://127.0.0.1:8000/mcp"
}
}
}
Подключение к Claude Code
claude mcp add --transport http tomsk-transport http://127.0.0.1:8000/mcp
Проверка через MCP Inspector
npx @modelcontextprotocol/inspector
# Подключиться к http://127.0.0.1:8000/mcp transport=Streamable HTTP
# Вызовите: get_stops("112S") — seed (промежуточные внесены вручную),
# get_stops("12") — lazy-fetch из Overpass (1й вызов ~5-10с, далее мгновенно),
# get_stops("442") — пригородный Северск, тоже из Overpass,
# get_routes_schedules("4"|"19"|"119"|"133"),
# find_nearby_stops("проспект Ленина 30", 600);
# прочитайте transport://routes; вызовите prompt find_route_prompt.
Тесты
uv run pytest # 116 unit/E2E тестов, ~25 сек
uv run pytest -m ocr_live # 4 live OCR-теста на реальных изображениях, ~30 сек
Live OCR-тесты по умолчанию отключены (addopts = ["-m", "not ocr_live"]). Они скачивают модели rapidocr и стучатся в карьерные сайты — запускать вручную для регрессии. Запросы к Overpass API в тестах не делаются — tests/conftest.py выставляет OVERPASS_ENABLED=false, реальный сетевой клиент проверяется через pytest-httpx с записанной фикстурой (tests/fixtures/overpass_route_12.json).
Ручная сверка OCR
uv run python scripts\manual_ocr_check.py
Прогоняет OCR-пайплайн по списку маршрутов из ROUTES_TO_CHECK, складывает распознанный markdown в data/ocr_manual_check/route_<id>.md. Удобно для сверки с оригинальными скриншотами.
Структура
src/transport_mcp/
├── server.py # composition root, регистрация tools/resources/prompts
├── config.py # pydantic-settings (.env)
├── exceptions.py
├── logging_setup.py # лог в stderr
├── domain/ # Pydantic-модели (Route, Stop, Schedule, ...)
├── db/
│ ├── connection.py
│ ├── migrations.py
│ ├── schema.sql
│ ├── seed.py # composition root для transport-mcp-seed
│ └── seeds/
│ ├── route_112s/112b/112d/26/29.py # ручные seed-модули (legacy)
│ └── _catalog.py # каталог 39 OCR-маршрутов
├── repositories/ # routes_repo, stops_repo, schedule_repo, cache_repo, overpass_sync_repo
├── services/
│ ├── routes_service.py
│ ├── stops_service.py # БД-first, fallback на Overpass под per-route Lock
│ ├── schedule_service.py # TTL+lock, поддержка multi-URL источников
│ ├── geocoding_service.py # OSM Nominatim + Overpass (для find_nearby_stops)
│ ├── overpass_client.py # OverpassRouteFetcher: relation+stop-nodes по ref
│ ├── route_osm_ref.py # mapping route_id → OSM ref + LEGACY_SEED_ONLY
│ └── ocr_service.py # rapidocr-onnxruntime + PDF через pypdfium2
├── parsers/
│ ├── base.py # ScheduleParser ABC
│ ├── etv_docx_parser.py # DOCX-парсер для 112С/Б/Д (python-docx)
│ ├── ocr_table_parser.py # универсальный OCR-парсер
│ ├── rasptomsk_ocr_parser.py # тонкий wrapper для обратной совместимости тестов
│ ├── rasptomsk_specs.py # DayBlockSpec для 26 и 29
│ ├── registry.py # ScheduleSource, SourceRegistry
│ └── route_registry.py # массовая регистрация из _catalog.py
├── downloaders/
│ ├── base.py # FileDownloader ABC
│ ├── cloud_mail_ru.py # cloud.mail.ru public weblinks (пассажир.online)
│ ├── http_direct.py # обычный GET (rasptomsk.ru)
│ ├── tomskavtotrans.py # HTML-индексатор страницы + vstack
│ └── local_cache.py # дисковый кэш SHA-256 (декоратор)
├── tools/ # get_stops, get_routes_schedules, find_nearby_stops
├── resources/ # transport://routes
├── prompts/ # find_route_prompt
└── utils/
├── geo.py # haversine
├── transport_constants.py # AVG_BUS_SPEED_MPS (для ETA до следующей остановки)
└── image_join.py # vstack_images для multi-file/HTML-источников
scripts/
├── seed_coordinates.py # one-time массовое геокодирование через Nominatim
└── manual_ocr_check.py # сверка OCR с оригиналами
data/
├── transport.db # SQLite БД
├── cache/ # дисковый кэш скачанных файлов (SHA-256(url))
├── terminal_coordinates.json # координаты конечных, заполняется seed_coordinates.py
└── ocr_manual_check/ # выводы ручной сверки
Замечания
-
Гибридная архитектура парсеров. 112С/Б/Д используют точный DOCX-парсер (
python-docx→ таблицы напрямую, 100% точность). Все остальные маршруты — OCR-пайплайн поверхrapidocr-onnxruntime(CPU, ONNX, поддержка русского). Это компромисс между качеством (DOCX даёт идеальные таблицы) и охватом (OCR покрывает любые форматы первоисточника). -
Координаты остановок. Конечные 38 OCR-маршрутов геокодируются один раз через OSM Nominatim (
scripts/seed_coordinates.py); результат лежит вdata/terminal_coordinates.json. Точки, которые Nominatim не нашёл, заполнены вручную как fallback. Для 112С/Б/Д координаты всех промежуточных остановок внесены вручную в seed-модулях. Для остальных маршрутов промежуточные остановки подтягиваются автоматически из OpenStreetMap через Overpass API при первом обращении кget_stops(см. замечание 7). -
Кэш. Скачанные документы хранятся в
data/cache/по SHA-256(url). Сгенерированный markdown — в таблицеschedule_documentsс TTL 24 ч (полеcache_meta.last_fetched_at). -
rapidocr-onnxruntime вместо PaddleOCR. PaddlePaddle 3.x имеет известный баг с oneDNN на Windows (
OneDnnContext does not have the input Filter), который не выключается ни флагами, ниenable_mkldnn=False. ONNX Runtime упаковка тех же моделей PaddleOCR работает стабильно, занимает ~50 МБ вместо ~700 МБ paddlepaddle и даёт сравнимое качество распознавания. -
Дополнительные распознанные колонки. На длинных расписаниях (например, маршрут 26 на rasptomsk.ru) первоисточник физически разбит на несколько столбцов на странице. OCR-парсер ожидает 6 колонок по спецификации DayBlockSpec (3 блока × 2 направления), а распознаёт 7-8. Лишние колонки выводятся в секцию
## Дополнительные распознанные колонкисо списком времён — это страховка против молчаливой потери данных. LLM-клиент видит и основные блоки, и дополнительные, и трактует их в контексте запроса пользователя. -
Маршрут 19 на rasptomsk.ru опубликован в виде PDF. OCR-движок определяет PDF по магическим байтам и рендерит каждую страницу через
pypdfium2в изображение перед распознаванием. -
Overpass API как источник остановок. Yandex Schedules API не покрывает городской транспорт Томска (только междугороднее автобусное и ж/д сообщение), Yandex Maps публичный API остановок маршрута не отдаёт. Поэтому источник промежуточных остановок — Overpass API (OpenStreetMap): бесплатный, без ключа. Запрос идёт по
area["name"="Томская область"]["admin_level"="4"](не["name"="Томск"]["admin_level"="6"]— в OSM городские маршруты Томска относятся к области, а не к городу). Сам Overpass-запрос отсекает рекурсию по way-геометрии и явно резолвит только stop-nodes черезnode(r.routes); out;— иначе запрос для admin_level=4 не укладывается в server-side timeout. Метки синхронизации хранятся в таблицеoverpass_sync(TTL=168 ч / 7 дней), при недоступности OSMget_stopsтихо возвращает имеющиеся в БД остановки. Поведение настраивается через env-переменныеOVERPASS_ENABLED,OVERPASS_URL,OVERPASS_AREA_NAME,OVERPASS_ADMIN_LEVEL,OVERPASS_TIMEOUT_S,OVERPASS_SYNC_TTL_HOURS,OVERPASS_MIN_STOPS. -
Пригородные маршруты, не размеченные в OSM. Маршруты
118,131,141,308,514в OpenStreetMap не размечены — для нихget_stopsвсегда возвращает только 2 конечные остановки из seed. Это ограничение источника данных, не проекта. По состоянию на дату аудита 34 из 39 не-legacy маршрутов реально находятся в OSM и через Overpass отдают полный список остановок (для маршрута 12, например, 42+37 точек туда-обратно).
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.