Мы выпустили MCP‑сервер, который даёт ИИ‑агентам прямой доступ к геосервисам API‑платформы 2ГИС: поиск мест и организаций, прямое и обратное геокодирование, построение маршрутов, изохроны и статические карты — сейчас это 6 инструментов поверх соответствующих API. 

Что это за сервер и как подключить — в документации. А в этой статье расскажу про инженерную часть: как он устроен внутри и на какие грабли мы наступили, пока доводили его до прода.

Статья будет полезна разработчикам, которые пишут свой MCP‑сервер или тулы под function calling и которые проектирует контракты инструментов для LLM, и просто всем, кому интересно, что ломается на стыке LLM и гео.

Особенности реализации

Без подключённых геоинструментов ассистент на вопрос про маршрут или ближайшую организацию отвечает уверенно и полностью выдумано — взять данные ему просто неоткуда. Казалось бы, дальше всё просто: дай модели доступ к геоданным, и проблема закрыта. На практике выяснилось, что доступа мало: получив инструменты, модель начинает врать иначе — и это куда труднее заметить.

Дальше разберём:

  1. Как устроен каркас сервера — чтобы добавление каждого нового инструмента не превращалось в копипаст обработки ошибок, логирования и http‑пулов.

  2. Почему модель уверенно выдумывает адреса и координаты — и как мы это ловили на реальных вызовах.

  3. Как мы закрываем это контрактом — валидацией и типизированными сценариями, а не уговорами в системном промпте.

Терминология, которую дальше будем использовать: 

Инструмент (тул) — функция, которую модель может вызвать. У неё есть имя, описание и параметры с типами. Код не исполняет сама модель — она формирует вызов с аргументами, исполняет клиент, а результат возвращается модели в контекст.

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, потому что тут нет смысла рассуждать — есть смысл повторить.

Добавление нового инструмента

Таким образом, для добавления нового инструмента нужно сделать:

  1. Pydantic‑модели входа/выхода в models/<domain>.py;

  2. Узкий клиент в integrations/<domain>_api.py, который возвращает доменную модель, а не httpx.Response;

  3. @mcp.tool в tools/<domain>.py с русским description, annotations (readOnlyHint, idempotentHint) и только бизнес‑параметрами;

  4. Вызов run_gis_tool и build_tool_result в callback — ключ, http_client, Settings и URL в параметры инструмента не передаются никогда;

  5. Регистрация в tools/__init__.py, контракт в mcp_tools.json и mcp-server-catalog.yaml, ALLOWED_TOOLS;

  6. unit + in‑process integration тесты.

Естественный путь (тул сам всё делает)

Что даёт каркас

httpx.AsyncClient на каждый инструмент и часто на каждый вызов

один пул на процесс через FastMCP lifespan, GISAPIClient — лёгкая request‑scoped обёртка

ключ читается из os.environ внутри тула

GISAPIClient — единственное место, где раскрывается SecretStr; в логах только api_key_ref и request_id

try/except по месту: где‑то проглотили, где‑то отдали тело upstream‑ошибки клиенту

одна граница ошибок на все инструменты, upstream body наружу не уходит

ошибка — строка Ошибка: 403, модель зовёт тул заново

стабильные MCP_API_KEY_REQUIRED / MCP_API_KEY_INVALID / MCP_2GIS_API_UNAVAILABLE / tool_forbidden + ссылка, куда идти

недокументированный sort=coolness улетает в API и возвращается чем-то непонятным

закрытые списки SORT_VALUES, GEOCODER_TYPES, ADDITIONAL_PARAM_NAMES: неизвестное значение отклоняется до запроса с InvalidParamsError

«почему тул деградировал» — гадание

один span с именем инструмента + метрики started/success/error из middleware

контекст‑ключ живёт дольше запроса

AccessContext — contextvar на вызов, ключ не течёт между запросами

Якоря: как без них модель дрейфует в другой город

Одна из первых проблем с подвохом, с которой мы столкнулись на поддержке ещё 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», и все пять — кофейни в Воркуте.

  1. Бодрый день, кофейня — Воркута, улица Ленина, 38 (67.4958, 64.0590)

  2. Coffee like, кофейня — Воркута, улица Ленина, 35/1 (67.4961, 64.0570)

  3. Coffee like, кофейня — Воркута, улица Ленина, 53Б (67.5067, 64.0650)

  4. Coffee Way, кофейня — Воркута, Центральная площадь, 5 (67.5025, 64.0601)

  5. CoffeeBlack, кофейня — Воркута, Деповский переулок, 2 (67.5032, 64.0775)

Широта 67.5 — это за Полярным кругом. Каждая карточка с адресом, типом и id, всё аккуратно.

Тот же запрос с точкой у отеля Radisson Slavyanskaya и радиусом 800 м выглядит так:

  1. Правда Кофе, экспресс‑кофейня — Москва, Краснопресненская наб., 14а к2

  2. Elysian Coffee, кофейня — Москва, Краснопресненская наб., 14а к2

  3. Азбука daily, кофейня — Москва, проспект Кутузовский, 18

  4. Capital Coffee, кофейня — Москва, Краснопресненская наб., 12

  5. Атмосфера, кофейня — Москва, Краснопресненская наб., 12

