Budowa serwera MCP w Pythonie — architektura, FastMCP i produkcyjny kod

Ten blog jest zarządzany przez własny serwer MCP. Publikacja wpisów, aktualizacja metadanych SEO, przypisywanie kategorii — wszystko idzie przez protokół, który jeszcze rok temu nie istniał w produkcyjnej formie. Nie piszę więc o MCP z perspektywy dokumentacji, tylko z perspektywy kogoś, kto postawił działający serwer i codziennie na nim pracuje. Ten artykuł to nie kolejne „hello world” — to architektura protokołu, świadome decyzje projektowe i produkcyjny kod w Pythonie.

Model Context Protocol (MCP) to otwarty standard wprowadzony przez Anthropic w listopadzie 2024, dziś rozwijany pod Agentic AI Foundation (Linux Foundation). Stabilna wersja specyfikacji nosi datę 2025-11-25, a na 28 lipca 2026 szykuje się największa rewizja od startu — o niej za chwilę, bo zmienia sposób, w jaki projektuje się transport. Zacznijmy jednak od pytania, które większość tutoriali pomija: co ten protokół w ogóle rozwiązuje?

Co protokół MCP naprawdę rozwiązuje

Problem, który MCP adresuje, jest kombinatoryczny. Masz M aplikacji LLM (Claude Desktop, Cursor, VS Code, ChatGPT) i N zewnętrznych systemów (baza danych, GitHub, API firmowe, WordPress). Bez wspólnego standardu każda para wymaga dedykowanej integracji — to M×N implementacji, każda z własnym formatem, autoryzacją i cyklem utrzymania. MCP zamienia to na M+N: piszesz serwer raz, a każdy zgodny klient potrafi go odkryć i użyć bez linijki kodu po swojej stronie.

Mechanicznie MCP stoi na JSON-RPC 2.0 i definiuje trzy role. Host to aplikacja LLM, która koordynuje całość. Klient jest instancjonowany przez hosta — jeden klient na jeden serwer. Serwer dostarcza kontekst i możliwości. To celowa inspiracja Language Server Protocolem: tak jak LSP ustandaryzował wsparcie języków w edytorach, MCP standaryzuje wpinanie narzędzi i danych do ekosystemu AI.

I tu pierwsze nieporozumienie do rozbrojenia: MCP to nie jest „function calling”. Function calling to mechanizm pojedynczego dostawcy — definiujesz funkcje w swoim kodzie i konkretny model je wywołuje. MCP to protokół transportowy i warstwa negocjacji: serwer ogłasza swoje możliwości, klient odkrywa je w czasie działania, a wersje są negocjowane przy inicjalizacji. Function calling żyje wewnątrz jednej aplikacji; serwer MCP jest wielokrotnego użytku między dowolnymi hostami.

Trzy prymitywy: tools, resources i prompts

Serwer MCP wystawia możliwości przez trzy prymitywy. Mylenie ich to najczęstszy błąd projektowy — każdy ma inny kontrakt i inne zastosowanie.

PrymitywCzym jestKto kontrolujeZastosowanie
ToolWykonywalna akcja z walidacją i logikąModel (wywołuje w razie potrzeby)Operacje ze skutkami ubocznymi, złożona logika
ResourceDane tylko-do-odczytu pod szablonem URIAplikacja / hostStatyczny lub półstatyczny kontekst
PromptSzablon wielokrotnego użytkuUżytkownik (wybiera świadomie)Powtarzalne, ustrukturyzowane instrukcje

Reguła praktyczna: Tool gdy potrzebujesz walidacji wejścia i logiki biznesowej („utwórz wpis o tytule X i statusie Y”). Resource gdy udostępniasz dane pod prostym parametrem („treść dokumentu o nazwie Z”). Prompt gdy dajesz użytkownikowi gotowy, sparametryzowany scenariusz. W praktyce większość serwerów zaczyna i kończy na tools — reszta to optymalizacja kontekstu.

Transport: stdio vs Streamable HTTP

MCP definiuje dwa transporty i wybór między nimi to pierwsza architektoniczna decyzja przy budowie serwera MCP.

WymiarstdioStreamable HTTP
LokalizacjaLokalny, ta sama maszynaZdalny, przez HTTPS
Model uruchomieniaSubproces hostaUsługa sieciowa
Liczba klientówJeden (proces)Wielu równolegle
AutoryzacjaDziedziczona z systemuOAuth 2.1 / OIDC
ZastosowanieNarzędzia CLI, integracje lokalneSerwery produkcyjne, SaaS

