Tutu MCP Proxy

Прокси перед mcp.tutu.ru: те же 16 инструментов, то же поведение, но всегда-загруженный каталог tools/list на 27,9 % меньше, а к нему добавлены два инструмента, которые не дают агенту сочинить цену, время или ссылку — и не дают ему искать по фильтру, который он выдумал сам.

01Зачем это

Агент, подключённый к MCP-серверу Туту напрямую, платит две цены. Первая — размер: tools/list отдаёт ~108 КБ (~25K токенов) ещё до того, как пользователь что-то спросил. Вторая — доверие: агент отвечает связным текстом независимо от того, было ли значение в ответе сервера или он его достроил по общему знанию.

Прокси закрывает обе, не меняя контракт: клиент видит те же имена инструментов и те же схемы аргументов.

Каталог
−27,9 %
110 164 → 79 411 байт, и это после добавления двух собственных инструментов. С учётом блока initialize — 33,1 %. Воспроизводится: tutu.py measure.
Выход агента
grounding
Каждая цена, время, номер поезда/рейса и ссылка сверяется с сырым tool_result. Без LLM-судьи, детерминированно.
Вход агента
premise gate
Значение, сужающее поиск, обязано прийти от пользователя или из прошлого ответа сервера. Третьего источника нет.
Токены на tools/list до первого поиска
Прокси 15 364 токена
Без прокси 25 269 токенов

−39 %. Не оценка: цифру вернул сам провайдер в usage на пробном запросе с полной поверхностью инструментов (gpt-5.6-luna, 19.08.2026, каталог из fixtures/). Прокси против прямого подключения к mcp.tutu.ru, воспроизводится: tutu.py evals.

Задачный успех
19–21/22
Против 17–18 из 22 без прокси. Четыре прогона по 22 сценария, gpt-5.6-luna; диапазон — разброс самой модели, бэкенд и сценарии одни и те же.
Обоснованность
99 %
Доля утверждений в ответе, которые нашлись в сыром tool_result. Без прокси — 97–98 %. В абсолюте разрыв заметнее: 1 выдуманное значение за прогон против 4.
Лишних вопросов
0
Гейт вмешался 8–12 раз за прогон и ни разу не переспросил там, где всё было определено. Механизм, уточняющий всё подряд, набрал бы идеальные метрики и испортил бы продукт.

Что именно ужимается

Урезаются описания, а не схемы. Многословная проза про пограничные случаи переезжает в результат вызова парного инструмента get_<domain>_instructions — то есть оплачивается только той сессией, которая её реально читает. Тип поля, enum, required, format и имена полей уходят клиенту байт в байт, это закреплено отдельным тестом.

