Мы выпустили MCP‑сервер, который даёт ИИ‑агентам прямой доступ к геосервисам API‑платформы 2ГИС: поиск мест и организаций, прямое и обратное геокодирование, построение маршрутов, изохроны и статические карты — сейчас это 6 инструментов поверх соответствующих API.
Что это за сервер и как подключить — в документации. А в этой статье расскажу про инженерную часть: как он устроен внутри и на какие грабли мы наступили, пока доводили его до прода.
Статья будет полезна разработчикам, которые пишут свой MCP‑сервер или тулы под function calling и которые проектирует контракты инструментов для LLM, и просто всем, кому интересно, что ломается на стыке LLM и гео.
Особенности реализации
Без подключённых геоинструментов ассистент на вопрос про маршрут или ближайшую организацию отвечает уверенно и полностью выдумано — взять данные ему просто неоткуда. Казалось бы, дальше всё просто: дай модели доступ к геоданным, и проблема закрыта. На практике выяснилось, что доступа мало: получив инструменты, модель начинает врать иначе — и это куда труднее заметить.
Дальше разберём:
Как устроен каркас сервера — чтобы добавление каждого нового инструмента не превращалось в копипаст обработки ошибок, логирования и http‑пулов.
Почему модель уверенно выдумывает адреса и координаты — и как мы это ловили на реальных вызовах.
Как мы закрываем это контрактом — валидацией и типизированными сценариями, а не уговорами в системном промпте.
Терминология, которую дальше будем использовать:
Инструмент (тул) — функция, которую модель может вызвать. У неё есть имя, описание и параметры с типами. Код не исполняет сама модель — она формирует вызов с аргументами, исполняет клиент, а результат возвращается модели в контекст.
MCP (Model Context Protocol) — открытый протокол, по которому ИИ‑модель подключается к внешним инструментам. Инструмент, подключённый через MCP, модель вызывает так же, как собственный встроенный навык: видит его имя, описание и параметры и сама решает, звать его или нет.
MCP‑сервер — программа, которая такие инструменты отдаёт. mcp-2gis — это MCP‑сервер, за которым стоит API 2ГИС.
Схема инструмента — JSON‑описание параметров: что обязательно, что опционально, какие значения допустимы. Это контракт, и код за ним модель не видит.
Описание (description) — текст, который модель читает, решая, звать ли инструмент и с какими аргументами. В MCP это часть продукта, а не комментарий: с кривым описанием модель может либо вообще не понять, что инструмент нужно вызвать, либо вызвать его некорректно.
Точка WGS84 — пара чисел: долгота longitude и широта latitude, в градусах.
Span, трассировка — размеченный кусок исполнения: по нему видно, какой инструмент звали, сколько шёл вызов и чем он закончился.
Домен — предметная область API: поиск мест, геокодирование, маршруты, матрицы, карты.
Архитектура MCP: каркас и инструменты как тонкие адаптеры
Когда инструмент подключают к приложению, могут не сразу задуматься о том, чтобы делать всё расширяемым и поддерживаемым. Как правило, это один‑два инструмента, и танцы с бубнами и абстракции скорее навредят читаемости кода, чем принесут пользу.
В нашем же случае одной из ключевых вещей, которые хотелось заложить в архитектуру, была масштабируемость. Нам было нужно обеспечить простую реализацию добавления новых инструментов и MCP‑серверов, так как эта задача у нас будет всплывать не раз и не два.
Решение простое и элегантное: инструмент — это только тонкий адаптер между MCP‑контрактом и доменом API. Все остальное живёт в каркасе.
Именно поэтому при добавлении очередного инструмента нам не нужно копипастить из предыдущего инструмента и бояться, что через пару релизов у нас будет пять копий обработки ошибки авторизации, три реализации circuit breaker и парочка пропущенных логирований API‑ключа для тестов.
Слои
MCP client -> FastMCP middleware (auth, allowlist, metrics, timeout) -> tools/<domain>.py (MCP-схема, Pydantic-валидация, маппинг результата) -> tools/gis.py (lifespan-зависимости + общая граница ошибок) -> integrations/<domain>_api.py (URL, параметры, JSON конкретного API) -> integrations/gis_client.py (общий httpx-пул, инъекция ключа, HTTP-ошибки) -> API 2GIS
Каждый слой отвечает только за свою часть. Интеграционный модуль получает URL и схему взаимодействия с API, но не знает ни заголовков, ни откуда взялся ключ, ни MCP.
Тул не занимается созданием http‑пулов, не читает переменные окружения. Такая архитектура позволяет хорошо разделять ответственность и уменьшать дублирование кода, а также легко подключать при новый MCP без необходимости копипастить тонну кода.
Ядро
Упрощённо ядро представлено ниже:
# упрощено: убраны isinstance-проверки lifespan и атрибуты span def get_runtime(ctx) -> Runtime: access = get_access_context() # contextvar: чей ключ, какой request_id if access is None: raise UpstreamUnavailable # без ключа до API 2GIS не идём # httpx.AsyncClient и Settings — одни на процесс (FastMCP lifespan) return Runtime(GISAPIClient(ctx.lifespan.http_client, ctx.lifespan.settings), access) async def run_gis_tool(name, ctx, operation) -> ToolResult: with tool_span(name): # один span на инструмент; метрики — в middleware try: rt = get_runtime(ctx) return await operation(rt.client, rt.access) except GISAPIError as e: return rt.client.error_result(e) # ошибка ключа/доступа → результат для модели except UpstreamError as e: raise to_mcp_error(e) from e # сеть/таймаут → протокольная ошибка, без тела upstream
Всё общее живёт в одном помощнике run_gis_tool, у которого 3 важные задачи:
собрать зависимости (общий httpx‑пул + контекст доступа этого запроса),
открыть один span на инструмент,
перевести ошибки в два разных контракта — «агент может об этом рассуждать» и «протокол упал, повторяй».
Обратите внимание на развилку в except. Это реализация одного из важнейших требований: бизнес‑ошибку ключа и доступа агент должен получить как результат инструмента с машиночитаемым кодом, а не как падение протокола — иначе модель не понимает, что делать, и идёт вызывать инструмент заново. А сетевой сбой или таймаут upstream, наоборот, обязаны остаться McpError, потому что тут нет смысла рассуждать — есть смысл повторить.
Добавление нового инструмента
Таким образом, для добавления нового инструмента нужно сделать:
Pydantic‑модели входа/выхода в
models/<domain>.py;Узкий клиент в
integrations/<domain>_api.py, который возвращает доменную модель, а неhttpx.Response;@mcp.toolвtools/<domain>.pyс русскимdescription,annotations(readOnlyHint,idempotentHint) и только бизнес‑параметрами;Вызов
run_gis_toolиbuild_tool_resultв callback — ключ,http_client, Settings и URL в параметры инструмента не передаются никогда;Регистрация в
tools/__init__.py, контракт вmcp_tools.jsonиmcp-server-catalog.yaml,ALLOWED_TOOLS;unit + in‑process integration тесты.
Естественный путь (тул сам всё делает) |
Что даёт каркас |
|
один пул на процесс через |
ключ читается из |
|
|
одна граница ошибок на все инструменты, upstream body наружу не уходит |
ошибка — строка Ошибка: 403, модель зовёт тул заново |
стабильные |
недокументированный |
закрытые списки |
«почему тул деградировал» — гадание |
один span с именем инструмента + метрики started/success/error из middleware |
контекст‑ключ живёт дольше запроса |
|
Якоря: как без них модель дрейфует в другой город
Одна из первых проблем с подвохом, с которой мы столкнулись на поддержке ещё Pro‑агента, — это абсолютно внезапное придумывание адреса. Допустим, мы задаём абсолютно понятный человеку запрос: «Покажи ближайшую ко мне кофейню». И тут начинаются чудеса. Модель может вместо вызова инструмента вдруг придумать реалистичный адрес кофейни — той, где вы находитесь, или просто популярной. Иногда она даже попадает в точку и называет верную улицу, вот только это не результат вызова инструмента, а смесь выдумки и вызовов, для которых не хватило данных.
Второй раз тот же сбой выглядит иначе: инструмент модель всё‑таки вызывает, но в параметрах «где искать» оказываются широта и долгота, которых никто не давал. Причина в контракте инструментов: «где» задаётся двумя числами — широтой и долготой. Пользователь их не дал, и формально модель могла бы искать без них, но тогда поиск получится не «рядом», а вообще неизвестно где. Поэтому она передаёт правдоподобные на вид числа (а уж их генерировать она умеет замечательно). Вызов при этом корректный, ошибки на уровне API нет — поэтому ответ выглядит обычным, просто про другую точку на карте.
Общее у этих двух случаев одно: модели нечем сказать «мне не дали данных», её банально научили, что говорить «не знаю» — это плохо, и она заполняет «пробелы» придуманными значениями. Этим, кстати, она очень напоминает троечников на теоретическом экзамене, когда точный ответ на вопрос неизвестен и начинается бесконечный поток знаний из смежных тем: авось и стрельнет во что‑то похожее.
Почему так происходит
У геоинструментов нет понятия «где пользователь». Нет аргумента «рядом со мной», нет сессии с геолокацией, нет способа переспросить. Есть только latitude / longitude — и обязательность этих полей не отличается от обязательности query.
Отсюда механика отказа: модели нужен центр поиска, центр поиска не задан, поле пустое → модель заполняет его правдоподобным числом. Она не «ломается» — она аккуратно додумывает. Ответ приходит валидный по форме и бессмысленный по содержанию — и это сильно хуже, чем если бы ответа не было совсем, потому что клиент не видит ошибки. И что ещё хуже — такую ошибку тяжело отловить разработчику.
Что такое якорь
Хороший промпт состоит из трёх частей (это же лежит в docs/examples.md):
якорь (адрес или ориентир);
фильтр (тип, рейтинг, количество);
действие (маршрут, сравнить, нарисовать).
«Найди три кофейни рядом с отелем Radisson Slavyanskaya и построй маршрут пешком» — есть все три части. «Найди кофейню рядом» — нет первой части, и дальше всё остальное неважно.
Как якорь выражен в контракте
Якоря два — регион и точка. Регион может использоваться как в запросе непосредственно, так и для пост‑валидации.
Регион в ответе. geocode_address возвращает до пяти кандидатов и всегда тянет поля, по которым кандидата можно выбрать обоснованно:
DEFAULT_FIELDS = ( "items.point", "items.address", "items.full_address_name", "items.adm_div", # город, район, регион — словами "items.region_id", # тот же регион числом )
Последние два поля — про принадлежность: они отличают одноимённую улицу в одном городе от такой же в другом. Без них модель могла рандомно взять первую же точку из выборки и выдать её за нужную.
Регион в запросе. Тот же регион можно задать в запросе — тогда неоднозначность снимается до геокодирования:
region_id: int | None = Field( default=None, ge=1, description="Идентификатор региона для снятия географической неоднозначности.", )
Точка. Longitude и latitude — центр поиска. Поля опциональные, но по отдельности бессмысленны: инструмент принимает их только парой, а radius (в метрах, до 40 000) — только вместе с точкой. Без точки реально работает только sort="relevance": distance формально передать можно, только сортировать будет не от чего, и где искать — решает API.
Пользователь центр поиска дать не может — он описывает место словами. Точку даёт geocode_address: на входе адрес словами, на выходе items.point — те же lon/lat, которые принимаются дальше.
Занимательные фейлы
История первая: как мы город в запросе игнорировали
Спрашиваем: «сколько ехать из Академгородка до дома 1 на проспекте Академика Королёва в Новосибирске». Модель честно вызывает geocode_address и передаёт адрес целиком — вместе со словом «Новосибирск». В ответе — Москва. Не ошибка, не повторный вопрос, а уверенный московский адрес.
Город был написан прямо в запросе, и это не помогло: якорем для геокодера считается region_id или точка, а не название города в тексте.
Как не надо: полагаться на то, что модель или геокодер «сами догадаются» про город, потому что он же написан.
Как надо: указать в запросе конкретный region_id — тогда уезжать некуда.
История вторая: маршрут между двумя фантазиями
Просим маршрут на машине от Шереметьево до 22-го километра Метрогородка, корпус 5, без платных дорог. Со стороны — замечательный запрос, в чем тут вообще можно ошибиться.
Инструмент build_route адреса не понимает: ему нужно от двух до десяти явных WGS84-точек, строку в схему не занести. Значит перед маршрутом обязан стоять геокодер — и вот он‑то нас и подводит. «22-й километр Метрогородка, 5» без города возвращает ровно один вариант: смотровую площадку на Метеогорке, 56.83 / 60.63. Это Екатеринбург, и тип объекта — смотровая площадка, а не дом. «Шереметьево» ведёт себя лучше, но не сильно: пять кандидатов, второй — примерно в 150 км южнее аэропорта.
Инструмент build_route в этой истории полностью здоров. Он построит подробный, уверенный, пошаговый маршрут из Шереметьево в Екатеринбург и не смутится ни на секунду.
Как не надо: передавать в маршрут то, что понятно человеку, и считать, что «до Москвы» — слишком очевидно, чтобы проверять.
Как надо: сначала geocode_address, назвать выбранного кандидата вслух, и только потом build_route.
История третья: «рядом» без «где» работает, но не так, как мы ожидали
Пользователь пишет: «Найди ближайшую кофейню». Слово «ближайшую» — это требование сортировать по расстоянию, и модель его честно выполняет: передаёт sort="distance". Вот только передать вместе с ним точку не от чего — пользователь не сказал, где он, а сам mcp‑сервер не может вернуться к пользователю и уточнить конкретную точку.
Инструмент такой вызов не отклоняет: distance лежит в списке допустимых сортировок, а проверки привязаны к radius, а не к сортировке.
Мы вызвали этот вызов на проде с теми же параметрами, которые передала бы модель — query="кофейня", types=["branch"], sort="distance", без широты и долготы. Ответ: «Найдено 5 из 44», и все пять — кофейни в Воркуте.
Бодрый день, кофейня — Воркута, улица Ленина, 38 (67.4958, 64.0590)
Coffee like, кофейня — Воркута, улица Ленина, 35/1 (67.4961, 64.0570)
Coffee like, кофейня — Воркута, улица Ленина, 53Б (67.5067, 64.0650)
Coffee Way, кофейня — Воркута, Центральная площадь, 5 (67.5025, 64.0601)
CoffeeBlack, кофейня — Воркута, Деповский переулок, 2 (67.5032, 64.0775)
Широта 67.5 — это за Полярным кругом. Каждая карточка с адресом, типом и id, всё аккуратно.
Тот же запрос с точкой у отеля Radisson Slavyanskaya и радиусом 800 м выглядит так:
Правда Кофе, экспресс‑кофейня — Москва, Краснопресненская наб., 14а к2
Elysian Coffee, кофейня — Москва, Краснопресненская наб., 14а к2
Азбука daily, кофейня — Москва, проспект Кутузовский, 18
Capital Coffee, кофейня — Москва, Краснопресненская наб., 12
Атмосфера, кофейня — Москва, Краснопресненская наб., 12
Столько же строк в ответе, но набор совсем другой. Формально ошибки не было ни в одном из вызовов. Модель получила валидный ответ и с чистой совестью посоветовала бы кофейни в Воркуте человеку, который стоит на улице 1905 года в Москве.
Как не надо: считать, что валидация поймает плохо заданное «где». Валидация смотрит на форму вызова, а не на смысл. Она поймает противоречие — широту без долготы, радиус без центра, радиус в 40 км без текстового запроса — потому что такие наборы параметров вообще ничего не значат. Но она не узнает, что точка выдуманная: по форме это обычная точка. А sort="distance" без точки всё ещё работает с точки зрения API.
Как надо: «рядом» существует только как «вокруг этой точки», поэтому точка должна появиться раньше, чем вообще встанет вопрос о сортировке. В реплике пользователя это ориентир словами — «найди три кофейни рядом с отелем Radisson Slavyanskaya»; в контракте это longitude/latitude с radius, которые даёт geocode_address. А на нашей стороне — закрыть distance без точки так же, как закрыт radius: параметр, который без другого бессмысленен, не должен быть опциональным по отдельности.
Как просят |
Что происходит |
Как надо |
«Найди кофейню рядом» |
Модель берёт широту и долготу «из головы»: у неё нет поля «спросить у пользователя», зато есть |
«Найди три кофейни рядом с отелем Radisson Slavyanskaya в Москве» — ориентир назван словами, геокодер превратит его в точку. |
«Построй маршрут до дома 5 в Метрогородке» |
|
«Сначала разреши оба адреса через геокодер, назови выбранный вариант, потом строй маршрут на машине без платных дорог». |
«Покажи парковки у Красной площади» |
Модель просит радиус без центральной точки. Каркас отклоняет вызов до API — лишний виток и задержка. |
«Покажи парковки в радиусе 1 км от Красной площади» — есть и точка, и радиус. |
А как надо‑то?!
Работа с инструментами может показаться довольно запутанной, сложной и полной нюансов, особенно для неподготовленного пользователя. И истории выше — вовсе не про то, что модель глупая (или уж тем более пользователи). Модель не умеет сказать «мне не дали данных» и не умеет догадаться про то, чего нет в схеме. Значит, уговаривать её быть аккуратной бесполезно — надо один раз проработать путь и отдавать его вместе с инструментами.
Для этого в MCP есть промпты: клиент делает prompts/list, потом prompts/get — и получает не красивую подсказку, а типизированный сценарий: аргументы с правилами и порядок шагов, который сервер сам отдаёт модели.
В MCP 2ГИС такой сценарий пока один — route_to_nearest_place. Это как раз пример правильного запроса из второй истории: найди ближайший объект нужного типа возле ориентира и построй туда маршрут.
Вход устроен так, что часть ошибок нельзя сделать
Transport = Literal["driving", "walking", "taxi", "bicycle", "scooter", "motorcycle", "truck", "emergency"] RouteMode = Literal["fastest", "shortest"] def route_to_nearest_place( origin_address: Annotated[str, Field(min_length=1, max_length=500, description="Полный исходный адрес, желательно с городом.")], landmark_query: Annotated[str, Field(min_length=1, max_length=500, description="Ориентир, возле которого нужно найти место, желательно с городом.")], transport: Annotated[Transport, Field(...)] = "driving", radius_m: Annotated[int, Field(ge=1, le=2_000)] = 2_000, route_mode: Annotated[RouteMode, Field(...)] = "fastest", require_public_access: Annotated[bool, Field(...)] = True, ) -> str: ...
transport — только из закрытого списка, radius_m — от 1 до 2000. Здесь мы закрываем сразу несколько проблемных историй из перечисленных выше: попросить «рядом» на сорок километров или выдумать несуществующий транспорт не получится, валидация на входе не даст этого сделать.
Семь шагов, на каждом из которых мы спотыкались сами
Шаг |
Что требует сценарий |
Какая история его породила |
1 |
|
первая: город в тексте ничего не гарантирует |
2 |
|
вторая: «терминал D» — это не «ст37» |
3 |
|
третья: «рядом» имеет смысл только от точки |
4–5 |
для парковок прочитать fields‑ресурс и повторить поиск; если нужен публичный доступ — исключить закрытые, а если доступа в данных нет — не придумывать его, а сказать о неопределённости |
вторая: уверенный ответ там, где данных нет |
6 |
выбрать ближайший из оставшихся и явно сказать, что близость измеряется от точки карточки ориентира |
тот же класс: скрытое допущение наружу |
7 |
|
вторая целиком: между выдуманными точками маршрут больше не строим |
Отдельным пунктом в конце сценария стоит «не подменяй подробный маршрут матрицей расстояний». На свободном пути модель очень охотно зовёт вместо build_route инструмент calculate_distance_matrix. Ответ выглядит похоже — «12 минут», а человек вместо списка поворотов получил оценку на глаз.
В итоге
Мы начали выносить тулы в MCP, когда поняли, что при нахождении бага или некорректного сценария его приходится фиксить в пяти местах. В ходе переноса выявился ещё один неожиданный побочный эффект: пока выносили и тестили на разных кейсах, отлавливали ещё пачку проблем, чинили и делали инструменты из MCP ещё лучше, чем у изначальных агентов. В итоге это привело к тому, что мы целенаправленно переводим агентов с персональных тулов на MCP‑тулы, потому что там они лучше протестированы, на них есть проработанные сценарии и более детальное описание.
Основные плюшки, которые мы с этого получили:
Контракт вместо договорённости. Параметры описаны схемой, допустимые значения — закрытыми списками, бессмысленные комбинации отклоняются до обращения к API. Персональный тул чаще всего принимает, что дали, и разбирается на стороне вызывающего кода.
Один контур ошибок вместо пяти копий. Машиночитаемые коды и предсказуемое поведение: бизнес‑ошибка — результат инструмента, сетевой сбой — ошибка протокола. Модель перестаёт гадать по строке «Ошибка: 403» и звать тул заново.
Наблюдаемость из коробки. Один
spanна инструмент, метрикиstarted/success/error. Вопрос «почему деградировало» больше не угадайка, хватает инструментов из коробки, чтобы разобрать причину проблемы.Одно исправление на всех. Нашли дырку — закрыли её один раз, и она закрылась у каждого, кто к этому MCP подключён.
Понятное дело, что сам факт выноса тула в MCP не делает его лучше. Если монолит распилить на кучку сильно‑связанных сервисов, то микросервисов не будет, будет распределённый монолит — так и тут: недостаточно просто вынести тул из агента в MCP, чтобы он стал хорош. А вот стандартизация, детальная проработка контрактов, сбор кейсов от разных потребителей и починка найденных в них ошибок — всё это действительно делает MCP‑тулы лучше.
David_Osipov
https://openstreetmap.caseyjhand.com/mcp