Обратно в блог
  • AI
  • backend

Будьте в курсе всех событий

Подключаем Swift CLI к AI-агентам: как сэкономить токены и не утопить контекст в Markdown

Допустим, у вас есть 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, а не целым предложением. Перечисление передаётся массивом строк, а не списком в текстовом описании.

01.png

Разница на одной команде — примерно в два раза. Но главная экономия скрывается не здесь.

Жизненный цикл токенов в сессии

Markdown-описание лежит в системном промпте и в худшем случае уходит в модель с каждым новым запросом. MCP-инструменты клиент подгружает иначе: один раз через механизм tools, который у большинства провайдеров кэшируется, а современные клиенты — Claude Code, Cursor — грузят их лениво, отдавая модели схему только когда инструмент реально понадобился. Вы не носите в контексте описания утилит, которые сейчас не нужны, — и не следите за их размером руками.

Ошибки и ретраи

Когда мы используем Markdown, агент собирает команду по памяти. Он опирается на текст инструкции и периодически ошибается. Забывает обязательный параметр, путает имя флага или передаёт строку туда, где требуется перечисление. Клиент не проверяет корректность до отправки — у него нет формальной схемы, только текст.

В случае с MCP клиент валидирует JSON по строгой схеме ещё до вызова утилиты. Если агент передал некорректные аргументы, клиент сразу возвращает ошибку. Агент её читает, исправляет данные, и только валидный вызов доходит до подпроцесса.

По моим наблюдениям на 200 запусках в смешанных сценариях картина такая:

  • Markdown — 60–75% успеха с первой попытки. Остальное уходит на ретраи с чтением --help.
  • MCP — 90–95% успеха с первой попытки.
3.png

Каждый ретрай — это ещё один полный цикл расхода токенов на ввод и вывод. Агент читает ошибку, думает, заново формирует вызов. По факту неудачная попытка удваивает стоимость запроса. Если учесть это в общих расчётах, разрыв между подходами становится ещё больше.

Итоговая экономия

Если сложить размер описаний, повторные отправки в контекст и ретраи, то против 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 в репозиторий.

  • AI
  • backend

Будьте в курсе всех событий

Ещё по этой теме