Допустим, у вас есть CLI-утилита на Swift. Это может быть скрипт деплоя, инструмент для миграции баз данных, генератор кода или обёртка над билд-процессом — что угодно полезное, что вы собрали на ArgumentParser за пару вечеров. А рядом работают Claude Code, Cursor и другие AI-агенты, которые всё чаще становятся частью нашей рутины.
Возникает вопрос — как научить агента пользоваться этой утилитой?
В идеале нужно сделать это так, чтобы агент не путал флаги, не забывал обязательные аргументы и не тратил тысячи токенов на пересказ вашего синтаксиса самому себе. И главное — чтобы разработчику не пришлось писать и постоянно актуализировать объёмные текстовые инструкции.
Я попробовал несколько способов решения этой задачи. В итоге остановился на протоколе MCP и собрал небольшую опенсорс-библиотеку — swift-argument-parser-mcp. Она превращает любой Swift CLI на базе ArgumentParser в MCP-сервер с минимумом усилий.
В статье расскажу, почему я выбрал именно MCP, с какими сложностями столкнулся при разработке и почему для уже готового Swift CLI это самый дешёвый в поддержке вариант: описание инструментов берётся прямо из кода и не отстаёт от него. Токены мы тоже посчитаем, но честно, с оговоркой о том, где MCP экономит, а где уступает альтернативам.
Три способа связать CLI-инструмент и AI-агента
Для подключения CLI к агенту существует три основных пути. Первые два быстро показывают свои ограничения на практике, а третий решает задачу на архитектурном уровне.
Способ 1. Bash
Самый простой, но при этом самый ненадёжный вариант — дать агенту инструмент Bash. В таком случае он просто пишет команды наугад.
Это работает ровно до первого нетривиального флага. Допустим, у нас есть команда my-cli release --env staging --skip-tests. Агент не передаст --env, потому что не знает об обязательности этого параметра. Или передаст --environment вместо --env. Ещё он может решить, что --skip-tests пишется без s на конце — просто потому что в соседнем проекте так принято.
В результате мы получаем бесконечные ретраи. Агент читает вывод --help на каждом шаге, сжигает токены, а скорость выполнения полезной работы сильно падает.
Способ 2. Markdown
Следующая ступень — использование Markdown-инструкций. Мы создаём файл my-cli.md и описываем наш инструмент. По сути, это правило (rule) для агента — то, что в Cursor лежит в .cursor/rules, а в Claude Code в CLAUDE.md. Клиент сам подгружает такой файл в системный промпт, чтобы агент всегда держал его содержание перед глазами.
# my-cli release
Собирает и заливает билд.
## Usage
my-cli release --env <staging|production> [--skip-tests] <path>
## Options
- `--env <env>` — обязательный, выбор между staging и production
- `--skip-tests` — пропустить тесты
Опираясь на это правило, агент старается не ошибаться. Если у вас всего одна команда — с этим можно жить. Но здесь кроются две фундаментальные проблемы.
Во-первых — токены. Файл с инструкцией подгружается при каждом обращении к агенту. На один инструмент уходит минимум 200–500 токенов. Если CLI-утилит много, системный промпт раздувается на 3–5 тысяч токенов. И агент перечитывает их перед каждым ответом.
Во-вторых — источник истины. Изменили флаг в Swift-коде — нужно править Markdown. Добавили подкоманду — снова обновлять текстовый файл. Если вы забыли это сделать, агент не узнает об изменениях и продолжит вызывать старую версию команды. Через месяц документация окончательно устареет, а о расхождениях вы узнаете только из логов или багрепортов.
Сразу оговорюсь: у этого подхода есть продвинутая инкарнация — её называют Skill, и по токенам она ведёт себя куда экономнее, чем тот подробный файл с примерами, что мы набросали выше. Первую проблему она и правда снимает. А вот вторую — расхождение с кодом — нет: Skill всё так же пишут и поддерживают руками. Ниже я ещё вернусь к Skills и честно сравню их с MCP.
Способ 3. MCP
MCP (Model Context Protocol) — стандарт, который инженеры из Anthropic опубликовали осенью 2024 года. Идея напоминает протокол LSP для редакторов кода. Сервер рассказывает клиенту о доступных инструментах, формате аргументов и ожидаемом результате. А клиент — в нашем случае AI-агент — сам решает, когда и как их вызывать.
Разницу с Markdown-подходом можно описать одной фразой — MCP даёт машинный контракт вместо текстового описания.
Выглядит это так. Агент отправляет запрос tools/list и получает массив инструментов с описанием в формате JSON Schema.
{
"name": "release",
"description": "Собирает и заливает билд.",
"inputSchema": {
"type": "object",
"properties": {
"env": { "type": "string", "enum": ["staging", "production"] },
"skip_tests": { "type": "boolean", "default": false },
"path": { "type": "string" }
},
"required": ["env", "path"]
}
}
JSON Schema обеспечивает жёсткую валидацию на стороне клиента. Если агент забудет передать env, MCP-клиент отклонит вызов ещё до запуска бинарника. Передаст env: "production2" — произойдёт то же самое. Агент получит ошибку валидации схемы и попробует снова, не дёргая саму утилиту с некорректными параметрами.
И ещё один важный момент — инструменты не висят в контексте постоянно. Клиент кэширует их на время сессии и не пересылает модели на каждый запрос. Вы не тратите бюджет токенов на утилиты, которые прямо сейчас не нужны агенту.
Когда я разбирался с этим протоколом, пришла очевидная мысль. В библиотеке ArgumentParser уже есть вся нужная информация — имена команд, типы данных, тексты для консольного хелпа, опциональность параметров и перечисления. Остаётся только извлечь эту схему и передать её MCP-клиенту.
Считаем экономию на токенах
Очевидный вопрос — чем этот подход реально лучше текстового файла с инструкциями? Я сделал несколько замеров в своих рабочих сессиях.
Цифры ниже приблизительные. Токенизация зависит от конкретного текста, кэширование — от особенностей клиента, а частота ошибок — от сложности запросов. Но порядок величин они отражают верно. И по ним чётко видно, на чём мы экономим бюджет.
Базовые токены на описание команды
Возьмём всё ту же команду release --env <staging|production>[--skip-tests] <path>. У неё три параметра — одно перечисление, один флаг и один позиционный аргумент.
Markdown-инструкция для такой команды занимает около 250–350 токенов. Структура стандартная — заголовок, блок Usage, список параметров с пояснениями, пример вызова. Вручную можно ужать текст до 180 токенов, но на практике никто этим не занимается.
MCP-инструмент с JSON Schema для этой же команды весит 120–180 токенов. JSON получается компактнее за счёт строгой структуры. Обязательность выражается полем required, а не целым предложением. Перечисление передаётся массивом строк, а не списком в текстовом описании.