До · 9 661 байт
Search Russian Railways (РЖД) tickets between two cities. Returns the real departure & arrival station names per offer (useful when a city has several stations — Москва has Курский / Ленинградский / Казанский). Each offer carries: `price`, a `fares` summary in the default `compact` view (`{count, price_from, price_to, currency, … ещё ~9,3 КБ
После · 1 140 байт
Search Russian Railways (РЖД) tickets between two cities. Each offer carries `price`, a `fares` summary (`{count, price_from, price_to, refundable_count, seat_categories, uncategorized_fares?}` — a count is a LOWER bound: `refundable_count: 0` means 'none confirmed refundable', not 'no refundable fare exists', and a `seat_categories` entry missing for сидячий/плацкарт/купе/СВ means not on sale ONLY when `uncategorized_fares` is absent), `legs[].segments[]` with carrier + train number + `vehicle_meta`, `search_results_url`, `checkout_url`, `checkout_ref`, and `details_ref` (→ `get_offer_details` for the full per-class ladder, → `get_rail_seatmap` for exact seats). Paginated: a page is a WINDOW over matched trains (`meta.total_matched`, `meta.has_more`) — never say a numbered train doesn't run from one page while `has_more` is true; pass `train_numbers` instead of paging blind. An empty `offers` with `meta.interchange_routes` populated means no DIRECT train that day; transfer plans are shown instead. Full field semantics, filter-interaction caveats and grounding rules: `get_rail_instructions`.

Настоящий top-level description инструмента search_rail — из записанной фикстуры tools/list и tutu_mcp/proxy/compact_tools.py, не сокращённый пересказ.

Практический смысл: подключение прокси не требует правок в промптах клиента и не ломает существующие вызовы. Ужимание невидимо для кода — видно только по счётчику токенов.

02Быстрый старт

Прокси говорит по Streamable HTTP без авторизации — ровно как upstream. Ключи OpenAI нужны только для прогона эвалов; самому серверу они не нужны.

Шаг 1 — адрес прокси

Ниже подставлен адрес развёрнутого экземпляра. Если вы поднимаете прокси у себя (шаг 3), замените его на http://127.0.0.1:8800/mcp.

http://127.0.0.1:8800/mcp

Шаг 2 — прописать его в клиенте

Claude Code

claude mcp add --transport http tutu http://127.0.0.1:8800/mcp

Cursor · ~/.cursor/mcp.json

{
  "mcpServers": {
    "tutu": { "url": "http://127.0.0.1:8800/mcp" }
  }
}

Claude Desktop · claude_desktop_config.json

{
  "mcpServers": {
    "tutu": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "http://127.0.0.1:8800/mcp"]
    }
  }
}

Шаг 3 — запустить у себя (опционально)

git clone https://github.com/Trum-ok/tutu-mcp-hackathon
cd tutu-mcp-hackathon
uv sync
uv run python tutu.py serve           # http://127.0.0.1:8800/mcp

По умолчанию поднимается mock-режим: ответы берутся из записанных фикстур, сеть не трогается вовсе. Это безопасно гонять как угодно часто — общий рейт-лимит хакатона не расходуется. Чтобы проксировать настоящий Туту:

TUTU_PROXY_MODE=live uv run python tutu.py serve
Проверка, что всё поднялось: в логе появятся две строки — режим и адрес. Клиент после подключения должен показать 18 инструментов: 16 родных Туту плюс assess_request и check_groundedness.

03Инструменты

16 инструментов Туту проходят насквозь — те же имена, те же аргументы, те же результаты. Добавлены два своих. Оба локальные: не ходят в сеть, не тратят рейт-лимит, отвечают мгновенно.

ИнструментКогда вызыватьЧто делает
assess_request Первым, до любого поиска Говорит, каких параметров не хватает и что в самом запросе противоречиво
check_groundedness Последним, до отправки ответа Сверяет черновик ответа с сырыми tool_result

assess_request — проверка на входе

Передайте запрос пользователя дословно и вызовы, которые собираетесь сделать:

{
  "user_request": "Хочу в субботу 11 октября съездить из Москвы в Питер и вернуться вечером",
  "planned_calls": [
    { "tool": "search_rail",
      "arguments": { "origin": "Москва", "destination": "Санкт-Петербург", "departure_date": "2026-10-11" } }
  ]
}

В ответ приходит: какие параметры блокирующие, какие — безопасные умолчания, и есть ли противоречие внутри самого запроса. Вердикт ask_user_first означает: спросить пользователя до дорогих поисков, а не после того, как таблица уже построена на догадке.

Проверка календаря. «в субботу 11 октября», когда 11.10.2026 — воскресенье, помечается как вероятная опечатка. Прокси показывает расхождение, но никогда не исправляет его молча: выбор между датой и днём недели принадлежит пользователю.

Premise gate: три способа разрешить блокировку

Если аргумент, сужающий поиск, не пришёл ни от пользователя, ни из прошлого tool_result, вызов возвращает clarification_required вместо данных. Это сделано намеренно: уверенную сравнительную таблицу невозможно построить на фильтре, которого никто не называл.

  1. Спросить пользователя — предпочтительный путь.
  2. Объявить источник, если пользователь всё-таки называл значение: _sources={"date": "user"}.
  3. Объявить допущение: _assume={"time_to": "концерт обычно заканчивается к 22:00"}. Тогда результат несёт преамбулу, с которой ответ обязан начинаться.

Оба поля вырезаются до того, как вызов уйдёт в Туту, — upstream-схемы не меняются.

check_groundedness — проверка на выходе

{
  "answer_text": "Самый дешёвый — «Ласточка» 727А в 07:00, 1 150 ₽ ..."
}

Из текста ответа извлекаются цены, времена, номера поездов и рейсов, URL — и каждое значение проверяется на фактическое присутствие в данных. Никакой второй модели-судьи: результат воспроизводим и его можно предъявить.

Доказательства берутся у прокси, а не у агента. Через прокси прошёл каждый tool_result этой сессии, поэтому пересылать их обратно не нужно — раньше это стоило агенту тысяч токенов на копирование и обрывалось на середине большого ответа. Заодно закрыта дыра: набор доказательств больше не выбирает тот, кого проверяют. Поле tool_result_json осталось необязательным — на случай данных, которые через прокси не проходили.
СтатусЗначит
confirmedЗначение найдено в ответе сервера
assumedПришло из объявленного допущения — раскрыто, но не доказано
unavailableВ данных его нет. Это то, что иначе ушло бы пользователю как факт
user_statedУсловие назвал сам пользователь — ответ его цитирует. Не доказательство, но и не выдумка, поэтому отдельно от unavailable
Допущения агент не может спрятать. Список допущений check_groundedness берёт из состояния сессии, а не из аргументов вызова: агент, способный их не передать, мог бы скрыть ровно то, что проверка создана вскрывать. Ответ, раскрывший допущение только в конце или не раскрывший вовсе, проверку не проходит.

04Как это выглядит в работе

Полный цикл одного хода агента:

  1. assess_request с дословным запросом → список блокирующих параметров и расхождений.
  2. Если вердикт ask_user_first — задать вопрос до поисков.
  3. Поиски (search_rail, search_hotels, …) — как обычно.
  4. Наткнулись на clarification_required — разрешить одним из трёх способов выше.
  5. Черновик ответа + check_groundedness со всеми сырыми tool_result.
  6. Значения со статусом unavailable — убрать или честно назвать отсутствующими; допущения вынести в первое предложение.
Кейс, ради которого всё это. Пустой отфильтрованный результат означает «нет в продаже на этот фильтр», а не «поезд не ходит». Разница между этими двумя фразами — разница между корректным ответом и потерянной поездкой; в наборе сценариев она проверяется явно.

05Трейс-вьювер

trace-viewer.html — один самодостаточный файл: открывается двойным кликом, без сервера и без сети. Показывает запрос пользователя, ответ агента с подсветкой каждого утверждения по статусу, проверки сценария и все вызовы инструментов с аргументами и сырыми ответами.

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

Три вещи, в которых интерфейс аккуратен

  • Синтетические прогоны помечены. Агент demo: или scripted: получает янтарный бейдж НЕ ЗАМЕР в шапке — рукописную демонстрацию нельзя перепутать с измерением.
  • Пустая подсветка — не зачёт. Если в ответе нет типизированных утверждений, панель говорит об этом прямо, а не выглядит «чистой».
  • Жёлтое — не зелёное. Объявленное допущение раскрыто, но не доказано, и в процент обоснованности не идёт.

Собрать локально:

make viewer-demo   # из рукописных демо-трейсов — без модели и без ключа
make viewer        # из последнего настоящего прогона эвалов

06Настройка

Все настройки — переменные окружения. Файл .env в корне репозитория читается при импорте и никогда не перекрывает то, что уже экспортировано в шелле. Шаблон — .env.example.

ПеременнаяДля чегоПо умолчанию
TUTU_PROXY_MODEmock (фикстуры, без сети) или livemock
TUTU_UPSTREAM_URLАдрес upstream MCP-сервераhttps://mcp.tutu.ru/mcp
TUTU_UPSTREAM_TIMEOUT_SТаймаут одного запроса к живому mcp.tutu.ru, секунды20
TUTU_FIXTURES_DIRГде лежат записанные фикстуры./fixtures
TUTU_CATALOG_TTL_SКак долго закешированный tools/list считается свежим, секунды900
TUTU_PROXY_HOST / TUTU_PROXY_PORTАдрес прослушивания127.0.0.1 / 8800
PORTФолбэк для PaaS (Render/Railway/Fly/…); TUTU_PROXY_PORT в приоритетезадаёт платформа
OPENAI_API_KEYТолько эвалы — агент под тестом и точный подсчёт токенов
OPENAI_MODELМодель по умолчанию для эваловgpt-5

Команды

Всё в репозитории запускается одной точкой входа — uv run python tutu.py --help. Короткие обёртки лежат в Makefile:

make run-mock      # прокси на фикстурах
make run-live      # прокси на настоящий mcp.tutu.ru
make test          # весь pytest, целиком на фикстурах, без сети
make evals         # baseline против proxy (нужен OPENAI_API_KEY)
make evals-dry     # самопроверка харнесса на рукописных планах, без ключей
make fixtures      # перезаписать фикстуры с живого сервера
make viewer-demo   # trace-viewer.html из демо-трейсов
make docs          # эта страница, site/index.html

07Вопросы и ошибки

FixtureNotFoundError — что это и что делать

Mock-режим ищет фикстуру по имени инструмента и точным (нормализованным) аргументам. Вызов с незаписанными аргументами даёт явную ошибку с перечнем доступных сценариев — а не тихий неправильный ответ. Это осознанный выбор: молчаливая подмена ответа в системе, которая борется с выдуманными данными, была бы самопротиворечием.

Заполнить пробел: make evals-record (один живой проход, дописывает недостающее) или make fixtures.

Промахи фикстур считаются отдельно от ошибок инструментов — дыра в нашей записи не должна читаться как сбой Туту.

Вызов вернул clarification_required вместо данных

Это не сбой, а premise gate. Значение, сужающее поиск, не имеет источника. Три выхода описаны в разделе assess_request: спросить пользователя, объявить _sources или объявить _assume.

Агент стал слишком часто переспрашивать

Вызывайте assess_request первым и передавайте запрос дословно: значения, которые пользователь реально написал, проходят гейт без лишнего круга. Отдельная метрика в эвалах считает уточняющие вопросы на сценариях, где уточнять было нечего, — это защита от превращения ассистента в анкету.

Нужен ли ключ OpenAI, чтобы просто попробовать прокси

Нет. Сервер, тесты и трейс-вьювер работают без каких-либо ключей. Ключ нужен только эвал-харнессу, потому что это единственная часть, которая запускает модель.

Числа токенов помечены знаком «~»

Значит, они оценены офлайн через tiktoken, а не получены от провайдера. Точная цифра берётся из одного реального пробного запроса на вариант с чтением usage.prompt_tokens — только так учитывается собственная сериализация инструментов провайдером. Оценка печатается с «~», чтобы не выглядеть точным числом, которое нечем подтвердить.

08Границы честности

Что здесь не сделано — чтобы это не пришлось выяснять на демо:

  • Ручные компактные описания есть у 3 из 16 инструментовsearch_rail, get_rail_seatmap, search_hotels: самые крупные и те, что названы в исходной статье. Механизм общий, покрытие — нет.
  • create_checkout_link намеренно не тронут. Это диспетчер покупки, и у него нет парного instructions-инструмента, куда переложить прозу.
  • Плата за сжатие названа. get_rail_instructions растёт с 26,8 КБ до 50,3 КБ — её платит только та сессия, которая его вызвала, вместо всех сессий до первого поиска.
  • Нет фикстуры на 429. Спровоцировать её означало бы сжечь общий рейт-лимит хакатона всем остальным командам.
  • Отложенная выдача инструментов (find_tools) измерена, но не включена — она дала бы 89 % экономии, но клиент, игнорирующий notifications/tools/list_changed, вообще не увидел бы инструменты поиска. Это первое место, где прокси реально сломал бы совместимость.