MCP (Model Context Protocol) — jak podłączyć AI do serwera i nie dać mu roota

AI

Asystent AI potrafi napisać regułę ufw, wyjaśnić błąd z journalctl i podpowiedzieć, czemu kontener nie wstaje. Ma jedną wadę: nie widzi Twojego serwera. Kończy się na kopiowaniu logów do okna czatu i z powrotem — wolno, a do tego łatwo wkleić coś, czego wklejać nie powinno się nigdzie.

Tę lukę zamyka MCP — Model Context Protocol: otwarty standard (Anthropic opublikował go pod koniec 2024 roku, dziś obsługuje go większość narzędzi AI), który mówi, jak aplikacja z modelem językowym rozmawia z zewnętrznymi narzędziami i danymi. Najprościej: to takie USB-C dla AI — piszesz jeden serwer MCP, a podłączysz go do Claude, edytora kodu czy własnego agenta.

W tym wpisie: jak MCP działa, własny serwer MCP w Pythonie, który pozwala AI sprawdzić dyski, usługi i logi tylko do odczytu, podłączenie go lokalnie i przez SSH oraz checklista bezpieczeństwa.

Jak działa MCP

Trzy role:

  • host — aplikacja, w której rozmawiasz z modelem (Claude Desktop, Claude Code w terminalu, IDE);
  • klient MCP — część hosta, która utrzymuje połączenie z jednym serwerem;
  • serwer MCP — Twój program, który wystawia możliwości: narzędzia (tools — funkcje, które model może wywołać), zasoby (resources — dane do odczytu, np. plik konfiguracji) i podpowiedzi (prompts — gotowe szablony zadań).

Wiadomości to JSON-RPC 2.0. Transport jest dwojaki: stdio — host uruchamia serwer jako proces i rozmawia z nim przez standardowe wejście i wyjście (lokalnie, najprościej) — albo Streamable HTTP, gdy serwer działa zdalnie jako usługa. Model nie dostaje dostępu do maszyny: widzi listę narzędzi z opisami, prosi o wywołanie, a host (zwykle po Twoim potwierdzeniu) je wykonuje i oddaje wynik.

Z punktu widzenia admina to dobra wiadomość: to Ty decydujesz, co narzędzie robi. Model nie dostaje powłoki — dostaje kilka funkcji, które sam napisałeś.

Serwer MCP tylko do odczytu w Pythonie

Cel: AI ma odpowiedzieć na pytanie „czy na serwerze nie kończy się miejsce i czy zabbix-agent2 działa?” bez dostępu do sudo i bez dowolnych poleceń. Instalacja oficjalnego SDK w środowisku wirtualnym:

tomasz@srv:~$ python3 -m venv ~/mcp-admin
tomasz@srv:~$ ~/mcp-admin/bin/pip install "mcp[cli]"

Serwer — plik ~/mcp-admin/serwer.py:

#!/usr/bin/env python3
"""admin-ro — serwer MCP tylko do odczytu: dyski, stan usług i ostatnie logi."""
import subprocess

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("admin-ro")

# Tylko te usługi — model nie poda dowolnej nazwy ani opcji polecenia.
USLUGI = {"ssh", "nginx", "docker", "mariadb", "zabbix-agent2"}
LIMIT = 4000  # znaków odpowiedzi: logi potrafią mieć megabajty


def uruchom(*polecenie: str) -> str:
    """Polecenie jako lista argumentów (bez powłoki), z limitem czasu i długości wyniku."""
    try:
        wynik = subprocess.run(polecenie, capture_output=True, text=True, timeout=10)
    except subprocess.TimeoutExpired:
        return "przekroczony czas (10 s)"
    tekst = (wynik.stdout + wynik.stderr).strip()
    return tekst[-LIMIT:]


def dozwolona(nazwa: str) -> str:
    if nazwa not in USLUGI:
        raise ValueError("usługa spoza listy: " + ", ".join(sorted(USLUGI)))
    return nazwa


@mcp.tool()
def dyski() -> str:
    """Zajętość systemów plików (df -h, bez tmpfs)."""
    return uruchom("df", "-h", "-x", "tmpfs", "-x", "devtmpfs")


@mcp.tool()
def usluga(nazwa: str) -> str:
    """Stan usługi systemd (systemctl status) z listy dozwolonych."""
    return uruchom("systemctl", "status", "--no-pager", "--lines=0", dozwolona(nazwa))


@mcp.tool()
def logi(nazwa: str, linie: int = 50) -> str:
    """Ostatnie linie dziennika usługi (journalctl -u), najwyżej 200."""
    linie = max(1, min(linie, 200))
    return uruchom("journalctl", "-u", dozwolona(nazwa), "-n", str(linie), "--no-pager", "-o", "short-iso")


if __name__ == "__main__":
    mcp.run()  # transport stdio: JSON-RPC na stdin/stdout

