Model Context Protocol (MCP) сервер для интеграции с BPMS Pyrus.
Позволяет любому MCP-совместимому клиенту (Claude Desktop, Cursor и др.) читать и изменять данные в Pyrus: задачи, формы, каталоги, пользователи, роли, объявления и базу знаний.
git clone <repo-url>
cd pyrus_mcp
pip install -r requirements.txt
# или
pip install -e .Создайте .env или экспортируйте переменные:
export PYRUS_LOGIN="your-email@pyrus.com"
export PYRUS_SECURITY_KEY="your-secret-key"
# или, если у вас уже есть токен:
# export PYRUS_ACCESS_TOKEN="..."Ключ безопасности (security key) берётся в Pyrus: Настройки → Авторизация → Секретный API ключ.
Реквизиты Pyrus сервер берёт из заголовков запроса — своего аккаунта у него нет:
X-Pyrus-Login + X-Pyrus-Security-Key # пара, одного логина мало
X-Pyrus-Access-Token # либо готовый токен
Без них любой вызов вернёт
no_credentials — включая самые лёгкие. По этому признаку
ошибку легко спутать с перегрузкой на больших данных, поэтому проверяйте
заголовки первым делом.
Добавьте в claude_desktop_config.json:
macOS:
~/Library/Application Support/Claude/claude_desktop_config.json
Windows:
%APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"pyrus": {
"command": "python",
"args": ["-m", "pyrus_mcp"],
"env": {
"PYRUS_LOGIN": "your-email@pyrus.com",
"PYRUS_SECURITY_KEY": "your-secret-key"
}
}
}
}Если используете виртуальное окружение, укажите полный путь до Python:
{
"mcpServers": {
"pyrus": {
"command": "/path/to/venv/bin/python",
"args": ["-m", "pyrus_mcp"],
"env": {
"PYRUS_LOGIN": "your-email@pyrus.com",
"PYRUS_SECURITY_KEY": "your-secret-key"
}
}
}
}В настройках Cursor → MCP Servers добавьте:
{
"mcpServers": {
"pyrus": {
"command": "python",
"args": ["-m", "pyrus_mcp"],
"env": {
"PYRUS_LOGIN": "your-email@pyrus.com",
"PYRUS_SECURITY_KEY": "your-secret-key"
}
}
}
}Каждый инструмент принимает два необязательных параметра:
| Параметр | Назначение |
|---|---|
access_token |
выполнить вызов от имени этого токена |
api_url |
контур клиента, например https://api.pyrus.com/v4 |
Нужны боту, который обслуживает организации-клиентов: токен и
api_url приходят в каждом вебхуке, а заголовки MCP
фиксированы на всё соединение и для этого не годятся. Если параметры не
переданы — работают реквизиты соединения (заголовки
X-Pyrus-*), как и раньше.
{"task_id": 372965117,
"access_token": "<токен из вебхука>",
"api_url": "https://api.pyrus.com/v4"}Override действует только на свой вызов и не переносится на следующий, поэтому в одной сессии можно работать с разными клиентами вперемешку.
| Инструмент | Описание |
|---|---|
get_task |
Получить задачу по ID со всеми комментариями |
create_task |
Создать задачу (простую или по форме) |
comment_task |
Добавить комментарий к задаче, согласовать, назначить, изменить поля |
| Инструмент | Описание |
|---|---|
close_task |
Закрыть задачу (action=finished) |
reopen_task |
Открыть ранее закрытую задачу |
assign_task |
Переназначить задачу на другого человека |
add_approvers |
Добавить согласующих к задаче |
update_task_fields |
Обновить поля формы задачи |
add_subscribers |
Добавить подписчиков к задаче |
| — | Все пишущие инструменты принимают brief=true: в ответ
приходит task_id, id нового комментария и состояние
(is_closed, current_step,
list_ids) вместо задачи целиком. Pyrus отвечает на любую
запись всей задачей — 18 КБ на вызов, замерено |
delete_task |
Удалить задачу безвозвратно (в отличие от
close_task) |
search_tasks |
Поиск задач в реестре по диапазону дат. Умеет то же, что
get_registry: field_ids, format,
обход через page_size + cursor — он ей же и
делегирован |
find_tasks |
Сквозной поиск по всему аккаунту — по автору,
ответственному, участнику, тексту, поверх всех форм и свободных задач.
Единственный способ ответить «какие задачи я поставил другим»
(author="me"): в API v4 списка задач нет вовсе. Обход
выборки целиком — page_size + cursor: своей
пагинации у Pyrus нет, её делает сервер. Единственный инструмент,
ходящий в API v3 — см. «Почему один инструмент ходит в v3» |
get_overdue_tasks |
Задачи, у которых срок уже прошёл. Берёт сроки из
календаря: в реестре их нет вовсе. form_id —
фильтр, а не требование |
get_tasks_due_soon |
Задачи со сроком в ближайшие N дней, тоже из календаря. Возвращает
{window, counts, tasks} с компактными записями |
| Инструмент | Описание |
|---|---|
get_forms |
Получить все шаблоны форм |
get_form |
Получить конкретный шаблон формы. summary,
field_ids, flatten |
get_registry |
Получить реестр задач по форме. Фильтры, format="csv",
field_ids, обход через page_size +
cursor |
get_form_permissions |
Кто имеет доступ к форме и на каком уровне |
change_form_permissions |
Выдать или отозвать доступ к форме |
get_form_export |
Полная веб-конфигурация формы: 36 разделов против 7 у
get_form — маршрут с условиями, права, SLA, статусы,
эскалации, скрипт формы, справочники, печатные формы. По умолчанию
нормализована (справочники — счётчиком, условия — плоским списком рядом
с деревом); sections=[...] сужает, max_bytes
не даёт результату не поместиться — см.
read_guide("экспорт-формы") |
normalize_form_export |
Тот же снапшот с другими флагами нормализации, без повторного скачивания — использует тот же 15-минутный кэш |
get_form_config |
Конфигурация формы, разбитая по именованным секциям: meta, fields, steps, register, workflow, statuses, sla, access, external, scripts, escalations, helpdesk, print_forms — легче полного экспорта |
get_form_fields |
Плоский список полей с путём в таблице, шагами редактирования/обязательности и сводкой видимости (какие поля и роли гейтят каждое поле) |
get_form_register_view |
Порядок полей в реестре, флаг скрытия, XLSX-ссылка регистра и обнаружение сирот |
resolve_form_refs |
Преобразовать id в имена для ролей, сотрудников, справочников, связанных форм, статусов и полей, с явным списком того, что не удалось разрешить |
audit_form |
Детерминированный аудит формы без ИИ: 31 код находок по гигиене
полей, правам, процессу/SLA/эскалациям, скриптам и справочникам — один и
тот же экспорт всегда даёт одни и те же находки.
checks=[...] сужает, severity_floor отсекает
по уровню важности |
score_form_config |
Здоровье автоматизации формы одним числом 0-3 плюс 8 подоценок по
категориям — те же находки audit_form, но посчитанные.
Детерминированно и воспроизводимо |
get_form_workflow |
Маршрут формы: именованные шаги со счётчиком условий, ролями, SLA (наибольшее из нескольких политик шага, в часах), флагом активности календаря и ссылкой на подпроцесс — плюс наблюдатели и последовательный граф для рендеринга |
render_form_workflow |
Диаграмма маршрута — format="mermaid" (текст),
"svg" или "html", автономно: ни CDN, ни
сторонних библиотек. Пустые шаги выделены визуально, условия и SLA — на
рёбрах |
get_form_access_matrix |
Кто видит форму: person_id по уровням доступа (2/3/4), записи без уровня/без person_id, все внешние пользователи, все роли из маршрута, и расхождения — роль в маршруте без единого участника с доступом |
diff_form_exports |
Структурный дифф между текущим экспортом формы и другим
(against_form_id — другая форма,
against_raw_export — сохранённый JSON) — не замечает дрейф
содержимого справочников, только структурные изменения |
get_lists |
Списки плоским обходом: summary (сколько всего, сколько
наверху, глубина), page_size + cursor,
parent_id у каждого узла. На измеренном контуре дерево —
3177 узлов и 728 676 Б, целиком не отдаётся |
get_list |
Получить конкретный список |
create_list |
Создать список (имя, родитель, цвет) |
update_list |
Изменить список (имя, родитель, цвет) |
delete_list |
Удалить список (задачи не удаляются) |
get_task_list |
Получить задачи из списка |
get_inbox |
Получить входящие задачи |
get_meetings |
Встречи календаря за окно (days_back,
days_ahead). Читает /v4/calendar с
include_meetings=true. Раньше читал Входящие и
потому всегда возвращал ноль — ключа meetings в
ответе /v4/inbox нет вовсе; исправлено 20.09.2026 |
score_inbox |
Оценить Входящие, календарь и встречи по личной модели весов и вернуть таблицу приоритетов. Сырьё наружу не выходит: 60 элементов — 23 КБ против 676 КБ задач целиком |
Ответ тула целиком уходит в контекстное окно модели, поэтому всё, что
больше MCP_MAX_RESPONSE_BYTES (по умолчанию 200 KB), не
возвращается вовсе: тул отвечает response_too_large и
подсказывает, что делать. Данные никогда не обрезаются молча — иначе
модель уверенно считала бы итоги по половине реестра.
Потолков два, и они про разное:
| Переменная | Что бережёт | Когда срабатывает | По умолчанию |
|---|---|---|---|
MCP_MAX_RESPONSE_BYTES |
контекст модели | после сериализации ответа | 200 KB |
PYRUS_MAX_DOWNLOAD_BYTES |
память процесса | во время скачивания от Pyrus | 16 MB |
Второй появился после того, как сервер убило нехваткой памяти: первый
проверяется уже после того, как тело загружено и
разобрано в объекты Python, и от переполнения не защищал. Замерено, что
JSON распухает в объектах Python в 4,7 раза, поэтому скачивание
обрывается потоком, а при известном Content-Length — до
первого байта тела. Отказ приходит как
download_too_large.
Сначала решите, нужны ли вам все данные — от этого зависит приём.
Нужны все — обходите порциями.
page_size задаёт размер порции, дальше возвращайте
полученный next_cursor, пока он приходит. Работает при
любом объёме и ничего не теряет. Есть у get_registry и
get_catalog.
get_registry(form_id=2363325, include_archived=True, page_size=150)
→ 150 задач + next_cursor
get_registry(..., page_size=150, cursor="…")
→ 149 задач + next_cursor # одна отсеяна как повтор границы окна
→ …порция без курсора = конец
Размер порции подбирается под ширину строки: замерено, что 300 задач с одним полем весят 196 КБ и упираются в потолок, а 150 проходят. Начинайте со 150.
Обход реестра работает только с JSON: отсев повтора требует id, а в
CSV колонку пришлось бы угадывать. Для выгрузки в CSV есть
plan_registry_walk — он делит период на окна, которым
перекрытие не нужно.
Нужна часть — сужайте. Рычаги по убыванию эффекта:
| Рычаг | Что делает | Замер |
|---|---|---|
get_registry(format="csv") |
CSV вместо JSON: имена колонок один раз в шапке, а не на каждой задаче | 50 задач формы на 33 поля: 1 110 006 Б → 33 988 Б, 32,7× |
get_registry(field_ids=[...]) |
Только нужные поля в каждой задаче | одна выгрузка сократилась в 23× |
get_registry(item_count=…) |
Потолок числа задач | — |
created_after / created_before |
Более короткий период | — |
get_form(summary=true) |
id, name, type, depth по каждому полю; списки вариантов считаются, а не копируются | форма 218 KB перестаёт быть недостижимой |
get_form(field_ids=[...]) |
Названные поля целиком, остальные отброшены | — |
CSV стоит брать всегда, когда вы считаете, агрегируете или просматриваете много задач. Но выбор чаще решает не размер, а состав колонок — форматы отдают разное (проверено на живом API):
close_date, в JSON его нет.
Это единственный дешёвый способ отличить закрытую задачу от открытой:
include_archived=true плюс непустой
close_date. В JSON пришлось бы дёргать
get_task на каждую задачу, а это упирается в рейт-лимит уже
на паре сотен.current_step, в CSV его
нет. Нужен шаг маршрута — берите JSON.Служебные колонки CSV: task_id,
create_date, last_modified_date,
close_date, last_note_id. Разделитель по
умолчанию — запятая (меняется через delimiter). BOM,
который Pyrus ставит в начало файла, сервер срезает, так что
csv.DictReader читает первую колонку как
task_id, а не task_id.
Порядок работы с большой формой: сначала
get_form(summary=true), чтобы найти id полей, потом
get_form(field_ids=[...]) или
get_registry(field_ids=[...]). Оба варианта достают поля,
вложенные в группы, — верхний уровень формы это не вся форма.
У реестра нет offset и курсора, поэтому полный обход делается окнами
по датам: берёте created_after/created_before,
запоминаете самый свежий create_date в ответе и он
становится следующим created_after. Окно в месяц плюс
format="csv" держит каждую страницу в пределах лимита.
| Инструмент | Описание |
|---|---|
get_announcements |
Получить объявления |
get_announcement |
Получить объявление по ID |
create_announcement |
Создать объявление |
comment_announcement |
Комментировать объявление |
| Инструмент | Описание |
|---|---|
list_catalogs |
Все справочники аккаунта: id, имя, версия. Без строк |
get_catalog |
Получить каталог. summary, columns,
format="csv", обход через page_size +
cursor |
create_catalog |
Создать каталог |
sync_catalog |
Полная синхронизация каталога |
update_catalog_items |
Частичное обновление каталога (diff) |
| Инструмент | Описание |
|---|---|
get_members / get_member |
Сотрудники организации |
create_member / update_member |
Завести сотрудника, изменить данные |
block_member |
Заблокировать — отзыв доступа к Pyrus |
set_avatar |
Аватар сотрудника по guid загруженного файла |
get_roles / get_role |
Роли организации |
create_role / update_role /
delete_role |
Управление ролями |
get_award_threshold /
set_award_threshold |
Пороги вручения и отзыва награды |
get_award_counter /
increment_award_counter /
set_award_counter |
Счётчик награды у сотрудника |
get_contacts / get_profile |
Справочное |
get_bots / create_bot /
update_bot / delete_bot |
Боты — технические аккаунты |
Увольнение и блокировка — разные вещи.
block_member отзывает доступ к Pyrus; в карточке это видно
по полю banned, которое становится true.
update_member(fired=true) доступ не трогает вовсе.
Человеку, который действительно уходит, нужна именно блокировка.
⚠️ Поле
firedесть в наших запросах, но в документации Pyrus его нет, и ни в одной живой карточке оно не встретилось. Не исключено, что Pyrus его игнорирует. Полагаться наfiredкак на признак увольнения не стоит — проверяйтеbanned.
Блокировка необратима через API: метода разблокировки Pyrus не даёт, восстановить доступ можно только в интерфейсе. Сотрудник при этом не удаляется — его задачи, комментарии и история остаются на месте.
Поля карточки сверх очевидных:
| Поле | Формат |
|---|---|
messenger |
объект {"type": "Telegram", "nickname": "@name"} —
нужны обе половины |
status |
свободный текст рядом с именем, с эмодзи (в живой карточке —
👍На смене) |
vacation_days |
отправляется строкой, как в документации Pyrus |
Неполный messenger сервер отклоняет: Pyrus такой объект
молча выбрасывает, и вызов выглядел бы успешным.
| Инструмент | Описание |
|---|---|
get_contacts |
Получить контакты |
get_bots |
Получить список ботов |
get_profile |
Получить профиль текущего пользователя |
get_members |
Получить участников организации |
get_member |
Получить участника по ID |
create_member |
Создать участника |
update_member |
Обновить участника |
get_roles |
Получить роли |
get_role |
Получить роль по ID |
create_role |
Создать роль |
update_role |
Обновить роль |
delete_role |
Удалить роль |
Интеграция для контакт-центров. Требует настроенного расширения в Pyrus.
| Инструмент | Описание |
|---|---|
register_call |
Зарегистрировать звонок — создаёт задачу или комментирует существующую |
attach_call_record |
Прикрепить аудиозапись и метаданные к звонку |
register_call(account_id="uniqueID12345",
from_number="+79774221338", to_number="+74953009080",
internal_number="200",
mappings=[{"code": "CallStartTime",
"value": "2021-12-23T00:11:32Z"}])
internal_number — то, как звонок находит ответственного:
Pyrus ищет сотрудника с таким рабочим телефоном. Без него звонок придёт
без назначенного.
В ответе task_id, is_new_task и
responsible_person.
Звонок для attach_call_record определяется по
external_id, либо task_id, либо
паре from_number + to_number
— одного номера мало. record_file — это guid уже
загруженного аудиофайла (ac3, mp3,
ogg, wav, wma), а не сам
файл.
Коды mappings: CallDuration,
CallStartTime, CallEndTime,
PhoneNumberFrom, PhoneNumberTo,
Rating, RatingComment,
RatingDate.
⚠️ Не проверено на живом API. Реализовано по документации Pyrus; тестового контура с настроенной телефонией не было. Ошибки, которые стоит узнавать:
unrecognized_call_id,unrecognized_account_id,invalid_field_mapping_code,unrecognized_attachment_id,unsupported_record_file_format,webhook_is_disabled.
Награда вручается и отзывается автоматически, когда счётчик сотрудника пересекает порог. Пороги задаются на награду, счётчик — на пару «сотрудник + награда».
set_award_threshold(award_id=7, grant_threshold=1, revoke_threshold=91)
increment_award_counter(member_id=1261579, award_id=7)
0 отключает соответствующую сторону. Если работают обе,
revoke_threshold должен быть больше
grant_threshold — иначе награду отзывало бы сразу же после
вручения; такую пару сервер отклоняет.
Изменение порогов не пересчитывает задним числом: у
кого награда уже есть, тот её сохранит, пока его счётчик не сдвинется.
Вручение и отзыв происходят в момент обновления счётчика, то есть на
increment_award_counter или
set_award_counter.
Боты — технические аккаунты для интеграций и вебхуков.
| Инструмент | Описание |
|---|---|
get_bots |
Список ботов; include_fired=true покажет и
отставленных |
create_bot |
Создать бота (name обязателен, плюс
hook_url, external_id) |
update_bot |
Изменить: is_enabled, hook_url,
rights, locale и прочее |
delete_bot |
Удалить безвозвратно |
Две особенности, проверенные на живом API:
{} —
пустым объектом без ключа bots, а не
{"bots": []}. Обращение сразу к ["bots"] на
пустом ответе сломается.bot_settings — это строка JSON, а не
объект: Pyrus хранит её дословно и так же отдаёт боту. Объект сервер
отклоняет, потому что API его примет, а настройка не заработает.Вывести бота из строя обратимо — это
update_bot(is_enabled=false), а не delete_bot:
удаление необратимо и ломает всё, что завязано на его
hook_url.
| Инструмент | Описание |
|---|---|
get_calendar_tasks |
Получить задачи из календаря за период |
Содержимое файлов не проходит через MCP-сервер и не попадает в контекст агента: инструменты выдают адрес и токен, а сам обмен байтами агент делает напрямую с Pyrus. Поэтому размер файла ограничен только Pyrus.
| Инструмент | Описание |
|---|---|
get_upload_target |
Куда и с каким токеном загружать файл (агент делает multipart-POST сам, получает GUID) |
get_file_download_url |
Ссылка на скачивание файла по ID + токен (агент качает сам) |
attach_files_to_field |
Положить уже загруженные файлы в поле формы типа
file |
attach_new_file_version |
Прикрепить файл новой версией существующего
(root_id) |
Загрузка: возьмите url и access_token,
отправьте POST с multipart/form-data, файл в
поле file, заголовок
Authorization: Bearer <access_token>. Полученный GUID
передайте в create_task или comment_task,
чтобы прикрепить файл.
Файл живёт в контуре, куда его загрузили. Если
работаете в контуре клиента, передайте те же
access_token/api_url и в
get_upload_target — иначе Pyrus ответит
No file with that ID has been uploaded.
attachments в
comment_task/create_task принимает не только
GUID-строки, но и объекты:
| Форма | Что делает |
|---|---|
"<guid>" |
новый файл |
{"guid": "...", "root_id": 123} |
новая версия файла 123 |
{"attachment_id": 123} |
прикрепить уже приложенный файл |
{"url": "...", "name": "..."} |
внешняя ссылка |
Две особенности Pyrus, о которые легко споткнуться:
root_id — id первой версии, он
неизменен. Для версии 3 указывайте тот же root_id, что и
для версии 2, а не id версии 2 — иначе цепочка версий рвётся.An ID can only be used once. Для следующей версии нужна
новая загрузка.fileЗаполняются только отдельным шагом — при создании
задачи fields с файловым полем игнорируется. Значение —
числовые id вложений строками, не GUID. Поле аддитивно:
значения добавляются, очистить через API нельзя.
{"task_id": 1042, "field_id": 16, "file_ids": [449339042, 449339043]}| Инструмент | Описание |
|---|---|
get_knowledge_base_entity |
Получить статью или раздел |
create_knowledge_base_entity |
Создать статью или раздел |
update_knowledge_base_entity |
Обновить статью или раздел |
delete_knowledge_base_entity |
Удалить статью или раздел |
get_knowledge_base_structure |
Получить структуру базы знаний |
get_knowledge_base_permissions |
Получить права доступа |
update_knowledge_base_permissions |
Обновить права доступа |
Пока сервер пишет в задачу, она лежит в личном списке владельца кредов «🤖 AI MCP Pyrus», а сразу после успешного коммита выходит из него. Список приватный: посторонние его не видят.
Список создаётся сам — при первой записи каждого пользователя, в его собственном Pyrus и по его реквизитам. Существующий находится по точному имени, в том числе внутри дерева; второй такой же не заводится.
Метка едет в том же теле, что и правка, а не
отдельным вызовом: изменение и метка атомарны, лишней записи в ленте от
постановки не появляется. Отдельным вызовом идёт только снятие — с
skip_notification и skip_auto_reopen. Второй
флаг обязателен: close_task — тоже комментарий, и без него
снятие метки заново открыло бы только что закрытую задачу.
Оставшаяся метка осмысленна. Если запись не прошла, метка не поставилась вместе с ней и задача чиста. Если прошла, а снятие нет — метка осталась, и это значит «изменение закоммичено, а последовательность не завершилась». Такие задачи и есть очередь на разбор; автоматически они не чистятся, иначе сигнал пропал бы.
Цена: один служебный комментарий на запись и одно перечисление
списков на клиента (728 КБ на измеренном контуре, дальше кэш на 30
минут). Выключается переменной PYRUS_TASK_MARKER=0 без
выкатки кода.
Реестр, календарь, Входящие и поиск отдают разные поля, и перепутать их дорого: инструмент на неверном источнике отвечает не на свой вопрос и делает это тихо. Сроков, например, в реестре нет вовсе — они только в календаре и, частично, в поиске.
Таблица источников с замерами — guide/что-где-лежит-в-pyrus.md.
Её же читает агент: read_guide("источники").
Знание про Pyrus стоит по-разному в зависимости от того, где оно лежит.
Описания инструментов платятся каждым агентом при каждом
подключении: 88 описаний — это около 163 КБ (перемерено
30.09.2026 по живому list_tools()), около 46 тысяч токенов,
и неважно, понадобится ли сегодня хоть одно из них. Поэтому там остаётся
только то, без чего первый же вызов будет неверным.
Остальное лежит в guide/ и читается по
требованию — инструментом read_guide("реестр") или
ресурсом pyrus://guide/registry. Платит только тот агент,
который решил, что ему это нужно, а решает он по оглавлению
(read_guide() без аргумента), которое занимает
килобайт.
| Страница | О чём |
|---|---|
sources (источники) |
Что в каком источнике есть: реестр, календарь, Входящие, поиск. Таблица с замерами |
registry (реестр) |
Выборка одним обходом, include_archived, что делать с
отказом, массовая запись |
search (поиск) |
Поиск по аккаунту и три его молчаливые ловушки |
limits (лимиты) |
Потолки Pyrus и сервера, фильтры, которые принимаются и не применяются |
form-export (экспорт-формы) |
36 разделов вместо 7 у get_form: маршрут с условиями,
права, SLA, скрипты, справочники. 403 не значит «формы нет», справочники
меняются сами по расписанию |
audit (аудит-формы) |
31 код находок audit_form/score_form_config по категориям, почему часть проверок реализована не полностью, как считается score_0_3 |
workflow (маршрут) |
Почему graph_edges строго последовательны, как считается sla_hours при нескольких политиках на шаге, откуда мишматчи в access_matrix, почему diff_form_exports не поддерживает весь контракт PRD |
Третий канал — сам отказ. Отклоняя запрос, сервер не
только объясняет причину, но и называет страницу: «Подробнее:
read_guide("реестр")». Это самое дешёвое обучение из трёх —
приходит ровно тому, кто ошибся, и ровно тогда, когда ошибка ещё не
стала выводом.
Страницы в guide/ — те же файлы, что читают люди в
репозитории. Копии нет намеренно: второй экземпляр текста однажды
разойдётся с первым, и узнать об этом будет неоткуда. Инструмент, ресурс
и человек открывают один файл.
Промпт — третий примитив MCP: не действие и не данные, а заготовка сценария, которую запускает человек. Место здесь заслуживает только то, что кодирует знание, легко теряемое: важен порядок, есть невидимая ловушка или цена ошибки высока, а сама ошибка не заметна.
| Промпт | Что делает |
|---|---|
export_register(form_id, fields, period) |
Полная выгрузка реестра формы без потери данных: обходит и потолок Pyrus в 20 000 задач, и лимит размера ответа |
inbox(horizon, mode) |
Разбор Входящих и календаря вместе с человеком: оценка по личной модели весов, раскладка по дереву Списков, откладывание |
delegated(person, narrow) |
Что человек поручил другим: поиск по автору, вычитание собственных задач, группировка по исполнителю и давности |
stale(days, person, act) |
Поручения без движения дольше N дней. Отбор идёт по
modified_date прямо из выдачи поиска — без единого лишнего
запроса; запись только по явному «напомнить» |
person_360(person, narrow) |
Полная картина по человеку: три роли — автор, ответственный, участник — тремя вызовами, потому что один вызов отвечает только про одну |
by_catalog_value(form_id, field, value) |
Задачи, где поле указывает на строку справочника. Держит границу: сквозного поиска по значению нет, фильтр живёт только внутри одной формы |
Промпту inbox нужны файловые инструменты у клиента —
модель весов живёт файлами рядом с агентом, а не в Pyrus. Инструкция
агенту: docs/промпт-inbox.md. Разбор
того, какие сценарии заслуживают быть промптами, а какие нет: docs/prompts.md.
Покажи все формы в Pyrus
Создай в Pyrus задачу "Подготовить отчет за Q3" и назначь на user@example.com
Покажи все задачи из формы "Заявки на отпуск" за последнюю неделю
Посчитай по форме 123 задачи за 2025 год в разрезе статусов
Модель сама выберет узкий запрос; вручную это выглядит так:
get_form(form_id=123, summary=true) → найти id нужных полей
get_registry(form_id=123, format="csv",
field_ids=[3, 12],
include_archived=true,
created_after="2025-01-01T00:00:00Z",
created_before="2026-01-01T00:00:00Z")
include_archived=true обязателен, когда вы считаете: по
умолчанию реестр отдаёт только открытые задачи и молчит об этом — на
одной форме это 681 задача вместо 3046.
Добавь комментарий к задаче 12345: "Документы проверены, можно двигаться дальше"
Закрой задачу 12345 с комментарием "Всё готово"
Переназначь задачу 12345 на user@example.com
Добавь согласующего Иванова к задаче 12345
Обнови поле "Статус" в задаче 12345 на значение "На проверке"
Покажи просроченные задачи из формы "Заявки на отпуск"
Покажи каталог "Контрагенты"
Кто имеет доступ к форме 123456?
Выдай сотруднику 1733 уровень member на форму 123456
Права задаются по числовому id сотрудника, почта и имя не принимаются:
change_form_permissions(form_id=123456,
permissions={"1733": "member",
"1731": "administrator"})
Уровни, от широкого к узкому: administrator,
manager, member, none.
none отзывает доступ — человек исчезает из
списка целиком (проверено вживую), а не остаётся в нём с
нулевым уровнем.
Пятый уровень, restricted_manager, в документации Pyrus
есть, но через API не работает: запрос отвечает
200 и оставляет уровень прежним. Сервер его отклоняет с
объяснением — иначе вызов выглядел бы успешным, ничего не изменив.
Задать этот уровень можно только в интерфейсе Pyrus.
Три вещи, о которых стоит помнить:
none — просто не упомянуть его недостаточно.get_form_permissions, и следите,
чтобы хотя бы один администратор остался.В ответ приходит весь список доступа после
изменения, в том же виде, что отдаёт
get_form_permissions, — не просто подтверждение.
Перечитывать права после вызова не нужно.
Сервер сверяет запрошенное с применённым. Если Pyrus ответил успехом,
но уровень не изменился, в ответе появятся поля not_applied
и warning с перечнем того, что не применилось, —
молчаливого «успеха» не будет.
pyrus_mcp/
├── __init__.py # Версия пакета
├── __main__.py # Точка входа python -m pyrus_mcp
├── client.py # HTTP клиент для Pyrus API v4 (+ один метод v3) + обёртки
└── server.py # MCP сервер (MCPServer) с 88 инструментами
tests/
├── test_client.py # Тесты HTTP клиента (моки)
├── test_server.py # Тесты MCP tools (моки)
└── test_large_payloads.py # Лимит ответа, CSV, сужение форм, таймауты
deploy/
├── ansible/ # Ansible playbook для VDS
├── terraform/ # Terraform для Hetzner/DO
├── launchd/ # Шаблон агента отложенной выкатки (macOS)
└── README.md # Гайд по деплою
guide/ # Справочник: читают и люди, и агенты (read_guide)
├── что-где-лежит-в-pyrus.md
├── инструкция-агенту-по-реестру.md
├── поиск-по-аккаунту.md
└── лимиты-и-потолки.md
scripts/
├── deploy-when-window-opens.sh # Тесты → коммит → railway up → проверка health
├── mcp_daily_report.py # Суточный отчёт по вызовам
├── railway_snapshot.py # Снимок переменных Railway
└── park_token.py # Выдать или отозвать токен доступа
Весь сервер работает с API v4, кроме find_tasks: он
обращается к https://pyrus.com/restapi/searchtasks из API
v3. Это не наследие, а необходимость — списка задач в v4 не существует
вовсе, GET /v4/tasks отвечает 405
(проверено 18.09.2026). В v4 достижимы только конкретная задача по id и
реестр конкретной формы, поэтому вопрос «где по всему аккаунту» отвечать
было нечем.
Что важно знать про этот контур:
/restapi как есть, отдельной авторизации не нужно. Хост,
однако, другой: api.pyrus.com/restapi отвечает «No HTTP
resource was found», v3 живёт на pyrus.com. Для своей
установки Pyrus сервер считает, что оба контура на одном хосте, и
подставляет ваш-хост/restapi.Offset, Skip,
CreatedAfter, ModifiedAfter,
LastTaskId, Ids, FormId,
IncludeArchived. Поэтому тело запроса собирается по белому
списку: всё, что Pyrus не читает, до него не доезжает.item_count (потолок 10 000) и признак «осталось
ещё». Большую выборку сужают фильтрами; когда Pyrus сообщает об обрыве,
в ответе появляется note, объясняющий это словами.TaskList.Tasks, а время приходит как
/Date(1789689600000)/. Наружу не выходит ни то, ни другое:
ответ нормализован в тот же вид, что и у остальных инструментов.mcp — фреймворк Model Context Protocolrequests — HTTP клиентpip install pytest pytest-asyncio
python -m pytest tests/ -vТесты покрывают: - Хелперы форматирования дат и очистки payload - Все
request dataclasses (конвертацию в dict) - HTTP клиент с замоканными
запросами (auth, CRUD задач, форм, пользователей, ролей, файлов) - MCP
tools (проверка вызовов и возвращаемых JSON) - Работу с большими
выборками: отказ вместо обрезки, компактная сериализация, CSV-реестры и
срезание BOM, сужение формы через
summary/field_ids, таймауты и то, что вызов не
блокирует event loop
Моки не отвечают на вопросы вроде «в какой кодировке приходит CSV» —
для этого есть отдельный скрипт. Реквизиты берёт из .env
или pyrus_test.env и никогда их не печатает.
python3 live_check.py # только чтение
python3 live_check.py --write --cleanup # плюс запись, с уборкой за собой
python3 live_check.py --form-id 123456 # на конкретной формеСреди проб — сравнение JSON и CSV на реальном реестре: именно так
выяснилось, что CSV даёт 32,7×, приходит с BOM и несёт колонку
close_date, которой в JSON нет. На формах с табличными
полями состав колонок может отличаться — если такие в работе есть,
прогоните --form-id по одной из них.
Для интенсивного использования агентами рекомендуется развернуть сервер в облаке или на VDS:
| Платформа | Сложность | Стоимость | Лучший выбор для |
|---|---|---|---|
| VDS (Ubuntu) | 🟠 Низкая | $3-10/мес | Интенсивная нагрузка, полный контроль |
| Railway | 🟡 Очень низкая | ~$5/мес | Новичков, быстрый старт |
| Render | 🟡 Очень низкая | ~$7/мес | Предсказуемый биллинг |
| Fly.io | 🟠 Низкая | ~$2-5/мес | Cost-оптимизации |
| Локально | 🟢 Нулевая | Бесплатно | Личного использования |
У токена три возможных уровня: vip — без лимитов,
gold — вдвое шире базовых, regular (по
умолчанию) — базовые: 60 запросов в минуту (всплеск 30) и 4
одновременных. На весь процесс — 24 одновременных независимо от уровней.
Превышение даёт 429 с retryable: true и
Retry-After.
Уровень хранится третьим полем записи реестра
(owner:<sha256>:vip), меняется через
python3 scripts/park_token.py tier <владелец> gold и
не требует перевыпуска токена.
Одиночная переменная MCP_AUTH_TOKEN с
сырым токеном отключена 20.09.2026: реестр хранит хеши,
а она — само значение, и для публичного адреса это лишняя дверь. Вернуть
при необходимости:
python3 scripts/park_token.py unpark legacy. Настраивается
переменными MCP_RATE_PER_MINUTE,
MCP_RATE_BURST, MCP_CONCURRENCY_PER_TOKEN,
MCP_CONCURRENCY_TOTAL — см. DEPLOY.md.
Считается по владельцу токена, а не по IP: агенты сидят за общими адресами облаков. Счётчики в памяти процесса, поэтому при нескольких репликах лимит умножается на их число.
Сервис отвечает на
https://mcp.pplus.software/mcp. Прежний
адрес pyrus-mcp-production.up.railway.app — тот же сервер
под другим именем и продолжает работать: клиентов переводят без
спешки.
Домен настроен в Railway (Settings → Networking → Custom Domain),
сертификат выпускается и продлевается платформой. На сборку и выкатку
это не влияет никак — railway up грузит рабочее дерево, а
имя сервиса живёт отдельно.
railway up грузит рабочее
дерево, а бесплатный тариф не пускает выкатку с 18:00 до 06:00 по
Москве. Скрипт scripts/deploy-when-window-opens.sh
прогоняет тесты, коммитит, выкатывает и подтверждает успех по
/health, а не по статусу сборки💡 Если у тебя уже есть VDS с Ubuntu — используй
deploy/ansible/. Одна командаansible-playbookнастроит всё: Docker, Nginx, SSL, автозапуск.
stdio (по умолчанию) — локальный
запуск, Claude Desktop, Cursorstreamable-http — удалённый доступ
через HTTP, актуальный стандартsse — Server-Sent Events,
устареваетПереключение через переменную окружения:
export MCP_TRANSPORT=streamable-httpMIT