Разница на одной команде — примерно в два раза. Но главная экономия скрывается не здесь.
Жизненный цикл токенов в сессии
Markdown-описание лежит в системном промпте и в худшем случае уходит в модель с каждым новым запросом. MCP-инструменты клиент подгружает иначе: один раз через механизм tools, который у большинства провайдеров кэшируется, а современные клиенты — Claude Code, Cursor — грузят их лениво, отдавая модели схему только когда инструмент реально понадобился. Вы не носите в контексте описания утилит, которые сейчас не нужны, — и не следите за их размером руками.
Ошибки и ретраи
Когда мы используем Markdown, агент собирает команду по памяти. Он опирается на текст инструкции и периодически ошибается. Забывает обязательный параметр, путает имя флага или передаёт строку туда, где требуется перечисление. Клиент не проверяет корректность до отправки — у него нет формальной схемы, только текст.
В случае с MCP клиент валидирует JSON по строгой схеме ещё до вызова утилиты. Если агент передал некорректные аргументы, клиент сразу возвращает ошибку. Агент её читает, исправляет данные, и только валидный вызов доходит до подпроцесса.
По моим наблюдениям на 200 запусках в смешанных сценариях картина такая:
- Markdown — 60–75% успеха с первой попытки. Остальное уходит на ретраи с чтением
--help. - MCP — 90–95% успеха с первой попытки.