I tu wchodzi zmiana, o której większość materiałów jeszcze nie pisze. Rewizja 2026-07-28 (obecnie release candidate) usuwa sesję na poziomie protokołu — nagłówek Mcp-Session-Id znika (SEP-2567). Wersja protokołu, informacje o kliencie i możliwości wędrują teraz w _meta przy każdym żądaniu, a nowa metoda server/discover pozwala klientowi pobrać możliwości serwera na żądanie. Konsekwencja jest praktyczna: każde żądanie może trafić na dowolną instancję serwera. Znika wymóg sticky-routingu i współdzielonego magazynu sesji, których wcześniej potrzebowały wdrożenia horyzontalne.

To nie znaczy, że Twoja aplikacja musi być bezstanowa. Serwer, który potrzebuje stanu między wywołaniami, robi to, co API HTTP robiły od zawsze: wystawia jawny uchwyt (np. basket_id) z jednego narzędzia i każe modelowi podawać go z powrotem jako zwykły argument w kolejnych wywołaniach. Projektuj więc pod bezstanowy transport od początku — to kierunek, w którym idzie protokół, i tańsza droga do skalowania.

Minimalny serwer MCP w produkcji — FastMCP

Oficjalny SDK Pythona dostarcza FastMCP — wysokopoziomowy framework, który generuje schemat wejścia z sygnatur i docstringów, integruje walidację Pydantic i rejestruje narzędzia dekoratorem. Poniżej nie „hello world”, tylko szkielet, który ma wszystko, co odróżnia zabawkę od kodu produkcyjnego: model Pydantic do walidacji, adnotacje zachowania, asynchroniczne I/O, obsługę błędów i pełne typowanie.

from __future__ import annotations

import os
import httpx
from pydantic import BaseModel, Field, ConfigDict
from mcp.server.fastmcp import FastMCP

# Serwer nazywamy wg konwencji {usługa}_mcp
mcp = FastMCP("weather_mcp")

API_BASE = "https://api.example-weather.com/v1"


class ForecastInput(BaseModel):
    """Walidacja wejścia dla zapytania o prognozę."""
    model_config = ConfigDict(
        str_strip_whitespace=True,
        extra="forbid",          # odrzuć nieznane pola
    )

    city: str = Field(..., description="Nazwa miasta, np. 'Wrocław'",
                      min_length=1, max_length=100)
    days: int = Field(default=3, description="Horyzont prognozy w dniach",
                      ge=1, le=14)


def _handle_error(e: Exception) -> str:
    """Spójne, akcjonowalne komunikaty błędów dla modelu."""
    if isinstance(e, httpx.HTTPStatusError):
        code = e.response.status_code
        if code == 404:
            return "Błąd: nie znaleziono miasta. Sprawdź pisownię nazwy."
        if code == 429:
            return "Błąd: przekroczono limit zapytań. Odczekaj chwilę."
        return f"Błąd: API zwróciło status {code}."
    if isinstance(e, httpx.TimeoutException):
        return "Błąd: przekroczono czas oczekiwania. Spróbuj ponownie."
    return f"Błąd: nieoczekiwany wyjątek: {type(e).__name__}"


@mcp.tool(
    name="get_forecast",
    annotations={
        "title": "Pobierz prognozę pogody",
        "readOnlyHint": True,      # nie modyfikuje stanu
        "openWorldHint": True,     # sięga do zewnętrznego API
    },
)
async def get_forecast(params: ForecastInput) -> str:
    """Zwraca prognozę pogody dla miasta.

    Args:
        params: zwalidowane wejście (city, days).
    Returns:
        str: sformatowana prognoza albo akcjonowalny komunikat błędu.
    """
    api_key = os.environ.get("WEATHER_API_KEY")
    if not api_key:
        return "Błąd konfiguracji: brak WEATHER_API_KEY w środowisku."

    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            resp = await client.get(
                f"{API_BASE}/forecast",
                params={"q": params.city, "days": params.days},
                headers={"Authorization": f"Bearer {api_key}"},
            )
            resp.raise_for_status()
            data = resp.json()
    except Exception as e:
        return _handle_error(e)

    lines = [f"Prognoza dla {params.city} ({params.days} dni):"]
    for day in data["forecast"]:
        lines.append(f"  {day['date']}: {day['temp_c']}°C, {day['condition']}")
    return "\n".join(lines)


if __name__ == "__main__":
    mcp.run()   # transport stdio (domyślny)

Kilka rzeczy jest tu nieprzypadkowych. Model Pydantic z extra="forbid" odrzuca nieznane pola, zamiast je po cichu ignorować. Adnotacje w dekoratorze (readOnlyHint, openWorldHint) to sygnały dla hosta. Całe I/O jest asynchroniczne. A sekret idzie ze zmiennej środowiskowej, nie z kodu — do tego wrócę przy bezpieczeństwie.

Obsługa błędów, która pomaga modelowi

