hq-mcp

hq-mcp

Read-only MCP server for VPN business operations, integrating SHM billing and Remnawave panel into composite tools for cross-system queries.

Category
访问服务器

README

hq-mcp

Русская версия · English

MCP-сервер, дающий ИИ-агенту доступ к VPN-бизнесу: биллинг SHM и панель Remnawave, сшитые так, чтобы на вопрос, лежащий поперёк обеих систем, можно было ответить одним вызовом.

Ни один инструмент ничего не меняет тем же вызовом, которым его попросили: пишущий сперва возвращает план, а применение — это второй вызов, несущий идентификатор этого плана.

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

Первый вызов на любой установке — platform_probe. Он отвечает, что вообще есть в этом развёртывании и что из этого живо; всё остальное здесь производно от того, что он сообщит. Ответы ниже подрезаны, значения вымышлены.

platform_probe {}
{
  "shm":   { "configured": true, "reachable": true, "version": "2.19.4", "live": true },
  "remna": { "configured": true, "reachable": true, "version": "3.2.3",
             "runtime": { "instances": 6, "youngestUptimeSeconds": 54294 } },
  "capabilities": { "shm.filter": false, "remna.realtimeBandwidth": true,
                    "tunnel.mysql": false, "…": "…" },
  "warnings": [{ "code": "specs_are_stale", "message": "…" }]
}

Дальше — вопрос, на который ни одна из двух систем не отвечает в одиночку: «клиент пишет, что оплатил, а конфига нет».

client_resolve { "query": "kot@example.com" }
{
  "shm":   { "count": 1, "matches": [{ "user_id": 4821, "email": "kot@example.com",
                                      "blocked": false }] },
  "remna": { "count": 0, "ambiguous": false,
             "paths": [{ "path": "email",   "tried": true, "found": 0, "note": null },
                       { "path": "service", "tried": true, "found": 0, "note": "…" }] }
}

Панель не знает про него ничего, но count: 0 здесь — не «аккаунта нет»: paths называет каждый пройденный поиск и то, чего он не видит. Что случилось на самом деле, говорит вторая пара глаз:

provisioning_diagnose { "shm_user_id": 4821 }
{
  "verdict": "panel_user_missing",
  "services": { "items": 1, "diagnosed": [{
    "user_service_id": 90210,
    "status": "ACTIVE",
    "verdict": "panel_user_missing",
    "storage": { "name": "vpn_mrzb_90210", "present": true, "checked": true },
    "panel":   { "username": "HQVPN_90210", "id": 11274, "found": false, "checked": true },
    "spool":   { "total": 0, "stuck": 0, "failed": 0, "succeeded": 0 },
    "history": { "total": 1, "success": 1 }
  }] }
}

Услуга ACTIVE, снимок конфигурации на месте, провижининг отчитался успехом — а пользователя, которому этот успех принадлежит, в панели нет. Ни биллинг, ни панель по отдельности такого не показывают.

Инструментов, читающих обе системы, тридцать четыре. Режим rw добавляет пятнадцать пишущих: тринадцать меняют боевые данные, один применяет план, ещё один читает локальный журнал мутаций.

Почему составные инструменты, а не прокси эндпоинтов

Очевидная конструкция — по инструменту на HTTP-эндпоинт, штук полтораста. Она была написана и выброшена, по двум причинам.

Сырой прокси обнуляет любой список запретов. Если модель умеет звать GET <любой путь>, то перечень операций, которые вы решили не давать, — украшение: до запрещённого пути одна строка. Здесь инструменты зовут поимённо названные маршруты, а сканер на этапе сборки роняет прогон, если запрещённый путь встретился литералом в исходнике.

И эндпоинт — это не вопрос. Пример выше затрагивает четыре маршрута SHM и два маршрута панели, а интересное в нём — именно стык. client_overview, sync_audit и provisioning_diagnose существуют потому, что баги живут на этом шве.

Правило, определившее всё остальное

Пустой ответ никогда не должен быть принят за доказанное отсутствие.

