Свой MCP-сервер для агента: read-only tools при helper'е с write-доступом к GitHub

Соло-разработчик, который подключает к Cursor собственный FastMCP-мост к GitHub, рискует перепутать «у меня нет write-tools» с реальной безопасностью: в разборе на Dev.to автор показывает, что общий HTTP-helper принимал POST и DELETE, хотя все три объявленных tool'а ходили только GET. Для любого, кто собирает MCP-слой под coding agent, это не теория — достаточно одного нового tool'а, копипаста или слишком ретивого агента, чтобы read-only сценарий опёрся на функцию с полным доступом к репозиториям.
FastMCP как мост между агентом и GitHub
В репозитории my-git-manager автор держит небольшой FastMCP-сервер (server.py): через него coding agent читает GitHub-профиль, список репозиториев и статистику по репо, а также — по описанию автора — статьи с Dev.to. Типичный соло-паттерн: один pet-project вместо корпоративного SecOps, свой набор MCP-tools под IDE.
Триггером аудита стал трендовый пост про write-доступ агента к публичным репозиториям. Первая реакция автора — «не моя проблема, write-tools у меня нет». Затем он открыл не объявления tool'ов, а реализацию общего helper'а — и нашёл разрыв между намерением и кодом.
Что может underlying function, а не только tool list
Все три GitHub-tool'а вызывают один helper _gh(path, method="GET", data=None):
| Tool | Назначение | Вызов |
|---|---|---|
get_github_profile |
профиль | GET /users/{GITHUB_USERNAME} |
list_repos |
список репо | GET /users/{GITHUB_USERNAME}/repos?... |
get_repo_stats |
метаданные репо | GET /repos/{GITHUB_USERNAME}/{repo} |
Ни один call site не передаёт data и не меняет method — дефолтный GET. Но сам helper строит запрос к https://api.github.com{path} через urllib.request.Request с параметром method; при наличии data тело сериализуется в JSON. Вызов с method="POST" или method="DELETE" технически возможен — параметр не зафиксирован на уровне функции.
Заголовок поста формулирует контраст жёстко: helper мог POST и DELETE, а каждый tool, который его вызывал, использовал только GET. Story «no write tools» верна по конвенции вызывающих, но не закреплена в коде.
Токен repo и blast radius одного credential
Токен из os.environ['GITHUB_TOKEN'] подставляется в каждый вызов _gh — и для read-only tool'ов, и для гипотетического write. В docs/project_notes/key_facts.md того же репозитория задокументированы scope repo, user. Scope repo — не «прочитать метаданные», а полный read/write ко всем репозиториям аккаунта, включая private: create, push, delete.
Автор отделяет этот кейс от другого своего поста, где в одном процессе жили два credential (GITHUB_TOKEN и DEV_TO_API). Здесь фокус на одном токене с избыточным scope при helper'е шире фактического использования — тот же паттерн, что и с substring-based attribution filter в том же репо (general-purpose helper шире реального сценария).
На момент аудита реальных write-вызовов не было: риск описан как теоретический — будущий tool, плохой edit, compromised или over-eager agent.
Guard на уровне helper: least privilege с трением
Фикс минимален и намеренно оставляет трение для будущих write-tool'ов:
if method != "GET":
raise ValueError(f"_gh is read-only — got method={method!r}")
Проверка стоит до сети: после патча server._gh(..., method="DELETE") бросает ValueError до urlopen — DELETE «never reached urlopen at all». Stub urllib.request.urlopen подтверждает: get_github_profile() — один GET, форма ответа прежняя; попытка DELETE — исключение без сетевых вызовов.
Если понадобится настоящий write-tool, придётся сознательно расширить guard — а не случайно унаследовать POST/DELETE из общего клиента.
Чеклист для своего MCP под Cursor
Перед тем как доверить агенту MCP-сервер в продакшен-соло-воркфлоу, имеет смысл пройти тот же путь, что автор:
- Grep по call site общего HTTP-клиента — смотреть не «что делают tools по названию», а что может underlying function и какие HTTP-методы она принимает.
- Сверить credential с фактическим использованием: scope токена может быть write-capable, даже если все текущие tool'ы read-only.
- Закрепить ограничение в коде, а не в комментарии к tool list — конвенция вызывающих не защищает от нового tool'а или ошибки агента.
MCP обещает агенту прозрачный слой инструментов; без такого аудита «у меня только GET-tools» остаётся заявлением о сегодняшнем коде, а не гарантией на завтра.