Столько же строк в ответе, но набор совсем другой. Формально ошибки не было ни в одном из вызовов. Модель получила валидный ответ и с чистой совестью посоветовала бы кофейни в Воркуте человеку, который стоит на улице 1905 года в Москве.

Как не надо: считать, что валидация поймает плохо заданное «где». Валидация смотрит на форму вызова, а не на смысл. Она поймает противоречие — широту без долготы, радиус без центра, радиус в 40 км без текстового запроса — потому что такие наборы параметров вообще ничего не значат. Но она не узнает, что точка выдуманная: по форме это обычная точка. А sort="distance" без точки всё ещё работает с точки зрения API.

Как надо: «рядом» существует только как «вокруг этой точки», поэтому точка должна появиться раньше, чем вообще встанет вопрос о сортировке. В реплике пользователя это ориентир словами — «найди три кофейни рядом с отелем Radisson Slavyanskaya»; в контракте это longitude/latitude с radius, которые даёт geocode_address. А на нашей стороне — закрыть distance без точки так же, как закрыт radius: параметр, который без другого бессмысленен, не должен быть опциональным по отдельности.

Как просят

Что происходит

Как надо

«Найди кофейню рядом»

Модель берёт широту и долготу «из головы»: у неё нет поля «спросить у пользователя», зато есть latitude и longitude, и она их заполняет. Ответ будет про кофейни в случайной точке.

«Найди три кофейни рядом с отелем Radisson Slavyanskaya в Москве» — ориентир назван словами, геокодер превратит его в точку.

«Построй маршрут до дома 5 в Метрогородке»

build_route адреса не разбирает. Модель подставит в точки координаты на глаз — маршрут будет между двумя выдуманными местами.

«Сначала разреши оба адреса через геокодер, назови выбранный вариант, потом строй маршрут на машине без платных дорог».

«Покажи парковки у Красной площади»

Модель просит радиус без центральной точки. Каркас отклоняет вызов до 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

geocode_address для исходного адреса; проверить город, полный адрес и тип кандидата; при нескольких кандидатах выбрать точное совпадение

первая: город в тексте ничего не гарантирует

2

search_places по ориентиру; выбрать сам ориентир, а не информационную точку и не одноимённый объект

вторая: «терминал D» — это не «ст37»

3

search_places вокруг WGS84-точки ориентира с types, radius и sort="distance"; взять несколько кандидатов, чтобы выбор можно было проверить

третья: «рядом» имеет смысл только от точки

4–5

для парковок прочитать fields‑ресурс и повторить поиск; если нужен публичный доступ — исключить закрытые, а если доступа в данных нет — не придумывать его, а сказать о неопределённости

вторая: уверенный ответ там, где данных нет

6

выбрать ближайший из оставшихся и явно сказать, что близость измеряется от точки карточки ориентира

тот же класс: скрытое допущение наружу

7

build_route с двумя явными точками — из шага 1 и шага 6

вторая целиком: между выдуманными точками маршрут больше не строим

Отдельным пунктом в конце сценария стоит «не подменяй подробный маршрут матрицей расстояний». На свободном пути модель очень охотно зовёт вместо build_route инструмент calculate_distance_matrix. Ответ выглядит похоже — «12 минут», а человек вместо списка поворотов получил оценку на глаз.

В итоге

Мы начали выносить тулы в MCP, когда поняли, что при нахождении бага или некорректного сценария его приходится фиксить в пяти местах. В ходе переноса выявился ещё один неожиданный побочный эффект: пока выносили и тестили на разных кейсах, отлавливали ещё пачку проблем, чинили и делали инструменты из MCP ещё лучше, чем у изначальных агентов. В итоге это привело к тому, что мы целенаправленно переводим агентов с персональных тулов на MCP‑тулы, потому что там они лучше протестированы, на них есть проработанные сценарии и более детальное описание.

Основные плюшки, которые мы с этого получили:

  • Контракт вместо договорённости. Параметры описаны схемой, допустимые значения — закрытыми списками, бессмысленные комбинации отклоняются до обращения к API. Персональный тул чаще всего принимает, что дали, и разбирается на стороне вызывающего кода.

  • Один контур ошибок вместо пяти копий. Машиночитаемые коды и предсказуемое поведение: бизнес‑ошибка — результат инструмента, сетевой сбой — ошибка протокола. Модель перестаёт гадать по строке «Ошибка: 403» и звать тул заново.

  • Наблюдаемость из коробки. Один span на инструмент, метрики started/success/error. Вопрос «почему деградировало» больше не угадайка, хватает инструментов из коробки, чтобы разобрать причину проблемы.

  • Одно исправление на всех. Нашли дырку — закрыли её один раз, и она закрылась у каждого, кто к этому MCP подключён.

Понятное дело, что сам факт выноса тула в MCP не делает его лучше. Если монолит распилить на кучку сильно‑связанных сервисов, то микросервисов не будет, будет распределённый монолит — так и тут: недостаточно просто вынести тул из агента в MCP, чтобы он стал хорош. А вот стандартизация, детальная проработка контрактов, сбор кейсов от разных потребителей и починка найденных в них ошибок — всё это действительно делает MCP‑тулы лучше. 

Комментарии (1)