Za każdym razem, gdy dodaję nowy serwer MCP do Claude Code, jest taki moment: patrzę na listę narzędzi, czytam jednolinijkowy opis każdego z nich i klikam zatwierdź. To wszystko. Taki jest cały przegląd bezpieczeństwa, jaki większość ludzi, łącznie ze mną, robi, zanim da jakiemuś programowi możliwość wywoływania funkcji w swoim imieniu.
Problem w tym, że rzecz, którą zatwierdziłem, i rzecz, która faktycznie się wykonuje, to mogą być dwa różne obiekty, rozdzielone czasem. Ta luka ma nazwę: tool poisoning, oraz jego kontynuację, rug pull.
Na czym właściwie polega ten atak
Serwery MCP udostępniają swoje narzędzia przez wywołanie tools/list, które zwraca nazwę, opis i schemat parametrów dla każdego z nich. Opis jest przeznaczony dla modelu, nie dla człowieka. To ta część protokołu, która mówi LLM-owi, co dane narzędzie robi i kiedy go użyć, i w większości klientów to też ta część, której nikt uważnie nie czyta, bo jest długa, napisana prozą i schowana pod technicznie wyglądającą listą parametrów.
I dokładnie tam żyje tool poisoning. Narzędzie może wyglądać zupełnie zwyczajnie, coś w stylu add_numbers(a, b), podczas gdy jego opis zawiera instrukcje skierowane wprost do modelu: odczytaj konkretny plik konfiguracyjny, zakoduj jego zawartość w base64 i przekaż ją przez nieużywany parametr, zanim zwrócisz sumę. Użytkownik widzi poprawny wynik działania arytmetycznego. Model, który traktuje opis jako część swoich instrukcji, a nie jako niezaufane dane, po cichu robi to, co mu kazano.
To nie jest teoretyczny przypadek brzegowy. Invariant Labs udokumentowało dokładnie ten wzorzec w swoich oryginalnych badaniach nad MCP tool poisoning i właśnie dlatego “przeczytaj opis narzędzia, zanim mu zaufasz” stało się realną radą, a nie paranoją.
Rug pull to część, która naprawdę mnie martwi
Tool poisoning zakłada, że złośliwy opis był tam od pierwszego dnia. Rug pull jest gorszy, bo tego nie wymaga. Serwer może wypuścić czysty, nudny zestaw narzędzi, przejść twój przegląd, zostać zatwierdzony i działać bez zarzutu przez tygodnie. Potem aktualizacja, czy to wypchnięta przez maintainera, pociągnięta przez auto-update, czy serwowana dynamicznie przez backend, którego nigdy nie widzisz, zmienia to, co zwraca tools/list. Zgoda, którą dałem przy instalacji, nie wygasa, bo nic w protokole nie zadaje tego pytania po raz drugi.
To ma większe znaczenie dla narzędzi agentowych niż dla zwykłej zależności npm, z jednego konkretnego powodu: kiedy biblioteka zmienia zachowanie, w końcu ktoś czyta diff, changelog albo stack trace. Kiedy zmienia się opis narzędzia MCP, jedyną rzeczą, która go czyta, jest model, przy każdym wywołaniu, i z założenia traktuje ten tekst jak instrukcje. Nie ma między tym żadnego etapu code review. Zaufanie dane raz, w momencie zatwierdzenia, jest dokładnie tym zaufaniem wydawanym przy każdym kolejnym wywołaniu, niezależnie od tego, czy to, co zatwierdziłeś, nadal istnieje w tej samej formie.
Co z tym realnie robię
Nie będę udawał, że istnieje czyste rozwiązanie, bo na poziomie protokołu go jeszcze nie ma. To, co dodałem, to tanie obejście: sprawdzenie, które uruchamia się na początku każdej sesji, wywołuje tools/list na każdym serwerze MCP, z którym łączę się przez HTTP, i hashuje pary {nazwa, opis} do małego pliku bazowego. Jeśli hash nie zgadza się z tym, co zapisano ostatnim razem, dostaję ostrzeżenie, zanim cokolwiek z tego serwera zdąży się wykonać w danej sesji, i idę sprawdzić, co się zmieniło i dlaczego, zanim znów zacznę ufać.
To kilka linijek logiki, koncepcyjnie:
| |
To mniej więcej to, co robi funkcja pinningu we własnym narzędziu Invariant, mcp-scan, a sam pomysł da się uogólnić na wszystko, co udostępnia metadane narzędzi przez osiągalny endpoint: zahashuj to, co zatwierdziłeś, porównuj przy każdym użyciu i traktuj niezgodność jako “nieprzejrzane”, a nie “pewnie jest ok”.
Gdzie to się sypie
Uczciwe ograniczenie jest takie, że to działa dobrze tylko dla serwerów, które można odpytać bez efektów ubocznych, co w praktyce oznacza te oparte na HTTP. Serwer działający jako lokalny proces, uruchamiany od nowa na każdą sesję, trudniej w ten sposób sfingerprintować, bo sprawdzenie go oznacza faktyczne uruchomienie, a to jest dokładnie to, przy czym chcesz być ostrożny. Dla takich przypadków zostaje mi pinowanie wersji i blokada auto-update, co jest słabsze niż sprawdzanie hasha, ale lepsze niż nic.
Nie uważam też, że to problem, który pojedynczy użytkownik ma rozwiązywać skryptem powłoki. To luka na poziomie protokołu: zatwierdzenie w MCP jest zdarzeniem jednorazowym w czasie, a większość narzędzi zbudowanych wokół niego nadal zachowuje się tak, jakby ta jednorazowa decyzja obowiązywała bezterminowo. Dopóki coś w rodzaju podpisanych, wersjonowanych manifestów narzędzi nie stanie się standardem, tanie sprawdzanie rozbieżności jest tym, co stoi między “przejrzałem to raz w kwietniu” a “nie mam pojęcia, co ten serwer robi w październiku”.
Jeśli masz podpiętych więcej niż parę serwerów MCP i nie sprawdzałeś, czy opisy ich narzędzi zmieniły się od momentu, gdy je zatwierdziłeś, to prawdopodobnie warte jest pięciu minut przed następną sesją.