Когда бэкенд отказывает, инструмент деградирует: отказ уезжает в degraded, предупреждение partial_result называет недостающую половину, а любая находка, зависевшая от этой половины, подавляется, а не вычисляется из того, что уцелело. Когда список усечён, вместе с ним приезжает серверный total — чтобы «такой услуги нет» не опиралось на необъявленное окно.

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

Совместимость: заработает ли это у вас

Проверено на SHM 2.19.4 и Remnawave 3.2.3 — оба числа сняты с работающего развёртывания, а не взяты из спецификации.

Минимум — SHM 2.18.0 и Remnawave 3.0.0. Официальный danuk/shm подходит: все маршруты, которые зовут инструменты, — апстримные, форк не нужен. Единственное место, где патч того развёртывания был виден инструменту, — четвёртый флаг GET /user/password-auth; теперь его отсутствие называется предупреждением sign_in_flag_absent, а не выдаётся за диагноз. Полный перечень маршрутов обеих систем, версия появления каждого и подробный ответ про форк — в COMPATIBILITY.md.

Проверка занимает один вызов — тот же platform_probe. Если версия ниже минимума, он отвечает предупреждением backend_version_below_minimum, называя версию, минимум и что именно отвалится. Ничего при этом не выключается: старая версия даёт громкие отказы на конкретных маршрутах, а не тихие пустые ответы.