Каждый ретрай — это ещё один полный цикл расхода токенов на ввод и вывод. Агент читает ошибку, думает, заново формирует вызов. По факту неудачная попытка удваивает стоимость запроса. Если учесть это в общих расчётах, разрыв между подходами становится ещё больше.
Итоговая экономия
Если сложить размер описаний, повторные отправки в контекст и ретраи, то против rule-файлов, которые клиент пересылает модели целиком на каждый запрос, подход с MCP расходует в 5–10 раз меньше входных токенов.
Здесь есть важная оговорка. Это общая оценка для всей сессии целиком — с учётом кода, пользовательских запросов и файлов в контексте. В реальной работе остальные данные разбавляют эти метрики.
Разрыв увеличивается, если:
- В проекте много команд — объём Markdown растёт линейно, а MCP остаётся практически константой из-за кэширования.
- Агент часто ошибается без валидации — на сложных командах доля ретраев быстро растёт.
И всё это — без учёта того, что MCP даёт один источник истины. Пока вы поддерживаете команду на ArgumentParser, сервер автоматически пересобирает её MCP-представление при каждом старте. Вам не нужно вручную синхронизировать файлы. Переименовали флаг с --env на --environment — схема обновилась сама.
Всё, что выше, я считал против rule-ов, которые пишут руками и которые клиент шлёт модели целиком. Но можно сделать умнее — использовать скиллы: компактную инструкцию плюс bash-обёртка над утилитой.
Идея в том, чтобы убрать из описания всё лишнее. Команды перечисляют списком без примеров — модель и так знает bash, ей достаточно знать, что команда есть. Вывод утилиты подрезают флагами --json, --compact, --page-size. А современные клиенты больше не держат описания в контексте постоянно: Claude Code подгружает их лениво через ToolSearch, Cursor читает с диска по запросу. Те же приёмы, что характерно, работают и для MCP — ленивая подгрузка снимает и его стартовый overhead.
Когда обе стороны доработаны, разрыв по токенам схлопывается. Публичные бенчмарки последнего года расходятся в выводах: где-то оптимизированный CLI+Skill выходит в разы дешевле MCP, где-то наоборот. Однозначного победителя на голых токенах нет — всё упирается в тип задачи и клиента.
Так что, если быть честным, токены не тот фронт, где MCP громит всех. Настоящая разница вылезает дальше — когда документацию приходится поддерживать.
Где MCP выигрывает без оговорок
И вот здесь начинается то, ради чего всё затевалось — единственный источник истины. Skill экономит токены ровно до тех пор, пока кто-то руками держит его в синхроне с кодом: подобрал команды, ужал описание, расставил флаги вывода и переделывает всё это при каждом изменении CLI. Это та же проблема с рулами, просто доработанная. Переименовали флаг — Skill об этом не узнает.
MCP, собранный из ArgumentParser, снимает эту работу целиком. Пока вы поддерживаете команду на ArgumentParser, сервер пересобирает её MCP-представление при каждом старте. Никакого второго файла, никакой ручной синхронизации, никакого дрейфа. Переименовали --env на --environment и схема обновилась сама.
Внутреннее устройство моста между ArgumentParser и MCP
В библиотеке swift-argument-parser есть полезный скрытый флаг — --experimental-dump-help. Если запустить бинарник с этим флагом, утилита выдаст полное дерево команд в формате JSON.
my-cli --experimental-dump-help
В ответ мы получим структуру с описанием всех аргументов, опций и подкоманд.
{
"serializationVersion": 1,
"command": {
"commandName": "my-cli",
"abstract": "...",
"arguments": [
{
"kind": "option",
"preferredName": { "name": "env", "kind": "long" },
"isOptional": false,
"allValues": ["staging", "production"]
}
],
"subcommands": [...]
}
}
Это та же самая информация, на основе которой ArgumentParser генерирует консольный вывод для --help. Разница лишь в том, что формат машиночитаемый.
Дальше работа библиотеки сводится к трём шагам.
Шаг 1. Получить дамп команд
Сначала утилита запускает собственный исполняемый файл со скрытым флагом и парсит полученный JSON.
let output = try processRunner.run(
executablePath,
arguments: ["--experimental-dump-help"]
)
let toolInfo = try JSONDecoder()
.decode(DumpHelpOutput.self, from: output.data)
Шаг 2. Собрать схему
Для каждой разрешённой команды мы формируем объект MCP Tool со строгой JSON-схемой. За этот процесс отвечает SchemaBuilder. Он проходит по списку аргументов и переводит их свойства в формат, понятный клиенту.
switch argument.kind {
case .flag:
schema["type"] = .string("boolean")
schema["default"] = .bool(argument.defaultValue == "true")
case .option, .positional:
if argument.isRepeating {
schema["type"] = .string("array")
schema["items"] = .object(["type": .string("string")])
} else {
schema["type"] = .string("string")
}
if let allValues = argument.allValues {
schema["enum"] = .array(allValues.map { .string($0) })
}
}
Шаг 3. Обработать вызов от агента
Когда агент присылает запрос tools/call, мы берём переданные аргументы из JSON, конвертируем их обратно в привычные консольные флаги и запускаем целевой бинарник как подпроцесс.
switch argInfo.kind {
case .flag:
if case .bool(let v) = value, v {
cliArgs.append("--\(cliName)")
}
case .option:
cliArgs.append("--\(cliName)")
cliArgs.append(stringValue(from: value))
case .positional:
positionalArgs.append((index, stringValue(from: value)))
}
На этом конвертация заканчивается. Дальше остаётся только запустить сам MCP-сервер через официальный пакет swift-sdk и подключить стандартные обработчики для методов ListTools и CallTool.
Настройка консольной утилиты для работы с Cursor и Claude
Перевести свой CLI-инструмент на MCP можно в три простых шага. Для этого вам не придётся переписывать существующую логику или создавать отдельное приложение.
Шаг 1. Подписываем команду под протокол
Берём обычную команду ArgumentParser и добавляем ей протокол MCPCommand.
struct Release: ParsableCommand, MCPCommand {
static let configuration = CommandConfiguration(
abstract: "Собирает и заливает билд.",
discussion: """
Собирает проект, прогоняет тесты и заливает артефакт в указанное \
окружение. Staging использует тестовые секреты и демо-данные, \
production — боевые.
Если нужно быстро проверить сборку, запускайте с --skip-tests.
"""
)
@Option var env: Environment
@Flag var skipTests = false
@Argument var path: String
mutating func run() throws { /* ... */ }
}
Шаг 2. Создаём подкоманду для запуска сервера
Добавляем подкоманду mcp. Она инициализирует и стартует сервер, передавая ему список доступных инструментов.
struct MCP: AsyncParsableCommand {
mutating func run() async throws {
let server = MCPServer(
name: "my-cli",
version: "1.0.0",
commands: [Release.self]
)
try await server.start()
}
}
Шаг 3. Регистрируем сервер в клиенте
Прописываем вызов нашей новой подкоманды в конфигурации Claude Code или Cursor.
{
"mcpServers": {
"my-cli": {
"command": "/usr/local/bin/my-cli",
"args": ["mcp"]
}
}
}
На этом всё. Агент видит инструмент release со строгой JSON Schema. Он знает допустимые значения для env, понимает, что skip_tests — это булево значение с дефолтом false, и помнит про обязательный позиционный аргумент path.
Один источник истины для консоли и агента
Отдельно стоит пояснить, откуда в схеме берётся поле description — то самое текстовое описание, по которому агент понимает назначение команды.
Протокол MCPCommand по умолчанию берёт его из полей abstract и discussion конфигурации CommandConfiguration. Это те же самые тексты, которые ArgumentParser использует для генерации привычного консольного вывода.
Получается, что один текст работает сразу в двух ролях. Разработчик в терминале читает его через my-cli release --help, а агент получает это же содержание через tools/list. Вам не нужно писать и синхронизировать два параллельных описания — для человека и для LLM. Документация живёт рядом с кодом, и ни одна из сторон не отстаёт от другой.
Я выбрал такой интерфейс сознательно. Утилита продолжает быть обычной CLI — вы всё так же запускаете её из терминала. Подкоманда mcp просто переводит бинарник в режим сервера вместо штатной работы. Вы получаете один исполняемый файл и два режима использования. Не нужно создавать отдельные таргеты, продумывать сложный деплой или поддерживать разные конфигурации.
Неочевидное поведение ArgumentParser на практике
Сама идея, как видите, звучит и реализуется довольно просто. Но на практике я столкнулся с неочевидным поведением системы в тех местах, где вроде бы ничего не должно было сломаться. Расскажу о двух таких ситуациях — при решении похожих задач они почти наверняка всплывут снова.
Путь к собственному бинарнику
Мне нужно было получить путь до исполняемого файла — того самого, который работает прямо сейчас. На первый взгляд задача решается в одну строку, но в Swift нет простого ответа без обращения к C-API.
Интуитивный вариант с CommandLine.arguments[0] не решает задачу. Это просто нулевой аргумент командной строки — строка, с которой запустили программу. Она зависит от способа запуска. Вызов ./my-cli даёт относительный путь. Запуск my-cli через $PATH — только имя без директории. Запуск через симлинк вернёт путь к самому симлинку, а не к реальному файлу.
MCP-серверу необходимо запускать самого себя для снятия дампа команд. Из-за CommandLine.arguments[0] половина запусков тихо ломалась. Локально у меня всё работало отлично, а на другой машине при установке утилиты через Homebrew вызовы падали.
Правильный способ зависит от платформы. Об обоих вариантах стоит знать — они требуются в системной разработке гораздо чаще, чем кажется.
Darwin (macOS, iOS). Здесь помогает функция из libc — _NSGetExecutablePath. Она возвращает абсолютный путь к исполняемому файлу, который ядро фактически использовало для загрузки процесса. Это информация из внутренних структур операционной системы, она не зависит от способа запуска. Сигнатура у функции архаичная. Она принимает буфер и указатель на его длину. Если места в буфере не хватило — возвращает ненулевое значение и пишет в переменную реально нужный размер. После этого можно аллоцировать больше памяти и вызвать функцию снова.
Linux. Подобной функции в libc нет, но выручает виртуальная файловая система procfs. В ней для каждого процесса есть директория /proc/<pid>/ с системной информацией. Для текущего процесса предусмотрен удобный алиас /proc/self/. Внутри лежит символическая ссылка exe — она всегда указывает на исполняемый файл. Читаем её обычным readlink и получаем надёжный абсолютный путь.
Обе ветки я закрыл условной компиляцией:
#if canImport(Darwin)
var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
var length = UInt32(buffer.count)
guard _NSGetExecutablePath(&buffer, &length) == 0 else {
throw MCPServerError.unableToDetectCurrentExecutablePath
}
#else
let length = readlink("/proc/self/exe", &buffer, buffer.count - 1)
#endif
Это хорошее напоминание — в Swift всё ещё встречаются места, где приходится спускаться на уровень C-API и работать с памятью вручную.
Булев флаг без дефолтного значения
Во время вызова --experimental-dump-help библиотека ArgumentParser не выдаёт параметр defaultValue для обычного @Flag. Для самой библиотеки всё логично — флаг без значения всегда означает false.
Но для MCP такая логика не работает. Схема без явно указанного default означает для агента «значение неизвестно». Из-за этого AI начинает честно прикреплять skip_tests: false к каждому вызову — даже когда это лишено смысла. Ничего критичного не происходит, но мы получаем визуальный шум в логах и тратим лишние токены.
Решение заняло ровно одну строку. Если перед нами флаг и значение не указано — значит это false:
case .flag:
schema["default"] = .bool(argument.defaultValue == "true")
В этом кроется важное правило работы со сторонними форматами. Они часто умалчивают о вещах, которые создателям кажутся очевидными. Если вы пишете мост между двумя разными системами — будьте готовы достраивать эту очевидность руками.
Коротко о преимуществах машинных контрактов
Когда мы используем Markdown-инструкции для подключения консольной утилиты к AI-агенту, мы делаем двойную работу. Сначала описываем аргументы в коде. А затем пересказываем то же самое простым текстом — чтобы модель нас поняла. И неважно, подробный это Markdown-файл или аккуратно ужатый Skill: и то, и другое — второе описание команды, которое живёт параллельно коду и однажды от него отстанет.
На одной-двух командах с этим можно смириться. Но когда их становится больше десятка, ручная синхронизация начинает отнимать время. Текстовый файл неизбежно отстаёт от реального кода. Из-за этого агент путается в параметрах, а мы тратим лишние токены на постоянную пересылку объёмного промпта.
Протокол MCP решает эту проблему на уровне архитектуры. Он полностью избавляет от дублирования — сервер берёт описание параметров напрямую из исходного кода приложения. Поля abstract и discussion работают одновременно как вывод для --help в терминале и как понятная инструкция для LLM. Переименовали параметр или добавили новый флаг — всё обновится автоматически при следующем запуске утилиты. Ничего не придётся актуализировать руками.
На этом всё. Забирайте библиотеку swift-argument-parser-mcp и пробуйте подключить свои инструменты к агентам. А если что-то пойдёт не так или вам не хватит какой-то функции — смело заводите issue или присылайте PR в репозиторий.




