31 lipca 2026
MCP serwer Claude nad danymi medycznymi — z terminala do Desktopu
- Claude Code
- AI
- MCP
- Prywatność
Trzy lata wyników badań krwi mam rozrzucone w segregatorze z papierowymi wydrukami z laboratorium i w folderze ze skanami i zdjęciami na dysku — bez jednego miejsca, które cokolwiek podsumowywało. Pytanie „jak zmieniała się moja ferrytyna od ostatniego roku” oznaczało przekopanie całego zbioru dokumentów: znalezienie właściwych badań, wyłowienie konkretnych dat, ręczne przepisanie liczb z PDF-ów — za każdym razem od nowa, żeby cokolwiek porównać w czasie. Żmudne i podatne na pomyłkę. Zbudowałem więc lokalny pipeline, który to automatyzuje — a potem, w ramach nauki agentic codingu, poszedłem krok dalej: otworzyłem te dane na MCP serwer Claude, żeby pytać o trend czy porównanie wprost w Claude Desktop, bez dotykania terminala.
To nie jest tekst o medycynie. To case study z realnego PRD, dwóch ADR-ów (Architecture Decision Record — krótki dokument spisujący jedną decyzję architektoniczną: kontekst, wybór, uzasadnienie) i garści notatek z wdrożenia — dokładnie tych materiałów, które sam chciałbym przeczytać, zanim zacząłem.
Punkt wyjścia: mechanizm działał, dostępu nie było
Wersja pierwsza projektu (OCR na Docling z Apple Vision, standaryzacja nazw markerów przez YAML i regexy, zaufany magazyn NDJSON) już umiała liczyć trend i porównanie dwóch markerów — ale wyłącznie z poziomu Claude Code w terminalu, bo to jedyny klient, który miał wbudowany dostęp do systemu plików bez żadnej dodatkowej konfiguracji. Claude Desktop w standardowej rozmowie tego dostępu nie ma — da się go dopiąć osobnym connectorem, tak jak w moim innym workflow, gdzie na starcie sesji montuję folder projektu. Ale nawet z takim connectorem Desktop dostałby tylko surowe bajty pliku NDJSON, nie logikę domenową: rozpoznawanie aliasów markera, grupowanie po jednostce, filtrowanie po kodzie. Musiałby tę logikę odtwarzać sam, przy każdym pytaniu, bez gwarancji spójności z tym, co już przetestowane w report.py. Problemem nie był więc goły dostęp do bajtów — to był brak dostępu do tej samej, sprawdzonej logiki, którą miał już Claude Code. Dokładnie to rozwiązuje protokół MCP: daje klientowi bez tej logiki ustandaryzowany sposób wywołania narzędzia, które ją ma.
MCP serwer Claude — dlaczego stdio, nie serwer sieciowy
Pierwsza decyzja, którą trzeba było podjąć świadomie, nie „bo tak się zwykle robi”: transport. Diagram architektury sugerował serwer nasłuchujący na porcie, do którego łączy się wielu klientów. Wybrałem inaczej — stdio: serwer uruchamiany jako subproces bezpośrednio przez klienta, na podstawie jego własnej, ręcznie edytowanej konfiguracji.
Różnica nie jest kosmetyczna. Serwer na porcie odpytałby dowolny proces na Macu, bez autoryzacji. Stdio przesuwa granicę z „co wysyłam do API” na „kto w ogóle może się połączyć z serwerem”: dane opuszczają dysk wyłącznie wtedy, gdy sam dopiszę serwer do configu konkretnego klienta. Żaden inny proces w systemie nie ma do niego dostępu, bo żaden inny proces nie wie, że serwer istnieje — nie ma portu do wyszukania.
To ta sama klasa ryzyka, co przy pierwszej wersji projektu, gdzie do żadnego API nie szły wartości pomiarów, tylko nazwa markera. W wersji drugiej realne wyniki (wartości, daty, zakresy referencyjne) trafiają już do kontekstu modelu klienta MCP — bo to jest właśnie wartość produktowa, trend i porównanie czytelne dla modelu. Świadomie rozszerzony zakres, nie przeoczenie — i dokładnie dlatego cała mitygacja prywatności nie polega na filtrowaniu treści, tylko na tym, kto może w ogóle nawiązać połączenie.
Jedno uściślenie, żeby nie przeciągnąć struny: same rekordy, które trafiają do modelu, nie niosą żadnego identyfikatora pacjenta — nazwiska, PESEL-u, numeru karty. Tylko kod markera, data badania, wartość, zakres referencyjny. Co dany klient MCP robi dalej z tymi danymi w swoim kontekście — czy je przechowuje, czy wiąże z moim kontem — zostaje poza kontrolą tego serwera, tak samo jak zaufanie do Claude Code było już przyjęte w wersji pierwszej. To świadomie nazwana granica projektu, nie luka, o której zapomniałem.
Wyłącznie odczyt — granica, nie przypadek
Druga decyzja: serwer wystawia tylko narzędzia do odczytu. Rozważyłem i odrzuciłem wariant z zapisem — np. zatwierdzanie propozycji nowego aliasu markera wprost z Claude Desktop, zamiast ręcznej edycji pliku w Claude Code.
Powód jest prosty: mechanika zatwierdzania aliasów — plikowa, z człowiekiem w pętli — działa i jest sprawdzona od pierwszej wersji. Duplikowanie jej w nowym kanale nie miało uzasadnienia, za to zwiększało powierzchnię ryzyka: klient MCP mógłby zmieniać zaufany słownik domenowy, na co żadna realna potrzeba nie wskazywała. Luka, którą naprawdę trzeba było zamknąć, to brak odczytu z klientów bez dostępu do plików — nie brak alternatywnej ścieżki zapisu. Dopisanie „skoro już tam jesteśmy, dorzućmy zapis” byłoby dokładnie tym scope creepem, który brzmi rozsądnie w danym momencie i wychodzi bokiem przy pierwszym audycie.
Rozpoznawanie markera: kod, etykieta, czy skrót przed nawiasem
Najciekawszy kawałek logiki nie jest wcale skomplikowany, ale ma pułapkę, której bym się nie spodziewał. Model wywołujący narzędzie może podać identyfikator markera na trzy sposoby: kod kanoniczny (HGB), pełną polską etykietę („Hemoglobina”) albo skrót sprzed nawiasu w etykiecie — bo słownik markerów trzyma niektóre nazwy jako „OB (odczyn Biernackiego)”, a naturalne jest zapytać po prostu o „OB”.
def resolve_marker(identifier: str, labels: dict[str, str] | None = None) -> str:
query_lower = (identifier or "").strip().lower()
code_by_lower = {code.lower(): code for code in labels}
label_by_lower = {label.strip().lower(): code for code, label in labels.items()}
prefix_by_lower = _prefix_aliases(labels, code_by_lower)
if query_lower in code_by_lower:
return code_by_lower[query_lower]
if query_lower in label_by_lower:
return label_by_lower[query_lower]
if query_lower in prefix_by_lower:
return prefix_by_lower[query_lower]
candidates = {**code_by_lower, **label_by_lower, **prefix_by_lower}
close = get_close_matches(query_lower, candidates.keys(), n=3, cutoff=0.6)
raise MarkerNotFound(identifier, sorted({candidates[c] for c in close}))
Skrót przed nawiasem trzeba było dopisać jako osobną warstwę, bo dopasowanie rozmyte porównuje długości całych ciągów — „OB” i „OB (odczyn Biernackiego)” są zbyt różnej długości, żeby połączyć się same z siebie. Ale sam prefiks jest niebezpieczny bez dodatkowego sprawdzenia: „PTH (1-84)” dałoby prefiks „pth”, który już jest kodem zupełnie innego markera. Prefiks wchodzi więc do słownika tylko wtedy, gdy nie koliduje z żadnym istniejącym kodem — przy kolizji narzędzie milczy o tym aliasie zamiast zgadywać, o który marker chodziło. Zero dopasowania jest tu bezpieczniejsze niż złe dopasowanie.
Ta sama zasada rządzi całym kontraktem: nierozpoznany identyfikator nigdy nie zwraca cichej pustej listy — zawsze jawny status „nie znaleziono” z podpowiedzią najbliższych dopasowań. Literówka i „marker istnieje, zero pomiarów” muszą wyglądać inaczej, bo model po drugiej stronie inaczej je zinterpretuje.
Pułapka: Desktop nie widział nowych narzędzi
Rejestracja serwera w Claude Desktop poszła gładko przy pierwszym, trywialnym narzędziu (ping → "pong") — komenda instalacyjna sama dopisała wpis do configu, razem z pełną ścieżką do binarki uv. Awaria wyszła dopiero, gdy narzędzia zaczęły importować zależność YAML z modułu domenowego.
Powód: komenda instalacyjna nie ma opcji na katalog roboczy — sprawdzone wprost w źródle biblioteki, nie zgadywane. Desktop odpala podproces z jakiegoś swojego katalogu, nie z katalogu projektu, więc uruchomienie bez wskazanego katalogu nie znajdowało plików projektu i zamiast jawnego błędu cicho lądowało na innej instalacji z PATH (u mnie: pod pyenv, bez potrzebnej zależności). Diagnoza była myląca, bo ręczne odtworzenie tej samej komendy z terminala, z katalogu projektu, działało bez zarzutu — dopiero uruchomienie z katalogu domowego odtworzyło błąd jeden do jednego. Naprawa to jedna linia dopisana ręcznie do configu, flaga katalogu roboczego zaraz po komendzie uruchamiającej. Automat jej nie doda nigdy — trzeba pamiętać o tym przy każdej reinstalacji.
Pułapka: fraza z PRD nie zadziałała
Kryteria sukcesu miały konkretne przykładowe zapytania — „porównaj hemoglobinę i OB” dla drugiego narzędzia. Wpisane dosłownie w Claude Desktop, nie zadziałało: model potraktował to jako pytanie edukacyjne o różnicę między markerami i odpowiedział ogólną wiedzą medyczną, nie sięgając po narzędzie w ogóle — mimo że działało poprawnie, potwierdzone niezależnie przez MCP Inspector chwilę wcześniej.
To nie był błąd kodu. Dopiero fraza „porównaj moje wyniki hemoglobiny i OB w czasie” zadziałała — bo jawnie sygnalizowała dane osobiste i oś czasu, dwa sygnały, których model potrzebował, żeby odróżnić „zapytaj mnie o wiedzę” od „sięgnij po narzędzie”. Kontrakt narzędzia (niezależne serie, bez łączenia po dacie) zostawał bez zmian — to model, już po stronie odpowiedzi, składał dwie serie w jedną czytelną tabelę. Poniżej realna odpowiedź z tego zapytania — bez żadnej ingerencji z mojej strony poza samym pytaniem:

