opsmcp: jak dać AI dostęp do serwerów i nie dać mu shella

Diagram opsmcp jako bezpiecznej warstwy MCP między narzędziami AI a serwerami Linux

AI całkiem dobrze radzi sobie z pierwszą częścią administracji: przeczyta logi, skojarzy błąd z usługą, podpowie komendę. Schody zaczynają się wtedy, gdy przestaje podpowiadać, a zaczyna wykonywać polecenia na serwerze.

Nie mam problemu z tym, że model wymyśli systemctl restart nginx. To prosta komenda. Mam problem z pomysłem, żeby model dostał zwykłe SSH i mógł odpalić cokolwiek, co uzna za dobry następny krok. W homelabie też potrafi to zaboleć, a na serwerach produkcyjnych tym bardziej.

Dlatego zrobiłem opsmcp. To serwer MCP, który działa jako warstwa pośrednicząca między AI a serwerami. Głównie pisałem go z myślą o n8n, ale powinien pasować też do Hermes AI, opencode i każdego klienta, który umie gadać po MCP. Agent nie dostaje shella. Dostaje listę narzędzi, które wcześniej opisałem w konfiguracji.

Prompt nie jest zabezpieczeniem

Najprostsza integracja z AI wygląda kusząco. Dajesz agentowi dostęp do SSH, piszesz „sprawdź logi nginx”, agent odpala komendę i odpowiada. Potem prosisz o restart usługi. Potem o sprawdzenie kontenerów. Potem pojawia się prośba, której nie przewidziałeś, albo model wybiera dobrą komendę, ale na złym hoście.

Da się dopisać w prompcie, że ma uważać. Tylko że prompt nie jest mechanizmem kontroli dostępu. Model może źle zrozumieć intencję, użytkownik może źle nazwać kontener, a narzędzie z za szerokim zakresem nadal pozostaje narzędziem z za szerokim zakresem.

opsmcp odcina ten problem w prostszy sposób: AI widzi tylko te operacje, które operator jawnie dopuścił.

Narzędzia są opisane w YAML, nie wymyślane w locie

opsmcp jest napisany w Go i wystawia narzędzia MCP przez streamable HTTP pod /mcp. Każde narzędzie to konkretna operacja SSH opisana w pliku YAML: opis, komenda, parametry, hosty i poziom ryzyka.

Przykład z konfiguracji:

tools:
  service_status:
    description: "Check systemd service status on the host"
    command: ["systemctl", "is-active", "{{service}}"]
    risk: read
    groups: [web, docker]
    parameters:
      service:
        type: enum
        values: [nginx, docker, vector]

Agent widzi narzędzie service_status, opis i schemat wejścia. Nie widzi dowolnego SSH. Nie może podmienić wartości na nginx; rm -rf /, bo service jest enumem z trzema dozwolonymi wartościami. Jeśli wywołanie nie pasuje do schematu, opsmcp odrzuca je przed połączeniem z hostem.

Tam, gdzie enum nie wystarcza, można użyć stringa, ale też z ograniczeniami. Parametr ma regex, max_length i przechodzi przez obowiązkowy bezpieczny zestaw znaków. To jest mało efektowne, ale bardzo potrzebne. Przy narzędziu, które ma dotykać serwerów, wolę nudną walidację niż ładną katastrofę.

Host też nie jest dowolnym tekstem

W każdym narzędziu jest domyślny parametr host, ale opsmcp nie traktuje go jak zwykłego tekstu od użytkownika. Serwer buduje enum z hostów opisanych w hosts.yaml. Jeśli narzędzie ma filtry groups albo hosts, lista hostów zawęża się do maszyn, na których ta operacja ma sens.

Można więc zrobić narzędzie dostępne tylko dla grupy web, tylko dla jednej maszyny albo dla przecięcia tych dwóch warunków. Jeśli w konfiguracji podasz i groups, i hosts, host musi spełnić oba.

To brzmi jak drobiazg, ale w praktyce chroni przed bardzo zwyczajnym błędem: dobra operacja, zła maszyna.

Komenda jest argv, a nie wolnym shellem

Najważniejsza granica w opsmcp jest prosta: klient MCP nie przysyła komendy do wykonania. Przysyła nazwę narzędzia i parametry.

Komenda siedzi w YAML jako szablon argv. Parametry są podstawiane dopiero po walidacji. Serwer nie uruchamia lokalnego shella, a polecenie wysyłane kanałem SSH exec składa z POSIX-owym quotowaniem argumentów. Do tego dochodzi obowiązkowy safe charset dla wartości podanych w czasie wywołania.

W praktyce oznacza to, że:

  • operator wcześniej opisuje każde narzędzie,
  • parametry mają typy enum, integer albo string,
  • integer musi mieć min i max,
  • string musi mieć regex i max_length,
  • sekrety nie trafiają do YAML,
  • klucze hostów są pinowane albo sprawdzane przez known_hosts,
  • host_key_accept_any: true jest tylko opcją developerską.

Nie nazwałbym tego sandboxem do kodu. To raczej panel z kilkoma przyciskami. Każdy przycisk ma opis, zakres i poziom ryzyka.

RO do czytania, ADMIN do rzeczy groźniejszych

opsmcp ma dwa tokeny: RO i ADMIN. Narzędzia mają trzy poziomy ryzyka: read, safe_write i destructive.

