MCP write-tool без проверки «есть ли что менять»: урок из трёх итераций hardening

Соло-разработчик, который подключает агенту MCP write-tools для публикаций, рискует не перезаписью контента, а лишними сетевыми вызовами и шумным журналом аудита: инструмент может сходить в API и «обновить» пост, хотя ни одно поле не передано. Разбор enjoy_kumawat про update_article в MCP-сервере своего репозитория показывает редкий случай, когда усиление безопасности записи и корректного сравнения изменений не закрывает дыру на входе функции.
Как устроен update_article и что уже чинили
В MCP-сервере репозитория автора есть tool update_article: на Python он ходит в Dev.to API — GET и PUT на /articles/{article_id} — и обновляет статьи через REST. Изначально tool принимал голый article_id и отправлял переданные поля напрямую в API; при неверном id это могло молча перезаписать уже опубликованный пост без следа.
Первый слой hardening добавил fetch-before-write и журнал аудита каждого изменения. Второй — исправил сравнение в логе: раньше оно было захардкожено как пара {title, published} независимо от реально изменённых полей. Правка только body_markdown в логе выглядела как «ничего не изменилось» — хуже, чем отсутствие записи.
Оба фикса отвечали на вопрос как tool пишет и как логирует. Ни один не проверял, есть ли вообще поля для обновления.
Пустой payload всё равно идёт в сеть
Сигнатура tool выглядит так:
def update_article(article_id: int, title: str = None, body_markdown: str = None, published: bool = None) -> dict:
Все параметры, кроме article_id, по умолчанию None — «не трогать поле». Вызов update_article(article_id=123) без остальных аргументов оставляет словарь article пустым {}. Ранний выход отсутствует: выполняется fetch текущей статьи, затем PUT с {"article": {}} на опубликованный пост. Результат попадает в журнал аудита как осмысленное действие.
Автор воспроизвёл поведение на stub: подменил сетевой слой (fake_dev) и вызвал tool только с id. Сетевые вызовы — GET /articles/123, затем PUT /articles/123 с data={"article": {}}. В логе: {"article_id": 123, "fields_changed": [], "url": "https://Dev.to/x/old"}. Пустой fields_changed визуально похож на «ничего не произошло», но фиксирует, что tool отработал. Журнал, созданный чтобы оставлять след после плохой записи, теперь же записывает вызовы, которые не должны были пройти «на входе».
Автор не выполнял live empty
PUTв production и не проверял, как Dev.to server отвечает на пустойarticle. Gap воспроизводим на клиенте без записи в живой контент.
Как агент попадает в пустой вызов с побочным эффектом
Баг связан не с намеренным пустым вызовом, а со случайными вызовами агента:
- забытый аргумент при вызове инструмента;
- передача
body=вместо ожидаемогоbody_markdown=; - спекулятивный вызов только с обязательным
article_id, когда optional-параметры молча трактуются как «не трогать».
Схемы MCP-tool не запрещают вызов только с required-аргументами — это следствие дизайна optional-параметров. В docstring tool нет требования «хотя бы одно из title / body_markdown / published обязательно». Для соло-разработчика, который даёт агенту write-tools в IDE, это практический риск: лишние API-вызовы, шумный лог, потенциально опасный PUT на опубликованный контент — даже если данные формально не меняются.
В том же репозитории у автора уже есть паттерн read-only guard у вспомогательной функции _gh. Урок поста: «сделать write осторожнее» и «проверить, есть ли что писать» — разные защиты, их нужно добавлять отдельно.
Защита до сети: что предлагает автор
Фикс повторяет идиому read-only guard:
- если после сборки
articleсловарь пуст —raise ValueError("update_article called with no fields to update (title/body_markdown/published all None)"); - fetch
before = _dev(...)перенесён после guard, чтобы лишнийGETне выполнялся до проверки.
На исправленной версии повтор repro даёт исключение и network calls made: [] — ноль запросов. Логика сравнения и журнал аудита не маскируют вызов.
Для своего MCP-сервера автор рекомендует зафиксировать в docstring и схеме tool требование «≥1 поле для update» и клиентскую защиту до сети — по аналогии с _gh. Публичный URL репозитория в посте не указан; какой именно MCP host (Cursor, Claude Desktop или другой) автор использовал — тоже не раскрыто.
Источники
- My MCP Tool Fetches Before It Writes and Logs Every Change. It Never Checked Whether There Was Anything to Change. — enjoy_kumawat, Dev.to, опубликовано 8 августа 2026; дата доступа: 10 августа 2026.