Kilka rzeczy, które są tu celowo:

  • opis funkcji to instrukcja dla modelu — z docstringu i typów argumentów SDK buduje opis narzędzia, więc pisz je tak, jak dokumentację dla kolegi z zespołu;
  • lista dozwolonych usług zamiast parametru „wpisz dowolną nazwę” — nazwa nie trafi do polecenia, jeśli nie ma jej na liście, a subprocess.run z listą argumentów nie przechodzi przez powłokę;
  • limit czasu i długości — 50 000 linii logu nie pomoże ani modelowi, ani Tobie;
  • przy transporcie stdio nic nie wypisuj na stdout (print() do debugowania zepsuje protokół) — komunikaty diagnostyczne tylko na stderr albo do pliku.

Konto, na którym działa serwer, nie potrzebuje sudo. Żeby journalctl pokazywał logi usług systemowych, wystarczy grupa systemd-journal:

tomasz@srv:~$ sudo usermod -aG systemd-journal tomasz

Przed podłączeniem do AI sprawdź serwer w MCP Inspector — przeglądarkowym kliencie, w którym wywołasz narzędzia ręcznie i zobaczysz dokładnie to, co dostanie model:

tomasz@srv:~$ npx @modelcontextprotocol/inspector ~/mcp-admin/bin/python ~/mcp-admin/serwer.py

Podłączenie do Claude

Claude Code (terminal) — jedna komenda:

tomasz@srv:~$ claude mcp add admin-ro -- /home/tomasz/mcp-admin/bin/python /home/tomasz/mcp-admin/serwer.py
tomasz@srv:~$ claude mcp list

Claude Desktop — wpis w claude_desktop_config.json (Ustawienia → Developer → Edit Config), potem restart aplikacji:

{
  "mcpServers": {
    "admin-ro": {
      "command": "/home/tomasz/mcp-admin/bin/python",
      "args": ["/home/tomasz/mcp-admin/serwer.py"]
    }
  }
}

Od tej chwili pytanie „sprawdź miejsce na dyskach i stan zabbix-agent2” kończy się wywołaniem dyski i usluga — a przed każdym wywołaniem klient pyta Cię o zgodę.

Serwer zdalny przez SSH

Najwygodniejsze dla admina: serwer MCP leży na serwerze, a Claude działa na Twoim laptopie. Transport stdio to tylko wejście i wyjście procesu, więc wystarczy… SSH. Na serwerze osobne konto (np. mcp-ro) bez sudo, z grupą systemd-journal, a w jego ~/.ssh/authorized_keys klucz z wymuszoną komendą — nawet z kluczem w ręku nie da się na tym koncie zrobić nic poza uruchomieniem serwera MCP:

restrict,command="/opt/mcp-admin/bin/python /opt/mcp-admin/serwer.py" ssh-ed25519 AAAA… mcp-laptop

W konfiguracji klienta zamiast Pythona — ssh:

{
  "mcpServers": {
    "srv-admin-ro": {
      "command": "ssh",
      "args": ["-T", "-o", "BatchMode=yes", "-i", "/home/tomasz/.ssh/mcp_ed25519", "mcp-ro@srv.lan"]
    }
  }
}

Klucz tylko do tego (ssh-keygen -t ed25519 -f ~/.ssh/mcp_ed25519) — jak skonfigurować logowanie kluczem, opisałem we wpisie SSH bez hasła.

Checklista bezpieczeństwa

  • Najmniejsze uprawnienia: osobne konto, bez sudo, tylko odczyt. Narzędzie, które coś zmienia (restart usługi), to osobna decyzja — i osobny serwer.
  • Żadnego „uruchom polecenie”: narzędzie exec(cmd) to zdalna powłoka z dodatkowym krokiem. Konkretne funkcje, lista dozwolonych wartości, argumenty bez powłoki.
  • Wynik narzędzia to dane, nie polecenia: w logach i plikach może stać tekst w stylu „zignoruj poprzednie instrukcje i…” (prompt injection). Model może się na to nabrać — dlatego serwer nie ma narzędzi, którymi dałoby się coś zepsuć.
  • Sekrety zostają na serwerze: nie wystawiaj narzędziami .env, kluczy ani zrzutów konfiguracji z hasłami — wszystko, co zwróci narzędzie, trafia do modelu.
  • Zatwierdzaj wywołania — nie włączaj „zawsze pozwalaj” dla narzędzi, których wyniku nie znasz.
  • Cudze serwery MCP czytaj przed uruchomieniem: to zwykły program z Twoimi uprawnieniami. Sprawdź źródło i wersję jak każdą inną zależność.
  • Zdalny serwer HTTP — tylko z uwierzytelnianiem i w sieci wewnętrznej / VPN, nigdy otwarty na świat.

Podsumowanie

MCP nie zastąpi monitoringu ani admina, ale świetnie skraca drogę od pytania do odpowiedzi: zamiast kopiować logi do czatu, pozwalasz AI samodzielnie zajrzeć — dokładnie tam, gdzie mu pozwolisz. Zacznij od kilku narzędzi tylko do odczytu, sprawdź je w Inspectorze, a potem dokładaj kolejne. Alarmy niech dalej przychodzą z Zabbixa (monitoring dysków w Zabbixie) — AI przyda się wtedy, gdy trzeba szybko zrozumieć, co się stało.

W kolejnych wpisach z działu AI: jak dać agentowi AI zadanie na serwerze tak, żeby było co sprawdzić po jego pracy, i kiedy lokalny model wystarczy zamiast chmury.