Przykładowa polityka wygląda tak:

risk_policy:
  read: auto
  safe_write: auto
  destructive: approval

Domyślnie read i safe_write mogą działać z tokenem RO. Operacje destructive wymagają tokena ADMIN. Jeśli workflow z tokenem RO spróbuje wykonać narzędzie destrukcyjne, opsmcp zwróci 403 zanim otworzy SSH.

To dobrze pasuje do n8n. Zwykły agent może sprawdzać statusy, dysk, kontenery i logi. Restart usługi albo kontenera można przepuścić przez akceptację człowieka, na przykład w Telegramie. Dopiero taki zatwierdzony workflow używa poświadczenia z tokenem ADMIN.

To nie zwalnia z normalnej administracji. Nadal trzeba ustawić sudoers, pilnować sekretów i rozsądnie dobrać narzędzia. Różnica jest taka, że droga od „pokaż logi” do „wykonaj dowolny shell” nie istnieje w samym opsmcp.

Jak to wygląda w n8n

W najprostszym układzie opsmcp działa obok n8n w Dockerze. MCP Client Tool w n8n łączy się z http://opsmcp:3000/mcp. Do zwykłych operacji używasz poświadczenia z tokenem RO, a do workflow z akceptacją człowieka osobnego poświadczenia z tokenem ADMIN.

Przykładowy scenariusz:

  • użytkownik pyta: „co działa na docker01?”,
  • agent wywołuje docker_ps,
  • użytkownik prosi: „pokaż logi nginx”,
  • agent wywołuje docker_logs dla wybranego kontenera,
  • użytkownik chce restartu kontenera,
  • agent prosi o potwierdzenie parametrów,
  • n8n uruchamia akceptację,
  • po zgodzie idzie docker_restart z tokenem ADMIN.

O taki poziom automatyzacji mi chodziło. AI pomaga w operacjach, ale nie dostaje kluczy do piwnicy.

Audyt jest po każdym wywołaniu

Każde wykonanie narzędzia zapisuje jedną linię JSON. W logu są: czas, nazwa narzędzia, host, parametry, poziom ryzyka, zakres tokena, skrócony identyfikator tokena, exit code i czas trwania.

Stdout komendy nie trafia do audytu, bo docker logs potrafi zwrócić dużo tekstu. Audyt ma odpowiedzieć na pytanie „kto i co uruchomił”, a nie przechowywać pełne wyjście komendy.

Do sprawdzania życia procesu jest /healthz. Jeśli ustawisz OPSMCP_METRICS=1, opsmcp wystawi też /metrics w formacie Prometheus.

Uruchomienie bez kombinowania

Repo zawiera Dockerfile z runtime opartym o distroless static nonroot, docker-compose, przykładowe pliki hosts.example.yaml i tools.example.yaml, Makefile oraz CI w Forgejo Actions.

Szybki start lokalnie:

cp .env.example .env
mkdir -p opsmcp
cp hosts.example.yaml opsmcp/hosts.yaml
cp tools.example.yaml opsmcp/tools.yaml
mkdir -p opsmcp/secrets
docker compose up -d
curl http://localhost:3000/healthz

Przed uruchomieniem warto zwalidować konfigurację:

make check-config

W repo są też release’y z binarkami dla sześciu platform: Linux, macOS i Windows, każda w wariantach amd64 oraz arm64. Obraz Dockera jest publikowany w repo.noop.re/drops/opsmcp.

Czego opsmcp celowo nie robi

Nie ma tu interaktywnego SSH, SCP, SFTP, tuneli ani zarządzania flotą. Nie ma też wbudowanego panelu zgód, wielu ról ani rozbudowanego RBAC.

To świadomy wybór. opsmcp ma być wąską warstwą między klientem MCP a serwerami. Ma przyjąć nazwę narzędzia, sprawdzić parametry, pilnować zakresu tokena i wykonać zdefiniowaną operację przez SSH. Reszta zostaje tam, gdzie i tak ją trzymasz: w n8n, Prometheusie, logach, sudoers i zarządzaniu sekretami.

Im mniej rzeczy robi ta warstwa, tym łatwiej mi ocenić, czy mogę jej zaufać.

Gdzie mi się to przydaje

Najlepiej widzę opsmcp przy powtarzalnych czynnościach administracyjnych:

  • sprawdzenie statusu usługi,
  • pobranie ostatnich logów kontenera,
  • szybka diagnostyka dysku albo sieci,
  • kontrolowany restart wybranej usługi,
  • workflow w n8n, który najpierw diagnozuje, a dopiero po zgodzie wykonuje zmianę,
  • homelab, gdzie chcesz wygody AI, ale nie chcesz wystawiać dowolnego SSH.

Jeśli potrzebuję pełnej sesji administracyjnej, odpalam normalne SSH. Jeśli chcę dać agentowi kilka bezpiecznych przycisków, opsmcp jest dużo lepszym miejscem pośrednim.

Kod i pierwszy test

Kod jest tutaj: repo.noop.re/drops/opsmcp.

Najprościej zacząć od hosts.example.yaml i tools.example.yaml. Dopisz jedną operację tylko do odczytu, odpal make check-config i podepnij MCP Client Tool w n8n. Agent przestaje zgadywać komendy, a Ty nie musisz mu oddawać całej konsoli.