Версия Что пропадает Кого это касается
SHM < 2.18.0 GET /healthcheck — единственный маршрут без авторизации только platform_probe: shm.live остаётся null, «биллинг лежит» и «пароль не тот» перестают различаться (shm_healthcheck_route_absent). Остальные инструменты не задеты
SHM < 2.11.3 GET /admin/user/search client_search, client_resolve — отказ, не пустой список
SHM < 2.9.0 GET /user/referrals client_account_state теряет счётчик рефералов
SHM < 2.4.0 GET /user/email client_account_state теряет адрес и признак подтверждения
Панель < 3.0.0 пользователь адресуется uuid, а не числовым id client_overview, subscription_inspect, traffic_stats, provisioning_diagnose, subscription_ops: /api/users/{id} отвергается валидацией с 400
Панель < 3.0.0 нет /api/connections/* connections_inspect — весь инструмент
Панель < 3.0.0 нет POST /api/users/{id}/actions/extend subscription_ops теряет продление (у панели остаётся только массовое)
Панель < 3.0.0 нет /api/system/stats/digest и /stats/http panel_activity теряет две из пяти своих выборок
Панель < 3.2.0 нет GET /api/system/configuration только platform_probe: возможность remna.subscriptionRequestHistory остаётся unknown — намеренно, а не false

Remnawave 3.x ломает совместимость со всем, что писалось под 2.x, и ломает негромко. Из объекта пользователя убран uuid, а вместе с ним исчезли маршруты by-telegram-id, by-email и by-tag, причём /api/users/{uuid} отвечает 400, а не 404, — так что отказ не похож даже на «нет такого пользователя». Здесь этих маршрутов нет вовсе; там, где сервер всё-таки встречает наследный uuid (например, в старом снимке storage SHM), он говорит об этом в ответе, а не сползает молча на догадку.

Спецификации OpenAPI отстают от прода, поэтому platform_probe несёт предупреждение specs_are_stale при каждом вызове; у SHM всё хуже обычного — её спека штампует info.version из конфига в рантайме, то есть описывает тот стенд, где выгрузку сделали, а не ваш. Поэтому проба не читает версии из файлов вовсе, а спрашивает их у живых систем — и там же устанавливает, что верно для этого развёртывания: сужает ли что-нибудь серверный filter у SHM, уважает ли панель filters в листинге пользователей (обе отвечают 200 и молча выбрасывают незнакомые параметры), ведёт ли панель журнал обращений за подпиской, существует ли маршрут realtime-трафика, какие ssh-туннели открыты. И отделяет «бэкенд лежит» от «наши креды не те»: 401/403 сообщается как credentialsRejected.

Установка

Нужны Node 22.12+ и pnpm, и хотя бы одна из двух систем — SHM или Remnawave. Обе не обязательны: каждая настраивается отдельно и в одиночку является полноценной конфигурацией. Инструменты той системы, которой нет, не публикуются вовсе — не «отвечают пусто», а отсутствуют, и platform_probe прямо называет, что настроено. Поэтому число инструментов зависит от установки: только панель — 16, только SHM — 18, обе — 34 (и больше в режиме rw).

pnpm install
pnpm build
pnpm run setup

pnpm run setup, именно с run. pnpm setup — встроенная команда самого pnpm: она правит профиль вашей оболочки и до этого репозитория не доходит.

Мастер существует потому, что шаг, который он заменяет, — написать .env руками — отказывает молча: опечатка в токене панели не мешает серверу подняться и всплывает позже ошибкой инструмента посреди неродственного вопроса. Поэтому он проверяет каждый креденшл на живой системе и различает три отказа — хост не ответил вовсе (DNS, TLS, закрытый порт), хост ответил и отверг креды, хост ответил тем, что не доказывает ничего (502, 429): чинятся они по-разному, а одно «login failed» отправило бы чинить не то.

Спрашивает он только про ту систему, которая у вас есть, и про режим доступа; всё прочее убрано за один вопрос Configure the optional settings? [y/N]. Часовой пояс читает с живой SHM, а не угадывает: SHM пишет даты собственным локальным временем без офсета, и неверная зона молча сдвигает каждый возраст. Секретов не печатает. По умолчанию ставит ro; на rw требует написать слово rw и отдельно подтвердить — назвав перед этим, сколько инструментов появится и сколько из них пишут в боевой биллинг и живую панель, посчитав по реестру в тот же момент. .env пишет с правами 0600 поверх копии прежнего, перенося переменные, о которых не спрашивал, и печатает команды подключения для Claude Code, Codex и opencode — но чужие конфиги не правит: мастер, переписывающий JSONC, однажды сломает кому-то рабочую настройку. Перезапускать его можно в любой момент, Enter сохраняет существующее значение. Без терминала он запускаться отказывается: MCP-клиент стартует сервер без TTY, и мастер, способный проснуться там, завис бы на вопросе, которого никто не видит.

Или руками

cp .env.example .env && chmod 600 .env    # и заполнить

Каждая переменная описана в .env.example. Отсутствующая или неверная роняет старт с указанием имени переменной и того, что от неё ожидается, вместо того чтобы всплыть позже непонятной ошибкой инструмента.

{
  "mcpServers": {
    "hq": {
      "command": "node",
      "args": ["/absolute/path/to/hq-mcp/apps/stdio/dist/index.js"]
    }
  }
}

Второй транспорт: MCP поверх HTTP

Тот же набор инструментов доступен по HTTP — это нужно, когда клиент не может запустить процесс сам: он в контейнере, на другой машине или их несколько. Отдельное приложение, конфигурация из того же .env:

# метка произвольная (её показывает /metrics), токен — не короче 24 символов:
# openssl rand -hex 24
HQ_MCP_HTTP_TOKENS='<label>:<token>' pnpm --filter @hq/http start
# hq-mcp http ready: url=http://127.0.0.1:42480 mode=ro profile=human tools=34 …

Без HQ_MCP_HTTP_TOKENS он не стартует вовсе, и отказывает раньше, чем соберёт клиентов к биллингу и панели. Слушает петлю; открыть его в сеть — HQ_MCP_HTTP_HOST=0.0.0.0, и об этом печатается предупреждение, потому что между сервером и сетью останется только этот токен. Порт — HQ_MCP_HTTP_PORT. Клиент подключается к /mcp, передавая токен обычным Authorization: Bearer:

{
  "mcpServers": {
    "hq": {
      "type": "http",
      "url": "http://127.0.0.1:42480/mcp",
      "headers": { "Authorization": "Bearer <тот же токен>" }
    }
  }
}

Маршрут бессессионный: Mcp-Session-Id не выдаётся и не требуется, поэтому за обратным прокси можно держать несколько копий процесса без липких соединений. Серверных сообщений у него нет, поэтому GET на SSE-поток и DELETE на закрытие сессии отвечают 405 — клиент MCP это понимает. Запрос с заголовком Origin отбивается 403: защита от DNS rebinding, см. «Ограничения».

Соседний /v1/tools — не MCP, а внутренний REST-фасад для ai-bot: одна ручка списка и одна на вызов, со своим конвертом ответа и своим потолком запросов.

Подгонка под свою установку

Апстримный danuk/shm не знает слова «Remnawave» — ни строки. Мост между биллингом и панелью живёт целиком в ваших шаблонах провижининга: один пользователь панели на user_service_id, имя <NAME_PREFIX><user_service_id>, снимок конфигурации в storage SHM под <STORAGE_PREFIX><user_service_id>. Оба префикса сервер читает живьём из config.remnawave вашей SHM и позволяет переопределить (HQ_MCP_STORAGE_PREFIX, HQ_MCP_PANEL_PREFIXES) — оператор знает, что в панели лежит сегодня, лучше, чем ключ конфигурации, описывающий, что SHM соберёт завтра.

Имя пользователя панели — единственный ключ связи, и префикс, не совпадающий ни с чем, не даёт ошибки: он даёт уверенный неверный ответ, в котором каждая услуга выглядит непровижиненной. Поэтому инструменты, способные это доказать, говорят кодом prefix_unverified и подавляют затронутую находку — sync_audit не возвращает корзину missingPanelUser вовсе, provisioning_diagnose помечает результат тем же кодом или panel_username_guessed. Конвенция нужна ровно трём инструментам (sync_audit, provisioning_diagnose, мутатор storage_edit); client_overview принимает remna_user_id необязательным параметром и без него просто не показывает половину панели. Если конвенции у вас нет, все остальные инструменты работают как обычно, а эти три не выдумывают находок. Разбор целиком, с порядком префиксов и наследными именами, — в COMPATIBILITY.md.

Инструменты

Тридцать четыре видны в ro; режим rw добавляет пятнадцать из последней таблицы и не убирает ничего. Числа — для профиля human; что из этого видит bot, сказано в модели безопасности.

Платформа и один клиент

Инструмент На что отвечает
platform_probe Что живо прямо сейчас: версии, возможности, туннели и является ли отказ аварией или кредами
client_resolve Любой идентификатор (telegram id, email, логин, id, имя в панели) в канонические id обеих систем — все совпадения, а не первое
client_search Поиск клиентов SHM по фрагменту, с серверным числом совпадений
client_overview Клиент целиком в обеих системах за один вызов
client_account_state Как учётка входит: email и его подтверждение, OTP, passkey, возможен ли вход паролем, рефералы
client_billing_view Деньги глазами клиента: предстоящее списание и те платёжные методы, что реально ему предложены
client_catalog_view Каталог и промокоды глазами одного клиента — его скидка, его бонусы, скрытые от него тарифы

Деньги, каталог, конфигурация

Инструмент На что отвечает
billing_ledger Платежи, бонусы, списания и две независимые сверки (баланс и бонус — разные колонки с разными путями обновления)
autopay_inspect Состояние автоплатежа и все удержанные комиссии — оно лежит в JSON-поле comment платёжных строк, а не в user.settings
promo_read Промокоды и их погашения: это разные строки, и читать их с одной нельзя
catalog_read Тарифы, прайс заказа, дочерние услуги, карта событий, категории — источник допустимых service_id
config_read Один ключ конфигурации SHM из закрытого списка, секреты замаскированы. Чтения конфигурации целиком не существует
template_read Список шаблонов или тело ровно одного — того файла, который и производит уведомление или скрипт провижининга

Услуги и провижининг

Инструмент На что отвечает
service_inspect Услуги клиента: статус, срок, запланированный следующий тариф, задачи спула по каждой
spool_inspect Очередь провижининга: залипшие, упавшие, приостановленные и реальная глубина
provisioning_diagnose «Оплачено, а конфига нет» — по каждой услуге, а не по клиенту
sync_audit Пакетная сверка биллинга с панелью, обе стороны вычитываются до конца
notify_history Сказали ли клиенту на самом деле, а если нет — почему; вердикт доставки, которого не показывает больше ничто
server_inventory Собственные транспорты SHM и их группы (ssh, http, mail, telegram) и разрывы, молча останавливающие провижининг. Это не список нод Remnawave

Панель — сперва со стороны клиента, затем со стороны флота

Инструмент На что отвечает
subscription_inspect Карточка Remnawave: статус, срок, трафик, HWID-устройства, последние обращения за подпиской. Ключи — никогда
subpage_read Что страница подписки реально показывает клиенту: платформы, приложения, шаги установки, ссылки кнопок
client_reach До каких нод этот клиент реально дотягивается и какие сквады и теги инбаундов это дают
device_inventory Картина HWID по всему флоту — та база, без которой число устройств одного клиента ничего не значит
traffic_stats Трафик по дням в разрезе нод и сквадов; это временной ряд, а не счётчики карточки
connections_inspect Кто подключён прямо сейчас. Панель отвечает на это джобом, и опрос инструмент ведёт сам
infra_map Ноды × профили конфигурации × инбаунды × хосты × сквады и разрывы между ними
infra_costs Сколько стоит инфраструктура, в стыке с панелью: оплаченная нода, до которой никто не доходит, — это уходящие деньги
country_health Ноды, онлайн, трафик и хосты одной страны
node_config_audit Что профиль объявляет против того, что панель на самом деле отдала бы ноде
squads_read Оба семейства сквадов: внутренние решают доступ, внешние — как подписка подана
panel_activity Что происходит с самой панелью: сводка, дайджест за окно, какие маршруты дёргают, история обращений за подпиской
torrent_reports Улики торрент-блокера — и, отдельно, установлен ли он вообще и следит ли

За туннелем (эти два без него отказывают, называя точную ssh-команду)

Инструмент На что отвечает
abuse_report Находки антиабуз-хука плюс топы панели. Дорого: неограниченные сканы боевой MySQL, потолок 5 вызовов на 5 минут
sql_query SQL только на чтение — префлайт и ничего больше, см. ниже

Пишущие (только rw, только профиль human, сначала план)

Инструмент Что меняет
billing_adjust Баланс или бонусы клиента SHM
billing_refund_service Возвращает на баланс сумму, которую SHM записал снятой за текущий оплаченный период
bulk_ops Массовые операции над клиентами панели — по названному набору id или по всему флоту
host_edit Один хост Remnawave: подпись, адрес, порт, SNI/host/path/ALPN/fingerprint, слой безопасности, теги, включение и скрытие
host_cleanup Удаляет хосты по явному списку uuid. Необратимо
node_manage Одна нода: enable, disable, restart, reset_traffic, update, create
subscription_ops Одна подписка в панели: enable, disable, extend, reset_traffic, revoke, set_limits, снятие устройств
service_lifecycle Услуга клиента: give, touch, change_plan, schedule_change, stop, activate, delete
provisioning_repair retry, resume или pause одной залипшей задачи спула
template_edit Перезаписывает тело существующего шаблона SHM
storage_edit Пишет пользовательский storage SHM по списку ключей, выведенному для этой установки
server_edit Строка транспорта или группа транспортов SHM — вебхуки, ssh-точка провижининга, почтовые отправители
user_flags Блокирует клиента или правит безопасные поля карточки (full_name, phone, comment)
ops_confirm Применяет план по его plan_id. Пишет то, что пишет запланированный инструмент
ops_audit Ничего. Читает локальный журнал мутаций — rw потому, что журнал есть часть мутационной поверхности

Мутации

Ничто не применяется тем вызовом, который об этом просит. Мутатор без plan_id читает текущее состояние, строит целевое и возвращает план: before, after, diff по полям, побочные эффекты, rollback там, где он есть, и идентификатор. Не пишет ничего. Применение — второй вызов:

ops_confirm { "plan_id": "…" }          # либо: тот же мутатор, ТЕ ЖЕ аргументы, плюс plan_id

План привязан к профилю, который его построил, к инструменту, под который он построен, и к хешу аргументов: погасить его нельзя ни от другого вызывающего, ни другим инструментом, ни тем же инструментом с одним изменённым числом. Живёт 10 минут. Одноразовость — атомарный rename на диске, а не «прочитать и удалить»: из двадцати одновременных подтверждений выигрывает ровно одно, остальные получают «не найдено». Отказ исправный план не сжигает — все проверки идут после захвата, и провалившаяся возвращает файл на место; сжигает его сама попытка, и если бэкенд упал, план израсходован. Это намеренно, и в этом разница между одним списанием и тремя. Перед применением инструмент перечитывает мир и сверяет его со снимком, из которого план строился: сдвинулся объект — план отвергается, а не накатывается поверх чужого изменения.

Каждая попытка журналируется в HQ_MCP_AUDIT_PATH (JSONL, права 0600): кто, чем, с какими аргументами, как объект выглядел до и после и чем кончилось — planned, applying, applied, failed или rejected; отказы наравне с успехами. applying пишется до обращения к бэкенду, и в этом весь смысл конструкции: запись без парной терминальной означает, что процесс умер посреди, снимок плана уже уничтожен, а деньги могли уйти. ops_audit ищет такие незакрытые записи по всему журналу, независимо от запрошенного окна, и сообщает о них первыми; неразобранные строки считаются, а не пропускаются молча.

Потолки держит фреймворк, а не автор инструмента. Мутация выше HQ_MCP_MAX_OP_AMOUNT отвергается до построения плана, и фреймворк отказывается зарегистрировать инструмент, который объявил денежный эндпоинт, но не сказал, как прочитать сумму из его входа. Потолок накрывает оба вида движения денег, и второй легко упустить: и платежи с бонусами, где сумму называет вызывающий, и действия жизненного цикла, тратящие баланс клиента (give, touch, change_plan, activate), где сумма — это цена тарифа из каталога. План, у которого цену прочитать не удалось, не выдаётся: незнание числа не делает списание бесплатным. HQ_MCP_MAX_BULK_USERS ограничивает, скольких клиентов панели вправе задеть одна массовая операция, и план, не сумевший установить это число у панели, отвергается, а не оценивается на глаз. Выше потолка операция отвергается целиком — никогда не усекается.

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

template_edit и storage_edit сперва пишут собственный откат в HQ_MCP_BACKUP_DIR (каталог 0700, файлы 0600); путь возвращается в ответе, restore_from кладёт байты обратно, и без снятого снимка не пишет ни один из двух. Бэкап отделён от снимка плана намеренно: тела шаблонов и снимки конфигурации несут секреты голыми подстроками, у которых нет имени поля, чтобы их замаскировать, — значит, им нельзя ехать обратно к модели внутри before/rollback; и откат обязан пережить смену, тогда как снимки планов подметаются в течение часа.

Ещё две вещи пишущие делать отказываются. Тело с маркерами <redacted:…> не записывается никогда: это вывод читающего инструмента, и запись его заменила бы живой креденшл тем словом, которым его спрятали. И сырые блобы панели (finalMask, xhttpExtraParams, muxParams, sockoptParams) исключены из любого патча хоста — на этом развёртывании 11 хостов из 51 несут внутри finalMask живой пароль Hysteria2.

Что доказано на самом деле, а что нет

host_edit — единственный мутатор, чья ветка применения прогонялась на живой системе: на боевой панели Remnawave 3.2.3 сменили подпись хоста, проверили, что пароль в finalMask уцелел и что не изменилось ничего сверх заявленного поля, и откатили обратно. Все остальные доказаны до плана включительно: план строится на боевых данных, применяющая часть покрыта тестами, но вживую её ветка не прогонялась. Читать это следует буквально. План, который выглядит правильным, — свидетельство о плане.

Модель безопасности

Два профиля. human — доверенный оператор, и ему достаются конкретные пригодные к действию отказы, включая точную ssh-команду, когда туннель закрыт. bot — недоверенный канал: любой отказ схлопывается в одно и то же сообщение, чтобы реестр нельзя было перебрать, нащупывая, какие имена отвечают иначе. Ни один пишущий инструмент боту не предлагается никогда: в rw профиль bot видит те же двадцать читающих инструментов, что и в ro.

Запрещённый класс, отдельный от просто опасного. Эти операции не закрыты воротами — их нет, и сканер на этапе сборки роняет прогон, если их путь встретился в исходнике литералом. Маршруты identity и keygen нод (GET, у которого в теле ответа приватный ключ). Маршруты токенов, авторизации и passkey (панель отдаёт токены открытым текстом, а созданный токен — это постоянный админ мимо всех ворот). Настройки панели и подписки. Выгрузка /admin/config целиком. Ручная пометка задачи провижининга успешной — она не выполняет работу, а лишь переводит услугу в ACTIVE при по-прежнему отсутствующем пользователе в панели. Удаление платежа, бонуса или списания — голый DELETE FROM по реестру, при котором users.balance не пересчитывается. Готовые к употреблению ссылки подписки и connection-keys. restart-all, reorder, bulk-actions сквадов и PUT /admin/spool с job_users — рассылка всем клиентам без отмены.

Класс сузился, и каждое сужение было исправлением, а не послаблением. Чтение шаблонов было запрещено вместе с записью, хотя причина — нет гита, нет отката — говорила только про запись; ширина стоила не теоретически: 43 % уведомлений в одном боевом окне отрендерились пустыми и не отправили ничего, задача при этом отчиталась SUCCESS, а причина молчания лежит внутри тела шаблона. Теперь чтение открыто, POST — под template_edit, который принёс с собой откат, а PUT и DELETE закрыты: у только что появившегося и у только что исчезнувшего шаблона нет предыдущего состояния, которое можно снять. Запрет на /api/sub был префиксным и заодно накрывал /api/subscription-page-configs и /api/subscription-request-history — два читающих контроллера, ключей не выдающих; теперь это exact плюс prefix на /api/sub/. Массовые операции над клиентами панели запрещались потому, что применяются ко всей базе без списка на просмотр, — верно ровно до тех пор, пока никто не считает: bulk_ops считает у панели до применения, отказывается, когда число установить не удалось или оно выше HQ_MCP_MAX_BULK_USERS, и боту не предлагается.

POST /api/users/bulk/delete-by-status остаётся запрещённым по своей форме: в его теле статус, а не список людей. Панель ставит задачу в очередь и удаляет тех, кто подойдёт в момент её выполнения, — не тех, кого просматривал оператор, — и отвечает 202 с пустым телом и без счётчика, так что учётки, истёкшие в промежутке, удаляются невидимо. Возможность сохранена как bulk_ops delete_by_status: он перечисляет конкретные id, показывает их и удаляет ровно их через bulk/delete. Массовые маршруты на других сущностях — хосты, ноды, сквады, рассылки спула — такого шага подсчёта не имеют и остаются отсутствующими.

Секреты маскируются на выходе — и по имени ключа, и по форме значения. По имени: закрытый список кредовых ключей, совпадение с token|secret|key|password|auth при явном списке исключений, хвостовая маскировка для нескольких и маскировка PII для профиля bot. Этого мало, и за один день это подвело трижды: токен Telegram-бота ехал внутри response.request.url строки спула (ключ называется url), он же лежал в колонке host пяти транспортных строк SHM, а тела шаблонов несут креденшлы голыми подстроками, рядом с которыми имени поля нет вовсе. Поэтому обход прогоняет каждую проходящую строку ещё и через правила формы значения: JWT; NAME=<значение>, где имя обещает секрет, а значение не похоже на плейсхолдер; токены Telegram-бота с окружающим путём и без него; user:password@ внутри URL. Живёт он внутри redact, которую зовут оба HTTP-клиента на входе и исполнитель на выходе, — помнить об этом не обязан ни один отдельный инструмент.

Правила откалиброваны, а не угаданы, и калибровка объявлена прямо в исходнике: порог «непрозрачного прогона» (32+ символа, выглядящих случайно) измерен на 197 боевых телах шаблонов и выключен на структурированных ответах API, где его перешагивают data-URI иконки и hex uniq_id платежа — вырезав их, чистка погасила бы ровно те поля, ради которых инструмент и писали. Границей безопасности это всё равно не является, и исходник так и говорит: у секрета, написанного словами, формы нет; всё, что проходит фильтр, остаётся внутри профиля human. Те же правила использует scripts/no-secrets.test.ts, не пускающий секрет в публикуемый коммит: две копии знания о том, «как выглядит секрет», расходятся молча, и вторая продолжает выглядеть работающей.

sql_query ничего не выполняет. Он валидирует и отказывает, и говорит об этом в собственном исходнике. Лексическая проверка — дешёвый первый фильтр и явно не граница безопасности; модуль перечисляет обходы, которые её проходят, и тесты держат их открытыми, чтобы никто не принял фильтр за гарантию. Пока выполнение не подключено, предусловия объявлены в том же файле: роль только на чтение, транзакция только на чтение, таймаут запроса и запретный список колонок.

Что стоит знать про ограничения

  • HTTP-транспорт говорит на MCP (/mcp, streamable HTTP) и отдаёт тот же набор инструментов, что stdio: публикует их одна функция на оба транспорта. Чего он намеренно не умеет: сессий (Mcp-Session-Id не выдаётся), server-initiated сообщений, а с ними — потока SSE на GET и возобновления по Last-Event-ID. Каждый вызов самодостаточен, поэтому сервер и транспорт создаются свежими на запрос; этого требует и сам SDK, чей бессессионный транспорт запрещено переиспользовать.
  • По маршруту /mcp два исхода исполнителя недостижимы, и счётчики /metrics видят по нему два из четырёх. Негодный вход разбирает SDK ДО инструмента и сам отвечает -32602; несуществующее имя он тоже отбивает сам, не доходя до реестра. Поэтому invalid_input и not_found по этому маршруту не появляются ни в ответе, ни в отчёте. На REST-фасаде достижимы оба.
  • Запрос к /mcp с заголовком Origin отбивается 403 без вариантов: сервер слушает петлю, а страница в браузере может увести свой домен на 127.0.0.1 и ходить сюда от имени оператора. Браузер проставляет Origin на любом POST кросс-происхождения, настоящий клиент MCP — никогда, а заголовков CORS сервер не отдаёт, поэтому браузерного клиента у него нет и быть не может. Встроенные allowedHosts/allowedOrigins для этого не годятся: в этой версии SDK они помечены устаревшими в пользу внешнего middleware, а пустой список origin-ов у них означает «проверка выключена», а не «никакой origin не годится».
  • Двум инструментам нужен туннель во внутреннюю сеть, и без него они отказывают. Видимыми они остаются намеренно: исчезнувший инструмент учит модель, что такой возможности не существует, тогда как на деле закрыт порт.
  • sync_audit вычитывает обе системы до конца и является здесь единственным дорогим вызовом — ради этого у него собственная норма запросов.
  • Размер страницы панели измеряется в рантайме, а не предполагается: API не объявляет максимума, а фактический менялся между релизами.
  • Массовые маршруты панели отвечают 202 или 204 с пустым телом и часть работы ставят в очередь, поэтому «применено» означает «принято панелью», а не «сделано для всех». Число, установленное планом заранее, — единственное честное, какое здесь вообще есть.
  • Правки, сделанные в панели, не переносятся обратно в биллинг SHM, и инструменты, которые их делают, об этом говорят. Шага сверки нет; расхождение вам потом покажет sync_audit.

Разработка

pnpm test          # модульные тесты
pnpm typecheck
pnpm test:guards   # сканер секретов и предохранители скрипта захвата фикстур

Тесты гоняются на фикстурах, повторяющих форму настоящих ответов. Там, где дефект был виден только на живой системе, тест, который его закрепляет, так и говорит.

Лицензия

MIT.

推荐服务器

Baidu Map

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选