О чём эта статья и о чём она не. Это инженерный разбор того, как устроен MCP‑сервер: как выбирается набор инструментов, как гейтятся необратимые операции, как ужимается вывод под контекст модели и как всё это доезжает до пользователя. Предметная область — self‑hosted панель администрирования с REST API. Никаких инструкций по её настройке, никаких рекомендаций сторонних сервисов и никаких сравнений здесь нет и не будет: статья про архитектуру агентных инструментов, а панель — просто конкретный API, на котором удобно показать проблемы.
Полгода назад я поддерживал TypeScript SDK для одной панели администрирования. Обычная библиотека: сгенерированный из OpenAPI клиент, авторизация, ретраи, вебхуки. И в какой‑то момент я поймал себя на том, что рутинные вопросы к панели — «у кого истекает доступ на этой неделе», «почему нода отвалилась», «сколько трафика съел вот этот аккаунт» — я решаю одинаково: открываю редактор, пишу пятнадцать строк скрипта на своём же SDK, запускаю, читаю, удаляю.
Мысль напрашивалась: SDK уже типизирован, схемы уже есть, значит модель может вызывать его сама. Так появился marzban-mcp.
Дальше выяснилось, что «обернуть SDK в MCP» — это примерно 10% работы. Остальные 90% — ответ на вопрос, который в обычной библиотеке вообще не стоит: что можно доверить модели делать с боевой инфраструктурой, а что нельзя, и как эту границу выразить в коде.
Коротко: что такое MCP, если вы про него не слышали
MCP (Model Context Protocol) — открытый протокол, по которому языковая модель получает доступ к внешним инструментам. Практически это выглядит так: вы прописываете в конфиге своего клиента (Claude Desktop, Cursor, Claude Code, VS Code и другие) команду запуска сервера, клиент поднимает его дочерним процессом и общается с ним по JSON‑RPC через stdin/stdout. Сервер объявляет список инструментов со схемами аргументов, модель выбирает нужный и вызывает.
Ключевое, что стоит понять до всего остального: модель не видит вашего кода и не видит документации. Она видит только имя инструмента, его описание и JSON‑схему аргументов. Всё, что вы не написали в описании, для неё не существует. Это меняет способ проектирования сильнее, чем кажется.
Проблема первая: инструмент — это не эндпоинт
Первая версия у меня в голове была очевидной: у SDK 50 операций, значит 50 инструментов, генерируем автоматически, готово.
Это плохая идея, и вот почему.
Список инструментов целиком лежит в контексте на каждом запросе. Пятьдесят инструментов с полными JSON‑схемами — это тысячи токенов, которые платятся всегда, даже если пользователь спросил «сколько у меня активных аккаунтов». Плюс чем длиннее список, тем хуже модель выбирает: она начинает путать похожие имена и брать не тот инструмент.
Эндпоинты спроектированы под программиста, а не под модель. В API есть modifyUser — частичное обновление, куда можно передать status: 'active'. Модель, которой сказали «включи Петрова обратно», должна догадаться: взять modifyUser, узнать, что статус — это enum, вспомнить правильное значение. А может передать 'enabled' и получить 422.
Я остановился на 21 инструменте и переписал их под намерения, а не под HTTP‑методы. Вместо одного modifyUser появились marzban_users_activate, marzban_users_deactivate, marzban_users_hold — три отдельных инструмента с пустыми схемами кроме username. Внутри все три вызывают тот же modifyUser, но модели больше не нужно знать про enum.
Появился и marzban_users_extend, которого в API нет вообще: продление доступа — это чтение текущего состояния, вычисление новой даты от сегодня или от старой, и патч. Три шага, которые модель делала бы тремя вызовами с шансом ошибиться в арифметике посередине.
Имена под неймспейсом. Все инструменты начинаются с marzban_, и это проверяется на этапе объявления:
const NAMESPACE_PREFIX = 'marzban_' export function defineTool<I extends z.ZodType, O extends z.ZodType>( definition: ToolDefinition<I, O> ): ToolDefinition<I, O> { if (!definition.name.startsWith(NAMESPACE_PREFIX)) { throw new Error(`Tool name "${definition.name}" must start with "${NAMESPACE_PREFIX}".`) } return definition }
Клиент обычно держит подключёнными несколько MCP‑серверов одновременно. Инструмент с именем list_users в такой компании — это заявка на то, что модель однажды позовёт чужой.
Описания пишутся для новичка, а не для коллеги. Это отдельная дисциплина, к которой я пришёл не сразу. Описание инструмента — единственное место, где можно объяснить модели, когда его не надо звать:
description: 'Lists users, optionally filtered by status or a search term (matches username/note). ' + 'Paginated — default 25, max 100 per call; prefer `search` over paging through everyone. ' + 'For one known username, use marzban_users_get instead.',
Последняя фраза сэкономила больше токенов, чем любая оптимизация вывода. До неё модель на вопрос про конкретного пользователя честно листала весь список постранично.
Проблема вторая: как отдать доступ, но не весь
Среди инструментов есть marzban_users_delete, marzban_core_restart и marzban_config_update — последний перезаписывает конфигурацию ядра целиком и перезапускает его, роняя все активные соединения. Отдавать это модели «как есть» нельзя.
Профили как первый рубеж
У каждого инструмента объявлен scope: read, write или destructive. Профиль в переменной окружения задаёт, какие скоупы вообще существуют:
const PROFILE_SCOPES: Record<McpConfig['profile'], ReadonlySet<ToolScope>> = { readonly: new Set(['read']), standard: new Set(['read', 'write']), full: new Set(['read', 'write', 'destructive']), }
Важная деталь: фильтрация происходит на регистрации, один раз при старте, а не в момент вызова. Инструмент вне профиля не просто откажет — он не появится в tools/list вообще. Модель о нём не узнает и не попытается его позвать. Это лучше отказа в рантайме: отказ модель воспримет как повод переформулировать и попробовать ещё раз.
Поверх профиля — глобы, deny выигрывает у allow:
return tools .filter(tool => allowedScopes.has(tool.scope)) .filter(tool => !matchesAny(tool.name, options.toolsDeny)) .filter(tool => !options.toolsAllow || matchesAny(tool.name, options.toolsAllow)) .slice() .sort((a, b) => a.name.localeCompare(b.name))
Сортировка по имени в конце — не косметика. Порядок tools/list не должен зависеть от порядка регистрации модулей, иначе ломается кеширование промпта на стороне клиента.
Аннотации readOnlyHint и destructiveHint, которые видит хост, выводятся из scope автоматически и намеренно не являются полями в объявлении инструмента:
function deriveAnnotations(tool: ToolDefinition<z.ZodType, z.ZodType>): ToolAnnotations { return { title: tool.title, readOnlyHint: tool.scope === 'read', destructiveHint: tool.scope === 'destructive', ...tool.annotations, } }
Если автор может выставить их руками, рано или поздно они разойдутся со скоупом — и хост покажет пользователю «безопасная операция» на удалении.
Почему диалога «Allow?» недостаточно
Клиенты сами спрашивают подтверждение перед вызовом инструмента. Казалось бы, вопрос закрыт.
Не закрыт. Диалог хоста показывает имя инструмента и аргументы. Пользователь видит:
marzban_config_update { "config": { ...400 строк JSON... } }
И нажимает «Allow», потому что читать 400 строк JSON в модалке никто не будет. Хост показывает вызов, а не последствия.
Поэтому у деструктивных инструментов есть describeConsequences — функция, которая может сходить в API и рассказать, что именно произойдёт с этими конкретными аргументами:
describeConsequences: async (args, ctx) => { try { const current = await ctx.sdk.core.getCoreConfig() const diff = diffTopLevelKeys(current, args.config) return `This will overwrite the entire core configuration and RESTART THE CORE — ` + `every active connection across all nodes will drop while it restarts. ` + `Top-level sections added: ${diff.addedKeys.join(', ') || 'none'}; ` + `removed: ${diff.removedKeys.join(', ') || 'none'}; ` + `changed: ${diff.changedKeys.join(', ') || 'none'}. ` + `The current config is returned as "backup" in the response.` } catch { return 'This will overwrite the entire core configuration and RESTART THE CORE — ' + 'every active connection across all nodes will drop. (Could not fetch the current config to diff.)' } }
Обратите внимание на catch. Диффа может не получиться — панель моргнула, сеть отвалилась. Это не повод превращать «подтвердите, пожалуйста» в сетевую ошибку: операция ровно настолько же необратима в обоих случаях. Подтверждение спрашивается всегда, дифф — по возможности.
Такой же приём в удалении пользователя: перед подтверждением подтягивается его статус, потраченный трафик и дата истечения, чтобы человек увидел, кого именно удаляет.
Токены подтверждения
Механика двухшаговая. Первый вызов деструктивного инструмента ничего не делает — он возвращает описание последствий и одноразовый токен. Второй вызов с тем же токеном выполняется.
Токен несёт три поля:
interface ConfirmPayload { /** Random id — makes the token single-use once recorded in `usedJti`. */ jti: string /** The exact tool this token was minted for; rejected if replayed against another. */ tool: string /** SHA-256 of the canonicalized call arguments (minus `confirmToken` itself). */ argsHash: string }
Что здесь важно и почему:
argsHash считается от канонизированных аргументов. Порядок ключей в JSON недетерминирован — один и тот же логический вызов может сериализоваться по‑разному. Поэтому перед хешированием ключи рекурсивно сортируются:
export function canonicalize(value: unknown): string { return JSON.stringify(sortKeysDeep(value)) }
Без этого модель получила бы отказ на честной повторной попытке. С этим — токен, выданный на удаление аккаунта alice, невозможно применить к удалению bob.
jti делает токен одноразовым. Использованные лежат в Map в памяти процесса с TTL и подчищаются лениво при каждой проверке.
Ключ подписи генерируется на процесс. crypto.getRandomValues(new Uint8Array(32)) при создании сервера, никуда не сохраняется. Перезапущенный сервер не признаёт токены, выданные предыдущим запуском — и это желаемое поведение, а не недоработка.
TTL пять минут. Подтверждение должно быть про «пользователь прямо сейчас сказал да», а не про «когда‑то в этой сессии соглашался».
Есть три режима через MARZBAN_MCP_CONFIRM: always — подтверждать каждый раз, auto (по умолчанию) — один раз на инструмент за сессию, off — для автоматизации, где человека в цикле нет по определению.
И отдельный крючок skipConfirm для превью:
skipConfirm: args => args.dryRun === true,
marzban_config_update с dryRun: true считает дифф и ничего не пишет. Гейтить превью тем же подтверждением, что и реальную запись, — верный способ приучить пользователя жать «да» не глядя.
Проблема третья: контекст стоит денег
Ответ API на список пользователей — это JSON, где у каждого аккаунта полтора десятка полей, включая массивы строк подключения. Сто пользователей — десятки тысяч токенов. Модели для ответа на «у кого заканчивается доступ» нужны три поля из пятнадцати.
Вывод разделён на два слоя. View знает предметную область — умеет отформатировать байты и таймстемпы, знает, какие поля важны. Renderer предметную область не знает — он раскладывает уже спроецированные строки в text, table или json.
export function render<T>(data: T, view: View<T>, options: RenderOptions): CallToolResult { const projector = options.verbosity === 'full' && view.full ? view.full : view.compact const rows = projector(data, { showLinks: options.showLinks }) const rendered = options.format === 'table' ? renderTable(rows) : options.format === 'json' ? renderJson(rows) : renderText(rows) const { text } = truncate(rendered, options.maxChars) return { content: [{ type: 'text', text }], structuredContent: data, } }
Ключевая деталь в последних двух строках. content — компактная проекция для модели. structuredContent — полные исходные данные, без обрезки и проекции, для программного потребления. Модель читает сжатое, а код, который вызывает инструмент программно, получает всё.
Обрезка по maxChars всегда режет по границе строки и добавляет явный маркер. Молчаливая обрезка читается моделью как «это всё» — и она уверенно отвечает по половине данных, не подозревая, что была вторая.
Мелочь, которая оказалась не мелочью: флаг MARZBAN_MCP_SHOW_LINKS, по умолчанию выключенный. Строки подключения — это и самое длинное поле в ответе, и самое чувствительное. Дефолт «не показывать» экономит контекст и заодно не тащит секреты в историю переписки.
Проблема четвёртая: надёжность и секреты
stdout зарезервирован под протокол. Это первый инвариант, который я записал, и он менее очевиден, чем звучит. Транспорт — stdio, значит любой console.log в любой зависимости ломает JSON‑RPC. Логгер пишет через process.stderr.write, и точка.
Конфигурация читается только из окружения. В коде это записано явным комментарием: аргументы инструментов никогда не несут учётных данных или базового URL. Причина не в удобстве. Аргументы инструмента формирует модель — а значит, на них влияет всё, что попало к ней в контекст, включая содержимое ответов от той же панели. Учётные данные, которые может подставить модель, — это учётные данные, которые может подставить кто угодно, чей текст до неё доехал.
Сервер поднимается, даже если панель лежит. SDK создаётся с authenticateOnInit: false. Иначе недоступная панель на старте означала бы, что клиент не получил даже tools/list — с точки зрения пользователя сервер просто не работает, без объяснений.
TLS: доверить, а не отключить. Типичный кейс — панель за самоподписанным или внутренним сертификатом. Соблазн отключить проверку целиком; вместо этого есть MARZBAN_TLS_CA_FILE — доверить один конкретный CA:
// Covers the common self-hosted case: a panel behind a self-signed // or internal-CA certificate. Trusts one extra CA rather than disabling // verification. caFile: z.string().min(1).optional(), // Escape hatch for panels the operator cannot get a trusted/known CA for. // Deliberately not exposed as a generic "insecure" toggle — the env var // name spells out exactly what it disables. tlsRejectUnauthorized: z.boolean().optional(),
Аварийный выход существует, но называется MARZBAN_TLS_REJECT_UNAUTHORIZED, а не INSECURE или SKIP_TLS — имя переменной проговаривает, что именно выключается.
Затирание секретов в тексте ошибок. SDK уже чистит структурированные поля ошибок при их создании. Но текст ошибки здесь уходит прямо в контекст модели — сток чувствительнее лог‑файла. Поэтому поверх лежит узкий строковый скраб:
const TEXT_PATTERNS = [ { pattern: /Bearer\s+[A-Za-z0-9\-_.]+/gi, replacement: 'Bearer [REDACTED]' }, { pattern: /eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]*/g, replacement: '[REDACTED_JWT]' }, { pattern: /("(?:password|token|access_token|secret)"\s*:\s*)"[^"]*"/gi, replacement: '$1"[REDACTED]"' }, ]
Это осознанно не полный порт объектного затирания из SDK — только те паттерны, которые реально доживают до уже сплющенного текста ошибки.
Конфигурация валидируется Zod‑схемой и падает громко. Неизвестное написание булева значения не резолвится молча в false, а выбрасывает ошибку с перечислением допустимых вариантов. Молчаливый дефолт в конфиге безопасности — это тихо выключенная защита.
Как это устроено внутри: инструмент как данные
Всё перечисленное — фильтрация, аннотации, подтверждение, рендеринг, маппинг ошибок — живёт в одном месте, в реестре. Объявление инструмента — это чистые данные:
export const usersDeleteTool = defineTool({ name: 'marzban_users_delete', title: 'Delete user', description: '...', inputSchema: usersDeleteInputSchema, outputSchema: usersDeleteOutputSchema, scope: 'destructive', view: userDeletedView, describeConsequences: async (args, ctx) => { /* ... */ }, handler: async (args, ctx) => { await ctx.sdk.user.removeUser(args.username) return { username: args.username, deleted: true as const } }, })
Хендлер возвращает обычные данные, а не CallToolResult. Он не знает про рендеринг, про формат, про обрезку, про ошибки. Это работа пайплайна.
На практике добавление нового инструмента — это схемы, view, defineTool, строчка в массиве экспорта и тесты. Ни server.ts, ни реестр не меняются: фильтрация, аннотации, подтверждение и обработка ошибок применяются сами.
Как это тестировалось
Юнит‑тесты мокают SDK и проверяют, что каждый инструмент дёргает правильный метод с правильными аргументами. Порог покрытия — 100% по рукописному коду, жёстко, в CI. Это не самоцель: перед тем как трогать поведение подтверждений и рендеринга, мне нужна была сетка, где видно, какие ветки реально исполняются, а какие считаются работающими.
Но юнит‑тесты с замоканным SDK принципиально не ловят одну вещь: расхождение между Zod‑схемой инструмента и реальными типами API. Для этого есть отдельный слой — интеграционные тесты против настоящей панели, поднятой в Docker. Тот же одноразовый стенд используется и локально, и на CI‑раннере; учётные данные — заведомо мусорные из .env.example, панель никуда не уезжает с раннера.
Самоподписанный сертификат стенда там доверяется через тот же httpsAgent, а не отключением проверки процессу — иначе тест перестанет проверять ровно тот код, ради которого написан.
Отдельная тонкость всплыла при отладке через MCP Inspector. У него есть --cli‑режим, и каждый вызов поднимает свежий процесс. Токен подтверждения подписывается ключом процесса — значит проверить двухшаговый флоу двумя --cli‑вызовами невозможно в принципе, токен всегда будет отвергнут. Это не баг, это ровно то поведение, которое я заложил. Пришлось написать это в документации явно, потому что с первого раза выглядит как поломка.
Цепочка доставки
Сначала контекст, без которого дальнейшее не читается. Всё это живёт в монорепозитории на pnpm workspaces + Turborepo: SDK, MCP‑сервер и сайт документации — отдельные пакеты в одном репозитории. MCP зависит от SDK через workspace:^, Turborepo следит за порядком сборки (build.dependsOn: ["^build"]), так что SDK всегда собирается первым. Публикуемых пакетов два, у каждого своя версия, свой чейнджлог, свои теги (sdk-v*, mcp-v*) и свой релизный воркфлоу — падение релиза одного никогда не блокирует другой.
Тут интереснее всего оказалась не сама сборка, а порядок шагов.
Вся автоматизация — на GitHub Actions, пять воркфлоу. ci.yml гейтит мёрдж: линт, тесты с порогами покрытия, сборка и проверка форматирования, причём Turborepo скоупит прогон только на изменившееся и зависящее от него. integration.yml поднимает панель в Docker на раннере и гоняет интеграционные сьюты — не блокирует мёрдж, потому что реальная сеть и старт контейнера медленнее и менее детерминированы. release-sdk.yml и release-mcp.yml публикуют пакеты. docs.yml выкатывает сайт документации на GitHub Pages. Общие куски релиза вынесены в два composite action, чтобы два релизных файла не дублировали самое хрупкое.
Релиз запускается не тегом, а бампом версии. Тег руками никто не создаёт. Пайплайн срабатывает через workflow_run после успешного CI на main, и первым делом выясняет, изменилась ли версия — через npm publish --dry-run против того, что уже лежит в реестре. Если не изменилась, воркфлоу останавливается, ничего не публикуя. Именно это делает безопасным запуск релизных воркфлоу на каждый пуш в main.
Docker‑образ мультиархитектурный, но QEMU не собирает. Сборочная стадия всегда идёт на архитектуре хоста, эмулируется только смена базового образа:
FROM --platform=$BUILDPLATFORM node:24-alpine AS builder RUN corepack enable WORKDIR /repo COPY . . RUN pnpm install --frozen-lockfile --filter=marzban-mcp... RUN pnpm turbo run build --filter=marzban-mcp RUN pnpm --filter=marzban-mcp deploy --prod --legacy /out FROM node:24-alpine AS runtime ENV NODE_ENV=production WORKDIR /app COPY --from=builder /out . USER node ENTRYPOINT ["node", "dist/index.js"]
Выход — чистый JS, между платформами отличается только базовый образ. arm64 собирается без эмуляции тулчейна, то есть быстро.
Кстати, у образа нет EXPOSE и нечего проверять снаружи: транспорт stdio, запускается как docker run -i --rm.
А теперь про то, как я сломал первый настоящий релиз.
marzban-mcp зависит от SDK через workspace:^. npm разворачивает этот протокол только для воркспейсов, которыми управляет сам, — а у меня pnpm‑воркспейсы, поэтому в корневом package.json нет поля workspaces, и npm оставил бы в опубликованном пакете буквальную строку workspace:^, которую npm install снаружи не резолвит. Значит, перед публикацией нужен скрипт, переписывающий диапазон в реальный.
Я поставил этот скрипт перед сборкой образа. Логично же: сначала привести package.json в порядок, потом собирать.
npm опубликовался. Docker упал.
Причина: контекст сборки образа — то же рабочее дерево, а внутри pnpm install --frozen-lockfile. Лок‑файл сгенерирован против workspace:^. Переписанный package.json с ним не сходится, --frozen-lockfile честно падает. При этом pnpm deploy внутри Dockerfile прекрасно резолвит протокол сам — переписывание ему вообще не нужно.
Правильный порядок оказался обратным: сборка образа, потом переписывание, потом публикация в npm. Оба факта теперь висят комментариями прямо в воркфлоу, чтобы следующий человек (то есть я через полгода) не переставил шаги обратно из тех же соображений «логичнее».
Второй урок из той же истории — про восстановимость. Релиз, упавший посередине, обычным перезапуском не чинится: dry‑run видит версию уже на npm и всё пропускает. Пришлось добавить флаг force_publish, чтобы доиграть остаток пайплайна для той же версии; npm publish для уже опубликованной версии и так no‑op.
Публикация без хранимых токенов. npm publish --provenance через OIDC — никакого npm‑токена в секретах репозитория нет. К образу прикрепляются provenance‑ и SBOM‑аттестации, но только на реальном пуше: в dry‑run прикреплять их некуда, поэтому они просто выключаются, а не роняют джоб.
Changelog генерируется git‑cliff из conventional commits, с фильтрацией по путям пакета. Отсюда, кстати, следует известный мне разрыв: фикс, который лежит в SDK и меняет поведение MCP, в чейнджлог MCP не попадает. Автоматического решения у меня нет — это задокументированная дыра, а не баг.
Что не получилось и что осталось долгом
Раз уж статья про инженерию, честно про недоделанное.
Список инструментов ведётся руками в трёх местах — в коде, в README пакета и на сайте документации. Ничто не проверяет, что они согласованы. Определения лежат структурно, генератор напрашивается сам собой, но пока не написан. Это моя главная претензия к себе: принцип «выводимое — генерировать, а не писать» я применил к клиенту из OpenAPI и не применил к документации.
Маппер ошибок читает HttpError.details.response.status напрямую, а не через типизированный аксессор SDK. Единственное место, где MCP‑пакет опирается на внутренности SDK вместо публичного API. Отмечено как долг, а не как образец для подражания.
Нативный elicitation отложен. В протоколе есть механизм, чтобы сервер сам запросил у пользователя подтверждение через хост. Я реализовал вариант с токеном, потому что он работает во всех клиентах уже сейчас, а нативная поддержка пока неравномерная. Когда она устаканится, токены останутся как fallback.
Один модуль без интеграционного покрытия. Известно, помечено чекбоксом в документации по тестированию, руки не дошли.
Что я вынес из этого
Три вещи, которые не были очевидны на старте.
Проектирование инструментов для модели — это UX‑работа, а не API‑работа. Мой самый полезный коммит за весь проект — не оптимизация, а фраза в описании инструмента о том, когда стоит взять другой. Модель — это пользователь, у которого есть только подсказки в интерфейсе и нет ни документации, ни возможности спросить.
Подтверждение должно описывать последствия, а не вызов. Диалог, показывающий имя функции и JSON аргументов, обучает пользователя жать «да» не читая. Диалог, говорящий «это удалит аккаунт alice, потративший 4.2 ГБ, с доступом до 12 марта», — единственный, который реально работает.
Границу доверия надо выражать в типах, а не в договорённостях. scope у инструмента — не метка. Из него выводятся аннотации, из него следует фильтрация, из него включается подтверждение. Автор инструмента физически не может выставить destructiveHint: false на удалении — не потому что не станет, а потому что такого поля в объявлении нет.
Ссылки
Исходники: github.com/Ilmar7786/marzban‑sdk — монорепо, MCP‑сервер в
packages/mcpДокументация: ilmar7786.github.io/marzban‑sdk, раздел MCP Server
Пакеты:
marzban-mcp,marzban-sdkСпецификация протокола: modelcontextprotocol.io
MCP Inspector — незаменим при отладке
kubb — генерация типизированного клиента из OpenAPI, на нём построен SDK
Архитектурные решения проекта лежат в репозитории как ADR — если интересно, почему что‑то сделано именно так, ответ скорее всего там
Код открыт под MIT. Если будете делать свой MCP‑сервер и упрётесь в те же вопросы — вопросы в issues приветствуются, спорить в комментариях тоже готов.