Wartości mieszczą się w normie przez cały okres, dlatego pokazuję je bez zaciemniania — to zresztą lepiej ilustruje sedno niż fikcyjny przykład: dwie w pełni niezależne serie o różnych datach badań (HGB i OB bada się zwykle innego dnia), świadomie bez łączenia po dacie — a mimo to model po stronie odpowiedzi złożył je w jedną czytelną tabelę i dopisał interpretację kliniczną, o którą nie prosiłem wprost w treści zapytania. Wniosek, który zabieram do każdego kolejnego promptu łączącego wywołanie narzędzia z pytaniem w naturalnym języku: przykład z dokumentacji może nie triggerować narzędzia tak, jak zakładasz, dopóki go realnie nie sprawdzisz z docelowym klientem.
„Mechanizm działa” to nie „kryterium spełnione”
Cała weryfikacja szła w trzech warstwach rosnącego kosztu: testy jednostkowe czystej logiki (rozpoznawanie markera, grupowanie po jednostce, błąd dla nieznanego identyfikatora) → ręczne wywołanie przez MCP Inspector, bez konfigurowania Desktopa → dopiero na końcu realne zapytanie z samego Desktopa, jak wyżej. Żadna z pierwszych dwóch warstw nie liczyła się jako dowód gotowości — bo wersja pierwsza projektu już raz pokazała, że zielona odpowiedź na wywołanie techniczne i spełnione kryterium produktowe to dwa różne stwierdzenia.
Co z tego wynika
Dla mnie ten projekt to nie „nauczyłem się MCP” — to ten sam agentic coding workflow, który opisuję na tej stronie przy innych projektach, przeniesiony z kodu produkcyjnego na dane medyczne: co idzie do kontekstu modelu, kto może się połączyć, gdzie kończy się automat, a zaczyna człowiek w pętli. Ta sama dyscyplina, którą opisałem przy self-review loop i hookach Claude Code — tam deterministyczny hook nie pozwala agentowi skończyć bez spełnionych kryteriów, tutaj trzy warstwy weryfikacji nie pozwalają uznać serwera za gotowy bez realnego dowodu z docelowego klienta. Ten sam wzorzec widać też przy własnej bazie wiedzy w Obsidianie — inny projekt, ta sama zasada: nie ufaj mechanizmowi, dopóki nie zadziałał end-to-end z realnym klientem, nie tylko w izolacji.
Transport sieciowy i rewizja modelu zagrożeń to świadomie odłożone pytanie na kolejną wersję, jeśli projekt kiedyś przeniesie się do chmury. Na teraz stdio i wyłączny odczyt zostają — bo to one dokładnie odpowiadają na pytanie, które miałem przed rozpoczęciem: jak otworzyć dane na nowego klienta, nie zmieniając przy tym, kto ma nad nimi kontrolę.
Dostęp to jednak nie koniec historii — samo pytanie „jak zmieniała się ferrytyna” wciąż wymagało, żebym ja sam wiedział, o co pytać. Część 2 tej serii opisuje, jak nad tym samym serwerem zbudowałem agenta na Claude Agent SDK, który sam planuje, które narzędzie wywołać: Claude Agent SDK nad własnym serwerem MCP.
Ten sam schemat decyzji przenosi się wprost do pracy z klientami e-commerce, tylko magazyn danych jest inny. Serwer MCP nad hurtownią BigQuery/dbt zamiast nad NDJSON, ale te same trzy pytania: kto się łączy (stdio czy opt-in per klient, nie otwarty port do sieci firmowej), czy w ogóle wolno pisać (przy analityce zwykle nie — dashboard nie powinien mieć prawa nadpisać modelu dbt) i czy narzędzie oddaje surowy dostęp do tabel, czy warstwę rozpoznawania identyfikatorów (żeby stakeholder pytający o „przychód” dostał ten sam kanoniczny event co analityk, nie zgadywankę po nazwach kolumn). To nie hipoteza — pierwszy krok tej samej drogi opisałem już w Google MCP Toolbox i Data Agent Kit, inny magazyn, ten sam wzorzec.