Писать контент через MCP-сервер получается на удивление хорошо. Вы просите агента написать запись для журнала изменений, он читает три последние, пишет черновик в том же тоне и присылает ссылку.
Потом вы просите добавить скриншот из папки «Загрузки», и агент упирается в стену.
Вызовы инструментов в MCP передают аргументы в JSON. В текущей спецификации нет ни типа «файл», ни сообщения для загрузки, ни способа, которым клиент мог бы передать серверу байты. У рабочей группы File Uploads есть черновик (SEP-2631), который такой способ предлагает, но в спецификацию он не вошёл, и ни один клиент его пока не поддерживает.
Поэтому, если вы хотите, чтобы агент вставлял картинки в то, что пишет, передачу файлов придётся проектировать самим. Я только что сделал это для MCP-сервера Kookee. Ниже — что я пробовал, что в итоге сделал и какие детали оказались важными.
Почему очевидные решения не работают
Base64 в аргументе инструмента. Аргументы инструментов генерирует модель, токен за токеном. Скриншот на 100 КБ — это около 136 КБ в base64, то есть десятки тысяч выходных токенов ещё до того, как что-то сохранилось. Хуже того, байтов у модели обычно вообще нет. Она видит картинку, которую вы вставили в разговор, но воспроизвести её байты не может — она бы их выдумала.
«Пришли URL, я скачаю». Импорт на стороне сервера превращает ваш API в прокси, который скачивает всё, на что укажет агент, в том числе то, к чему его склонит промпт-инъекция. Внутренние адреса, эндпоинты метаданных облака, редиректы на то и другое: поверхность для SSRF, которую потом приходится защищать привязкой DNS, лимитами редиректов и проверкой содержимого. К тому же у файла на вашем ноутбуке всё равно нет URL.
Поднять лимит на размер тела запроса. Лимит тела на MCP-эндпоинте проверяется до аутентификации. Поднимете его для загрузок — поднимете и для каждого анонимного запроса.
Схема, которая работает: подписать, загрузить, подтвердить
Браузеры решили эту задачу много лет назад. Панель управления не гонит картинку через свой API-сервер: сервер подписывает одноразовую загрузку, браузер отправляет байты прямо в объектное хранилище, а потом сообщает серверу, что готово.
Агент с доступом к терминалу — это браузер с curl. Поэтому в MCP те же три шага:
create_upload { fileName, mimeType, size } → fileId, подписанный POST, готовая команда curl
curl … -F 'file=@"./diagram.png"' → 204 от хранилища
confirm_upload { fileId, alt } → публичный URL + фрагмент, который встраивает файл
Байты вообще не проходят через MCP-сервер. Вызовы инструментов весят несколько сотен байтов, лимит тела остаётся прежним, а загрузки попадают в то же хранилище, с теми же квотами и той же очисткой, что и файлы, добавленные в панели управления.
Честная цена: нужен агент, который умеет запускать команды. Claude Code, Cursor в режиме агента и Codex CLI умеют. Чат-приложение, которое не может запускать команды на вашем компьютере, загрузить файл не сможет, поэтому описание инструмента велит агенту так и сказать и отправить пользователя в панель управления, а не импровизировать.
Туда же движется и сам протокол. По черновику SEP-2631 загрузку сначала разрешают, затем возвращают подписанный адрес назначения, байты загружает клиент, а инструмент получает ссылку на файл. Та же схема, только curl запускает клиент, а не модель. Серверная половина, которую вы делаете сегодня, пригодится и потом.
Четыре детали, от которых зависит безопасность
1. Не верьте «готово»
Подтверждению от браузера обычно верят: если он говорит, что загрузка закончилась, сервер так и считает. Агент же с той же готовностью подтвердит загрузку после curl, который упал, а потом вставит несуществующую картинку.
Поэтому при подтверждении сервер спрашивает само хранилище — HeadObject по ключу — и отказывает, если там ничего нет. Заявленный размер тоже закрепляется в политике загрузки. Это вопрос денег: политика подписанного POST принимает любой размер до лимита на файл, если не подписать точный content-length-range, а квоты хранилища обычно считают заявленный размер. Заявите один байт, загрузите 25 МБ — и квота ничего не заметит. Подпишите точный размер, и хранилище отклонит несовпадение с кодом 400.
2. Подписанный тип содержимого — тот, что отдаёт ваш CDN
Каждое поле подписанного POST становится условием политики на точное совпадение, включая Content-Type. Это хорошо — загружающий не может его поменять, — но это значит, что подписанный тип ваш публичный CDN и будет отдавать.
Поэтому MIME-тип, который присылает агент, нужно сверить с белым списком до подписи. Иначе агент — или промпт-инъекция, которую он прочитал три вызова назад, — сможет опубликовать text/html на домене вашего CDN. Я принимаю ровно те типы, что принимает панель управления, и никакие другие.
3. Экранируйте сгенерированную команду так, будто она враждебна
Сервер отдаёт агенту готовую к запуску команду: если попросить модель собрать multipart POST из JSON-объекта, она почти наверняка ошибётся в какой-нибудь мелочи. В команде есть имя файла, которое выбрал агент и которое подписано в поле формы, — тут и ловушки.
В curl -F значение, которое начинается с @ или <, — не значение, а указание прочитать файл. Имя файла вроде @notes.png в подписанном поле заставило бы curl прочитать файл с диска. Поэтому каждое подписанное поле уходит через --form-string, где всё понимается буквально, а файл идёт последним, потому что поля после него S3 игнорирует:
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`;
const command = [
`curl -sS --fail -o /dev/null -w '%{http_code}\\n' ${shellQuote(uploadUrl)}`,
...Object.entries(fields).map(([name, value]) => `--form-string ${shellQuote(`${name}=${value}`)}`),
`-F 'file=@"<LOCAL_FILE_PATH>"'`,
].join(' \\\n ');
Вторая половина — локальный путь. Он оказывается внутри одинарных кавычек оболочки и внутри имени файла в двойных кавычках для curl, так что путь с ' ломает оболочку, а путь с " или \ ломает curl. Вместо третьего слоя экранирования, в котором модель запутается, правило простое: если в пути есть один из этих символов, скопируйте файл по простому временному пути и загрузите копию.
Имена файлов проверяются в обработчике, а не только в схеме. Я отклоняю слеши и управляющие символы, включая диапазон C1, от U+0080 до U+009F, который наивная проверка «меньше 0x20» пропускает. А регулярное выражение в схеме держите в чистом ASCII: шаблон экспортируется в JSON Schema клиента без флага u, поэтому клиентские валидаторы неправильно читают Unicode-класс вроде \p{Cc} и отклоняют любое имя, в котором есть p или c.
4. Пусть агент привязывает то, что загрузил
На этом я и обжёгся.
В редакторе загруженная картинка вставляется вместе с id файла, и по этому id система знает, что запись использует файл. Файл, на который ничто не ссылается, через несколько дней удаляет сборщик мусора. Так убираются загрузки, которые люди бросили на полпути.
Дайте агенту URL, и он напишет то, что пишет любая модель: . Без id. Сегодня картинка показывается нормально, файл ни к чему не привязан, а через три дня он исчезает из опубликованной статьи. Ошибки нет нигде — тихая потеря данных с задержкой.
Поэтому confirm_upload возвращает фрагмент с id, а инструменты записи отклоняют Markdown, в котором URL загрузки появляется без id, с ошибкой, которая велит агенту вставить этот фрагмент.
Первая версия этой проверки отклоняла любой URL с моего CDN без id. И заодно блокировала совершенно безопасные операции записи: копию или перевод поста, картинку в котором вставили по URL, или комментарий со ссылкой на PDF, который уже использует другая запись. Агент получал ошибку на Markdown, который скопировал без изменений, и никакого id добавить не мог. Исправление — найти файл и отказывать, только если он ещё ни к чему не привязан: это единственный случай, когда данные действительно теряются.
Урок шире этого случая: проверка, написанная для агента, должна целиться в сбой, а не в форму входных данных. С правилом, которое строже самой проблемы, агент договориться не может.
Как это выглядит
Загрузи ./diagram.png, поставь под заголовок «Архитектура» с коротким alt-текстом, а ./cover.png сделай обложкой.
Агент дважды вызывает create_upload, запускает две команды curl, подтверждает обе загрузки, вставляет схему через edit_entry_content и ставит обложку через update_entry. В тексте записи появляется блок вроде этого — он читается обратно байт в байт, и текст вокруг него можно править, как вокруг любого другого блока:
<p><img src="https://cdn.kookee.dev/…/diagram.png" alt="Путь запроса от агента до хранилища" data-id="…"/></p>
Если вы добавляете загрузку в свой MCP-сервер
Подписывайте загрузки. Никогда не гоняйте байты файла через вызов инструмента MCP.
Подтверждайте, спрашивая хранилище, и подписывайте заявленный размер как точный content-length-range.
Сверяйте MIME-типы с белым списком до подписи: подписанный тип — тот, что отдаёт ваш CDN.
Отправляйте подписанные поля через
--form-string, ставьте файл последним и объясните агенту, что делать с неудобными путями.Проверяйте имена файлов в обработчике; шаблоны в схеме держите в ASCII.
Ограничивайте частоту подписей. Загрузки, которые так и не подтвердили, обычно не попадают в квоты хранилища.
Возвращайте фрагмент, который привязывает файл, и отклоняйте непривязанную форму — но только для файлов, которые действительно потеряются.
Напишите в описании инструмента, что для загрузки нужен терминал, чтобы клиенты без него сообщали об этом пользователю, а не падали на полпути.
MCP-сервер Kookee теперь загружает картинки и файлы из Claude Code, Cursor и других агентов, которые умеют запускать команды. Настройка описана в статье Загрузка картинок и файлов из ИИ-ассистента, а общая картина — в посте Как подготовить контент продукта для ИИ-агентов.