О чём эта статья и о чём она не. Это инженерный разбор того, как устроен 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_activatemarzban_users_deactivatemarzban_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_deletemarzban_core_restart и marzban_config_update — последний перезаписывает конфигурацию ядра целиком и перезапускает его, роняя все активные соединения. Отдавать это модели «как есть» нельзя.

Профили как первый рубеж

У каждого инструмента объявлен scopereadwrite или 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_CONFIRMalways — подтверждать каждый раз, auto (по умолчанию) — один раз на инструмент за сессию, off — для автоматизации, где человека в цикле нет по определению.

И отдельный крючок skipConfirm для превью:

skipConfirm: args => args.dryRun === true,

marzban_config_update с dryRun: true считает дифф и ничего не пишет. Гейтить превью тем же подтверждением, что и реальную запись, — верный способ приучить пользователя жать «да» не глядя.


Проблема третья: контекст стоит денег

Ответ API на список пользователей — это JSON, где у каждого аккаунта полтора десятка полей, включая массивы строк подключения. Сто пользователей — десятки тысяч токенов. Модели для ответа на «у кого заканчивается доступ» нужны три поля из пятнадцати.

Вывод разделён на два слоя. View знает предметную область — умеет отформатировать байты и таймстемпы, знает, какие поля важны. Renderer предметную область не знает — он раскладывает уже спроецированные строки в texttable или 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 на удалении — не потому что не станет, а потому что такого поля в объявлении нет.


Ссылки

Код открыт под MIT. Если будете делать свой MCP‑сервер и упрётесь в те же вопросы — вопросы в issues приветствуются, спорить в комментариях тоже готов.