Zwróć uwagę na funkcję _handle_error powyżej. To nie jest kosmetyka. Komunikat błędu w serwerze MCP jest czytany przez model, nie przez człowieka wpatrzonego w logi — i decyduje, czy model spróbuje sensownie naprawić wywołanie, czy utknie. „Błąd 404″ nie mówi nic; „nie znaleziono miasta, sprawdź pisownię” mówi modelowi, co zrobić dalej. Traktuj każdy komunikat jak instrukcję naprawczą, nie jak wpis do logu.

To ta sama dyscyplina, co przy debugowaniu jako procesie dedukcji, nie zgadywania — precyzyjny sygnał zamiast szumu skraca drogę do przyczyny. Różnica polega na tym, że tutaj odbiorcą sygnału jest model, który na jego podstawie planuje kolejny krok.

Bezpieczeństwo: dlaczego opisy narzędzi są niezaufane

Specyfikacja MCP mówi wprost: narzędzia to dowolne wykonanie kodu i należy je traktować z odpowiednią ostrożnością. Co więcej — opisy zachowania narzędzia, w tym adnotacje, są niezaufane, chyba że pochodzą z zaufanego serwera. To nie formalność. Złośliwy serwer może w opisie narzędzia albo w wyniku jego wywołania przemycić instrukcje, które model potraktuje jak polecenie — to prompt injection przez wynik narzędzia.

Konsekwencje dla Ciebie jako autora serwera są konkretne. Sekrety trzymaj w zmiennych środowiskowych, nigdy w kodzie ani w opisach (widać to wyżej — WEATHER_API_KEY z os.environ). Dla transportu zdalnego użyj OAuth 2.1 / OIDC — rewizja 2026-07-28 dokładniej dopasowuje autoryzację do OAuth i OpenID Connect, a rozszerzenie Enterprise-Managed Authorization jest już stabilne. Waliduj każde wejście Pydantikiem, bo model może podać cokolwiek. I ustawiaj adnotacje uczciwie:

AdnotacjaZnaczeniePrzykład
readOnlyHintNarzędzie nie modyfikuje stanuPobranie prognozy, odczyt wpisu
destructiveHintOperacja nieodwracalnaUsunięcie zasobu
idempotentHintPowtórzenie nie zmienia wynikuUstawienie wartości na X
openWorldHintSięga do zewnętrznych systemówZapytanie do API pogodowego

Host buduje na tych sygnałach ścieżki zgody użytkownika. Skłamana adnotacja (np. readOnlyHint na narzędziu, które kasuje dane) to nie tylko zły kod — to złamanie kontraktu bezpieczeństwa, na którym opiera się cały model zaufania MCP.

Stan, współbieżność i skalowanie

Serwer produkcyjny obsługuje wielu klientów naraz, a każde narzędzie robi I/O — zapytanie do API, do bazy, do dysku. Dlatego cały kod jest asynchroniczny (async def, httpx.AsyncClient): jeden proces obsługuje wiele równoległych wywołań bez blokowania, bo w czasie oczekiwania na odpowiedź sieciową event loop przełącza się na inne zadanie.

To dokładnie ten sam problem skalowania I/O, który pod spodem rozwiązują epoll i io_uring, gdy event loop przestaje wystarczać — model „jeden wątek na połączenie” nie skaluje się w nieskończoność. Serwer MCP na Streamable HTTP siedzi na tej samej warstwie: asynchroniczność nie jest ozdobnikiem, tylko warunkiem obsłużenia wielu klientów na jednej instancji. A dzięki bezstanowemu rdzeniowi 2026-07-28 skalowanie horyzontalne sprowadza się do postawienia kolejnych instancji za load balancerem — bez sticky sessions.

# Lokalnie — stdio (domyślnie)
mcp.run()

# Zdalnie — Streamable HTTP, skalowalne horyzontalnie
mcp.run(transport="streamable_http", port=8000)

Podsumowanie

Budowa serwera MCP, który nie jest zabawką, sprowadza się do kilku świadomych decyzji: wybór transportu (stdio lokalnie, Streamable HTTP produkcyjnie), właściwy prymityw (tool vs resource vs prompt), walidacja Pydantikiem, akcjonowalne błędy, sekrety w środowisku i uczciwe adnotacje. FastMCP zdejmuje z Ciebie boilerplate, ale architektura i bezpieczeństwo zostają po Twojej stronie.

I jeszcze jedno, świeże: projektuj pod bezstanowość. Rewizja 2026-07-28 czyni transport bezsesyjnym domyślnie, a to najtańsza droga do skalowania, jaką protokół kiedykolwiek zaoferował. Serwer MCP napisany dziś z myślą o jawnych uchwytach stanu zamiast sesji przetrwa tę zmianę bez przepisywania. To pierwszy wpis z serii o MCP — w kolejnych wejdziemy głębiej w bezpieczeństwo i wzorce zaawansowane.

Zostaw komentarz

Twój adres email nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry