При разборе инцидентов я всё чаще использую ИИ-агента: он быстро собирает информацию о сервере — нагрузку, память, диски, процессы, журналы — и сводит её с данными мониторинга, чтобы понять, почему тормозит сайт, чем забит диск или что упало ночью. По его выводам я могу попросить агента и решить проблему, а для этого ему может понадобиться перезапустить сервис, изменить конфиг или даже настройки ядра. Дать ему для этого SSH с sudo — не самый безопасный путь. Мне хотелось попробовать другой подход. Посмотрим, что из этого получилось.
Я захотел, чтобы агент видел сервер через набор понятных инструментов, root получал только на то, что я разрешил, и чтобы каждый его шаг был записан. А из-за большой любви к Kubernetes и его объектной модели мне захотелось так же управлять и операционной системой: представить её как дерево объектов — процессы, диски, сервисы, файлы — и работать с ними привычными командами вроде get и describe. Для этого я создал утилиту linuxctl. Так появился linux-mcp-daemon — демон mcpd и клиент linuxctl. В этой статье расскажу, как он устроен, как поставить его за пару минут и подключить к ИИ-агенту, и на какие грабли я наступил по дороге.
Что уже есть
Прежде чем писать своё, я посмотрел, что уже сделано. Протокол для подключения инструментов к ИИ-агентам — MCP (Model Context Protocol), и Linux-серверов для него хватает. Условно они делятся на три типа:
-
Удалённый shell под соусом MCP. Инструмент «выполнить команду» плюс белый список. Проблема в том, что список обычно проверяет только первое слово команды, а команда уходит в
sh -c—ls; rm -rf ~проходит. По сути это тот же SSH, только с иллюзией контроля. -
Только чтение, локально. Хорошие проекты с десятками read-only инструментов, работают через stdio на той же машине, что и агент. Безопасно, но агент работает на моём ноутбуке, а сервер далеко. И сделать ничего нельзя, только посмотреть.
-
SSH напрямую. Про него я уже написал выше.
Ни в одном из найденных вариантов не было того, что мне было нужно. Я хочу строить системы, в которых ИИ-агент, зная архитектуру сервиса, сам собирает информацию со всех серверов, участвующих в инциденте. Для этого нужен сетевой доступ с аутентификацией, а не локальный процесс на каждой машине, а ещё разделение пользователей и выдача root по одному инструменту.
Коротко про MCP
MCP — открытый протокол поверх JSON-RPC. Агент подключается к серверу и спрашивает, что тот умеет. Для нас важны два вида возможностей:
-
Tools — функции, которые модель вызывает сама, когда сочтёт нужным. У каждой есть имя, описание и JSON-схема аргументов. В спецификации они называются model-controlled. У mcpd на сегодня их 38:
processes/top,services/manage,files/readи другие. -
Resources — данные с адресом-URI, которые подключает к контексту приложение, а не модель (application-controlled). У mcpd 11 постоянных ресурсов (
os://release,network://routes,devices://pci…) и 6 шаблонов:service://{name}/status,file:///{path},process://{pid}/{target}и т. д.
Транспортов два. При stdio клиент сам запускает сервер у себя дочерним процессом. По HTTP сервер работает отдельно, и клиент к нему подключается. mcpd сетевой: он живёт на сервере, агент ходит к нему по HTTPS.
Разница не формальная. В апреле OX Security показала, чем опасен stdio: клиент выполняет команду из своего конфига, чтобы запустить сервер, — и выполнит её, даже если это не сервер, а что угодно. Достаточно подменить конфиг, и на машине разработчика запустится чужой код. Изъян есть во всех официальных SDK, чинить протокол Anthropic отказалась. У mcpd этой проблемы нет: клиент ничего у себя не запускает, официальные SDK не используются. А главный совет исследователей — запускать MCP-процессы изолированно и с минимальными правами — в mcpd сделан по умолчанию.
Как устроен mcpd
Демон написан на Go, это один статический бинарник. Инструменты вместо shell. Агент не выполняет команды — он вызывает инструменты с понятными именами вида группа/команда: processes/top, disks/usage, logs/journal-control, services/manage, files/read и другие, на сегодня их 38. Для каждого инструмента (tool) сервер выдаёт описание и JSON-схему аргументов, так что агент сам понимает, что умеет сервер.
Kernel-first. Большинство данных mcpd читает сам — из /proc, /sys и у systemd по D-Bus, — а не запускает ps, df или systemctl и не парсит их вывод. Внешние программы вызываются только там, где своя реализация вышла бы хуже: smartctl, traceroute, journalctl, dmesg, last и find. Например, журнал systemd хранится в бинарном формате со сжатием, а библиотека libsystemd потребовала бы cgo и лишила бы нас статического бинарника. Все они запускаются без shell, с отдельными аргументами.
Каждый вызов — отдельный процесс от имени пользователя. Главный процесс mcpd только принимает запрос, проверяет токен и права. Сам инструмент он не выполняет: для каждого вызова запускается новый короткоживущий процесс (worker) от имени Linux-пользователя с тем же именем, что у пользователя mcpd. Worker выполняет одну команду, отдаёт результат и завершается. Пользователь alice в mcpd — это аккаунт alice в Linux: что ему запрещает ядро, то не сделает и агент.
Root — по одному инструменту и с границами. В файле mcp-sudo.yaml для каждого пользователя перечислено, какие инструменты он может вызвать с privileged: true, то есть от root. И не просто «может», а в каких пределах:
users: logs: privileged: tools: files/read: allowed: true paths: [/var/log] # root только внутри /var/log logs/journal-control: allowed: true network/curl: network: deny_private: true # никаких localhost, 10/8, 169.254.169.254
Для инструментов с путями paths обязателен: без него конфиг не загрузится. Хотите весь диск — пишите paths: ["/"] явно, чтобы это было видно. Если root не выдан, mcpd так и отвечает:
$ linuxctl tool files/read --path /root/.bashrc --privileged trueuser mcp is not authorized to run files/read on path /root/.bashrc as root
TLS по умолчанию. При первом запуске mcpd сам генерирует самоподписанный сертификат, клиенты доверяют ему по отпечатку. Открытый HTTP в конфиге есть, но выключен.
Всё записано. Каждый вызов пишется в лог демона (journald или docker logs): пользователь, инструмент, аргументы (секреты вроде токенов вырезаются) и итог вызова. Вызовы, которые меняют систему, помечаются audit и пишутся при любом уровне логирования.
Мои стенды
Всё, что ниже, я гонял на трёх установках одновременно:
-
локальный Kubernetes в Docker Desktop на Mac — основной стенд для разработки;
-
VPS с Ubuntu 24.04 (1 CPU, 2 ГБ): mcpd через systemd;
-
тот же VPS: mcpd в Docker-контейнере на соседнем порту.
Изменение я считал готовым, только когда оно задеплоено на все три и проверено вживую. Несколько багов проявились только на одном из стендов.
Установка
На сервере с systemd — одна команда:
curl -fsSL https://raw.githubusercontent.com/nucleusv/linux-mcp-daemon/main/scripts/install.sh | sudo bash
Скрипт скачает релиз, проверит sha256, создаст конфиги без единого пользователя, заведёт первого пользователя mcp и напечатает его токен (один раз — в конфиге хранится только солёный хеш) и отпечаток сертификата. Есть и пакеты .deb/.rpm, и образ ghcr.io/nucleusv/linux-mcp-daemon.
Проверяем с любой машины, где стоит linuxctl (он есть и под macOS):
export MCP_SERVER=https://my-server:9091export MCP_TLS_FINGERPRINT=sha256:... # из вывода install.shexport MCP_TOKEN=...linuxctl get system os-release
OS Release Info:PRETTY_NAME="Ubuntu 24.04 LTS"...Kernel Info:Linux 213625.com 6.8.0-142-generic #142-Ubuntu SMP PREEMPT_DYNAMIC Wed Sep 2 14:24:27 UTC 2026 x86_64
linuxctl — клиент в духе kubectl: get, describe, create, update, delete поверх групп инструментов. Список команд он не хранит, а каждый раз читает схему у демона, так что новый инструмент на сервере сразу доступен в клиенте. explain показывает, какие команды есть у группы и во что они превращаются (описания сокращены):
$ linuxctl explain disks get -> tool disks/list get free -> tool disks/free get usage -> tool disks/usage get mounts -> tool disks/mounts get partitions -> tool disks/partitions get performance -> tool disks/performance get health -> tool disks/health describe <name> -> template disks://{name}/stats$ linuxctl get processes --sort_by mem --limit 3 --output tablePID USER COMM STATE PPID RSS_BYTES CMDLINE967 root dockerd S 1 107339776 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock274 root systemd-journal S 1 65556480 /usr/lib/systemd/systemd-journald813 root containerd S 1 63471616 /usr/bin/containerd
Для bash и zsh есть автодополнение: Tab дополняет глаголы, группы, флаги конкретного инструмента и даже URI ресурсов вроде network://interfaces. Подсказки тоже берутся у демона, поэтому каждый пользователь видит только то, что ему разрешено. Как включить — описано в документации.
$ linuxctl get disks <Tab>free health mounts partitions performance usage
Подключаем агента
Для Claude Code достаточно:
export NODE_EXTRA_CA_CERTS=~/.config/mcpd/my-server.crt # сертификат с сервераclaude mcp add --transport sse my-server https://my-server:9091/sse \ --header "Authorization: Bearer <token>"
После этого агент видит инструменты сервера и сам решает, какими пользоваться. Вот что он получает, например, от processes/top (реальный вывод с VPS):
top - 17:15:34 up 23:51, 1 user, load average: 0.15, 0.06, 0.01Tasks: 114 total, 1 running, 113 sleeping, 0 stopped, 0 zombie%Cpu(s): 1.0 us, 1.9 sy, 0.0 ni, 96.1 id, 0.0 wa, 0.0 hi, 0.0 si, 1.0 stB Mem : 2063577088 total, 509673472 free, 305860608 used, 1248043008 buff/cacheB Swap: 536866816 total, 536592384 free, 274432 used. 1552297984 avail Mem PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 66914 mcp 20 0 1297649664 10428416 6569984 R 2.0 0.5 0:00.03 mcpd
Это не вызов top: всё прочитано из /proc, а %CPU посчитан так же, как считает top, — по двум замерам с интервалом. Размеры по умолчанию в байтах, чтобы агенту не пришлось разбирать 1.8G; для людей есть human_readable. В последней строке виден сам worker этого вызова: процесс mcpd, запущенный от пользователя mcp.
Обратите внимание: где «узкие» права на самом деле root
Главная страница документации — не про установку, а про риски. Некоторые права выглядят узкими, но на деле равны полному root:
-
files/updateс путями, покрывающими/etc, — это/etc/sudoers.d/, cron, systemd-юниты; -
services/manage— любой юнит, включаяsshи сам mcpd; -
kernel/system-controlбез списка разрешённых ключей —kernel.core_patternзапускает программу от root при каждом падении процесса; -
files/readна/— это/etc/shadowи приватный ключ самого mcpd; -
аккаунт пользователя в группе
docker— root без всяких прав в mcpd.
И отдельно: network/curl без блока network: ходит откуда угодно, в том числе на 169.254.169.254 облачного провайдера. Агента, которому «подложили» инструкцию в логе, можно попросить сходить за внутренним URL и пересказать ответ.
Грабли, на которые я наступил
MCP-пользователь по имени root. Раз вызов идёт от аккаунта с тем же именем, пользователь root в mcpd получал root на каждый вызов — мимо всех ограничений mcp-sudo.yaml. Теперь такой конфиг просто не загружается, а linuxctl не даст такого пользователя создать.
Симлинки. Проверка paths делается по пути, а симлинк может увести куда угодно: /var/www/x → /etc/shadow. Теперь при ограниченных путях файлы открываются по одному компоненту с O_NOFOLLOW, и на симлинк mcpd отвечает refusing to follow a symbolic link. А при paths: ["/"] симлинки открываются как обычно — выходить там некуда.
Два разных «privileged». В Docker есть флаг --privileged, а у вызова есть аргумент privileged: true — и это совсем разные вещи. Флаг Docker даёт контейнеру возможность выйти на хост, аргумент вызова просит root на один вызов. Самое неприятное нашлось при проверке: контейнер, запущенный без --pid host, видел свой собственный PID 1, и вызов с privileged: true тихо выполнялся от root внутри контейнера. Агент думал, что работает с сервером, а работал с образом. В v0.3.4 такой вызов падает с понятной ошибкой:
cannot switch to the host's filesystem - is the mcpd container running with --privileged --pid host?
Нестабильный тест в релизе. Тест сравнивал список сокетов с выводом ss, и на занятом раннере GitHub между двумя снимками успевал проскочить DNS-запрос. Релиз упал на ровном месте. Теперь тест повторяет сравнение, пока снимки не совпадут, — настоящая ошибка в разборе повторяется каждый раз.
Что дальше
В ближайших планах — read-only инструменты для Docker (контейнеры, логи, статистика, одним снимком), сводка «здоровья» системы одним вызовом, чтобы агенту не делать десяток запросов, и аудит безопасности: SUID-файлы, открытые на запись каталоги, sshd.
Проект открытый, лицензия Apache 2.0:
-
документация: https://nucleusv.github.io/linux-mcp-daemon/
-
страница про права и риски: https://nucleusv.github.io/linux-mcp-daemon/configuration/permissions-and-risks/
Буду рад вопросам, баг-репортам и историям, как вы даёте агентам доступ к своим серверам. Удачных вам диагностик и спокойных ночей без пейджера 🙂
ссылка на оригинал статьи https://habr.com/ru/articles/1086976/