01Зачем это
Агент, подключённый к MCP-серверу Туту напрямую, платит две цены. Первая — размер:
tools/list отдаёт ~108 КБ (~25K токенов) ещё до того, как пользователь что-то
спросил. Вторая — доверие: агент отвечает связным текстом независимо от того, было ли значение
в ответе сервера или он его достроил по общему знанию.
Прокси закрывает обе, не меняя контракт: клиент видит те же имена инструментов и те же схемы аргументов.
initialize — 33,1 %. Воспроизводится: tutu.py measure.tool_result. Без LLM-судьи, детерминированно.tools/list до первого поиска−39 %. Не оценка: цифру вернул сам провайдер в usage на пробном запросе с полной поверхностью инструментов (gpt-5.6-luna, 19.08.2026, каталог из fixtures/). Прокси против прямого подключения к mcp.tutu.ru, воспроизводится: tutu.py evals.
gpt-5.6-luna; диапазон — разброс самой модели, бэкенд и сценарии одни и те же.tool_result. Без прокси — 97–98 %. В абсолюте разрыв заметнее: 1 выдуманное значение за прогон против 4.Что именно ужимается
Урезаются описания, а не схемы. Многословная проза про пограничные случаи переезжает в
результат вызова парного инструмента get_<domain>_instructions — то
есть оплачивается только той сессией, которая её реально читает. Тип поля, enum,
required, format и имена полей уходят клиенту байт в байт, это
закреплено отдельным тестом.
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 КБ
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
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 означает: спросить пользователя до дорогих поисков, а не после того, как таблица уже построена на догадке.
Premise gate: три способа разрешить блокировку
Если аргумент, сужающий поиск, не пришёл ни от пользователя, ни из прошлого
tool_result, вызов возвращает clarification_required вместо
данных. Это сделано намеренно: уверенную сравнительную таблицу невозможно построить на
фильтре, которого никто не называл.
- Спросить пользователя — предпочтительный путь.
- Объявить источник, если пользователь всё-таки называл значение:
_sources={"date": "user"}. - Объявить допущение:
_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Как это выглядит в работе
Полный цикл одного хода агента:
assess_requestс дословным запросом → список блокирующих параметров и расхождений.- Если вердикт
ask_user_first— задать вопрос до поисков. - Поиски (
search_rail,search_hotels, …) — как обычно. - Наткнулись на
clarification_required— разрешить одним из трёх способов выше. - Черновик ответа +
check_groundednessсо всеми сырымиtool_result. - Значения со статусом
unavailable— убрать или честно назвать отсутствующими; допущения вынести в первое предложение.
05Трейс-вьювер
trace-viewer.html — один самодостаточный файл: открывается двойным кликом, без сервера и без сети. Показывает запрос пользователя, ответ агента с подсветкой каждого утверждения по статусу, проверки сценария и все вызовы инструментов с аргументами и сырыми ответами.
Кликните любое подсвеченное значение — снизу выедет ящик с точным фрагментом ответа сервера, откуда значение взято. Или с прямой констатацией, что его нет ни в одном из них.
Три вещи, в которых интерфейс аккуратен
- Синтетические прогоны помечены. Агент
demo:илиscripted:получает янтарный бейдж НЕ ЗАМЕР в шапке — рукописную демонстрацию нельзя перепутать с измерением. - Пустая подсветка — не зачёт. Если в ответе нет типизированных утверждений, панель говорит об этом прямо, а не выглядит «чистой».
- Жёлтое — не зелёное. Объявленное допущение раскрыто, но не доказано, и в процент обоснованности не идёт.
Собрать локально:
make viewer-demo # из рукописных демо-трейсов — без модели и без ключа
make viewer # из последнего настоящего прогона эвалов
06Настройка
Все настройки — переменные окружения. Файл .env в корне репозитория читается при
импорте и никогда не перекрывает то, что уже экспортировано в шелле. Шаблон —
.env.example.
| Переменная | Для чего | По умолчанию |
|---|---|---|
TUTU_PROXY_MODE | mock (фикстуры, без сети) или live | mock |
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, вообще не увидел бы инструменты поиска. Это первое место, где прокси реально сломал бы совместимость.