# Как загрузить файл через MCP и не слить все токены

> Почему агенту нельзя передавать файлы аргументом в MCP и как грузить их мимо контекста модели.

Автор: [Александр Майоров](https://alexandermayorov.com/index.md) · Опубликовано: 9 октября 2026 · Теги: MCP, агенты, загрузка файлов

Файл можно загрузить через MCP, но передавать его прямо в вызове MCP плохая идея. Я делаю по-другому: MCP-сервер даёт агенту ссылку на загрузку, а файл агент отправляет сам через `curl` из терминала. В контекст модели файл не попадает, и токены на него не уходят.

У меня есть проект, где агент работает через MCP-сервер, и туда нужно загружать файлы и картинки. Когда мне понадобилось добавить картинку, агент ответил: «Картинку агенту не передать: файл кладёт человек, агент только ставит ссылку». И предложил схему, где я должен открыть браузер, зайти в хранилище и загрузить файл руками.

Меня это не устроило. Ниже рассказываю, как сделал так, чтобы агент загружал файлы сам.

## Почему нельзя передать файл прямо в вызове MCP

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

Что получается:

- **Файл целиком проходит через контекст.** Агент читает файл и переписывает его в вызов символ за символом. Картинка на 300 КБ в base64 весит около 400 КБ, это больше ста тысяч токенов.
- **Агенту не нужно знать, что внутри файла.** Ему надо только положить файл куда нужно и вставить ссылку в текст.
- **Модель может ошибиться в base64.** Она пишет строку заново. Одна ошибка в символе, и на сервер приходит битый файл.
- **Лимиты.** Большой PDF или презентация в один вызов просто не поместятся.

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

## Решение: файл идёт мимо MCP через curl

Я разнёс это на два потока. Через MCP агент узнаёт, куда загружать, и получает результат. А сам файл отправляет обычным HTTP-запросом через `curl`. Модель пишет короткую команду с путём к файлу, дальше файл читает и отправляет уже `curl`.

По сути это то же самое, что presigned URL в S3: сервер выдаёт временную ссылку, и клиент загружает файл по ней напрямую. Только клиент тут AI-агент с терминалом.

1. **Агент**: просит у MCP-сервера ссылку на загрузку
2. **MCP-сервер**: выдаёт ссылку на 5 минут и готовую команду curl
3. **curl**: отправляет файл мимо контекста модели
4. **Сервер**: проверяет файлы и возвращает готовый markdown

У меня два варианта:

1. **Ссылка от MCP.** Агент вызывает инструмент MCP, получает ссылку и готовую команду `curl` и запускает её. Подходит, когда заранее неизвестно, какие будут файлы и куда их класть.
2. **MCP только подтверждает.** Агент по инструкции из скилла загружает файл через `curl` в хранилище и сообщает MCP, что файл загружен. MCP-сервер и хранилище живут на одном сервере и строят путь к файлу по одному правилу, так что ссылку передавать не нужно. Сервер проверяет, что файл на месте и не пустой, и отмечает задачу выполненной. Подходит, когда заранее известно, какой файл и куда.

В обоих вариантах агент файл не читает. Модель видит только короткий запрос и короткий ответ.

## Как агент загружает картинку: пошагово

Покажу на первом варианте. Имена и адреса условные.

### Шаг 1. Агент получает ссылку

Агент вызывает инструмент MCP и передаёт только ID записи, к которой относятся файлы. Сервер возвращает ссылку, которая живёт 5 минут и работает только для этой записи, и готовую команду:

```
{
  "ok": true,
  "upload_url": "https://example.com/upload/<ключ>",
  "expires_at": "09.10.2026 15:05",
  "curl": "curl -sS -F \"file1=@/путь/к/картинке.png\" \"https://example.com/upload/<ключ>\""
}
```

В описании инструмента прямо написано: файлы через MCP не передавать, `curl` запускать там, где лежат файлы. Без этого модель иногда всё равно пытается засунуть файл в вызов.

### Шаг 2. Агент запускает curl

```
curl -sS -F "file1=@./Схема.png" -F "file2=@./Отчёт.pdf" "https://example.com/upload/<ключ>"
```

За один запрос можно отправить несколько файлов, у каждого своё поле. В команде только пути к файлам, так что токенов она почти не тратит.

### Шаг 3. Сервер сохраняет файлы

Сервер проверяет ссылку, кладёт файлы в папку записи и сразу переименовывает: «Схема.png» превращается в `skhema.png`. В ответе готовые строки markdown, которые агент вставляет в текст:

```
{
  "ok": true,
  "saved": [
    {"file": "skhema.png", "markdown": "![подпись](files/skhema.png)"},
    {"file": "otchet.pdf", "markdown": "[отчёт](files/otchet.pdf)"}
  ],
  "errors": [],
  "next": "Вставь строки из saved[].markdown в текст и сохрани запись."
}
```

Поле `next` подсказывает агенту следующий шаг. Ответ `curl` агент читает так же, как ответ MCP.

### Шаг 4. Агент сохраняет запись

Агент вставляет строки в текст, правит подписи и сохраняет запись через MCP.

В итоге я пишу агенту одну фразу: «загрузи картинки из этой папки и вставь их в текст». Дальше он всё делает сам.

### Эндпоинт загрузки на сервере

Упрощённый пример на TypeScript для Node.js с Express и multer. Как генерировать и проверять ключ, не показываю, это вы пишете сами:

```
import express, { type NextFunction, type Request, type Response } from "express";
import multer from "multer";
import { MAX_FILE_BYTES, MAX_FILES } from "./config";
import { storeFile, type SavedFile } from "./storage";

type Grant = { recordId: string };

export function makeUploadKey(recordId: string): string {
  ...
}

export function verifyUploadKey(key: string): Grant | null {
  ...
}

const app = express();
const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: MAX_FILE_BYTES, files: MAX_FILES },
});

app.get("/upload/:key", (_req: Request, res: Response) => {
  res.status(405).json({
    ok: false,
    message: 'Только POST multipart/form-data: curl -F "file1=@файл.png" <эта ссылка>',
  });
});

app.post(
  "/upload/:key",
  (req: Request, res: Response, next: NextFunction) => {
    const grant = verifyUploadKey(req.params.key);
    if (grant === null) {
      res.status(401).json({ ok: false, message: "Ссылка неверная или истекла. Получи новую через MCP." });
      return;
    }
    res.locals.grant = grant;
    next();
  },
  upload.any(),
  async (req: Request, res: Response) => {
    const grant = res.locals.grant as Grant;
    const files = (req.files ?? []) as Express.Multer.File[];
    const saved: SavedFile[] = [];
    const errors: { name: string; error: string }[] = [];

    for (const file of files) {
      const result = await storeFile(grant, file);
      if ("error" in result) {
        errors.push({ name: file.originalname, error: result.error });
        continue;
      }
      saved.push(result);
    }

    res.status(saved.length === 0 ? 422 : 200).json({ ok: errors.length === 0, saved, errors });
  },
);

app.listen(3000);
```

Тексты ошибок я пишу для модели. На 405 агент видит правильную команду, на 401 понимает, что нужна новая ссылка. Подробнее об этом я писал в статье [«MCP: как дать AI доступ к вашим продуктам и данным»](https://alexandermayorov.com/ru/articles/mcp-guide.md).

## Безопасность: что проверять при загрузке

Агенту я доверяю не больше, чем любой форме на сайте. Поэтому проверяю:

- **Срок жизни ссылки.** У меня 5 минут. Агенту хватает, а старая ссылка из логов или переписки уже не сработает.
- **Привязка к записи.** По ссылке для одной записи нельзя загрузить файл в другую. Подделать или продлить ссылку на стороне клиента нельзя.
- **Расширения и размер.** Принимаю только то, что нужно. Обычно это картинки и офисные документы.
- **Содержимое файла.** Если прислать текстовый файл с расширением `.png`, сервер вернёт ошибку «Это не картинка».
- **Никаких SVG.** В SVG можно встроить скрипт. Если файлы отдаются с домена, где пользователи залогинены, скрипт получит доступ к их сессии.
- **Имя файла задаёт сервер.** Из присланного имени делается только ключ: транслит, нижний регистр, дефисы. Путь вида `../../.env` не пройдёт.
- **Логи.** Каждая загрузка пишется в лог: какие файлы и куда.

## Где это работает, а где нет

Нужен агент с терминалом и доступом к файлам: Claude Code, Cowork, Cursor или свой агент на сервере. В обычном чате claude.ai или ChatGPT агент не видит файлы на вашем компьютере, так что отправить их ему нечем.

Поэтому ручную загрузку я не убирал. В инструкции MCP-сервера написано: если есть файлы и терминал, загружай сам, если нет, дай человеку ссылку для ручной загрузки. Агент сам решает, как поступить.

### Ограничения

- Агенту нужен выход в интернет из терминала. Если песочница закрыта, домен для загрузки надо разрешить отдельно.
- Клиент может спросить разрешение на запуск `curl`: команда отправляет данные наружу.
- Если агент долго искал файлы и ссылка протухла, он получит 401 и запросит новую.
- Файл с тем же именем перезаписывает старый. Для картинок в тексте это удобно, для архива документов нет.
- Картинку агент не видит. Если подпись нужна по содержанию, ему придётся открыть файл отдельно.

## Что запомнить

- Параметры вызова MCP пишет модель, поэтому всё, что туда попадает, идёт через контекст и стоит токенов.
- Файлы в вызове MCP я не передаю. MCP выдаёт ссылку или правило, а файл уходит обычным HTTP через `curl`.
- Ответ сервера при загрузке пишу для модели: готовые строки для вставки, подсказка, что делать дальше, понятные ошибки.
- Для клиентов без терминала оставляю ручную загрузку.

## Частые вопросы

### Можно ли передать файл через MCP?

Можно, если закодировать его в base64 и передать строкой в вызове. Но тогда файл целиком идёт через контекст модели, сжигает токены, и модель может его испортить. Надёжнее, когда MCP выдаёт ссылку на загрузку.

### Чем это лучше base64?

Модель пишет одну короткую команду вместо сотен тысяч символов. Файл доходит без искажений, а размер ограничен только настройками сервера.

### Работает ли это в обычном чате Claude или ChatGPT?

Нет. В чате у агента нет терминала и доступа к вашим файлам. Там файл придётся загрузить вручную через браузер.

### Безопасно ли давать агенту ссылку на загрузку?

Если сервер проверяет всё, что перечислено выше, это не опаснее обычной формы загрузки на сайте.

### Нужен ли для этого отдельный сервис?

Нет. Загрузку принимает один обработчик в том же бэкенде, где работает MCP-сервер.

> Если нужно, чтобы агенты работали с файлами в вашем продукте, [напишите мне](https://alexandermayorov.com/#contacts). Помогу сделать MCP-сервер и загрузку файлов.

---

- HTML-версия: https://alexandermayorov.com/ru/articles/mcp-file-upload-curl
- Другой язык: [English](https://alexandermayorov.com/en/articles/mcp-file-upload-curl.md)
- llms.txt: https://alexandermayorov.com/llms.txt
