Serwer MCP jako bramka w Go — jeden binarny plik przed WordPressem

przez Łukasz | lip 1, 2026

Szacowany czas budowania: ~40 minut, jeśli masz gotowy VPS i WordPressa z REST API.

W poprzednim wpisie zbudowaliśmy serwer MCP wewnątrz WordPressa — jako plugin, w PHP, przez REST API. Działało. Ale w kodzie została jedna linijka, którą sam oznaczyłem ostrzeżeniem:

php
'permission_callback' => '__return_true',   // ⚠️ każdy może wywołać endpoint

To nie był lenistwo – to była konsekwencja topologii. Kiedy serwer MCP mieszka w WordPressie i ma bezpośredni dostęp do bazy, musisz go pilnować od środka: dokładać auth, walidację, rate limiting do tego samego procesu, który renderuje Twój blog. Każdy tool to potencjalna furtka do bazy.

Ten wpis pokazuje inną odpowiedź na to samo pytanie: co, jeśli serwer MCP w ogóle nie mieszka w WordPressie? Co, jeśli to osobny proces, na osobnym serwerze, napisany w Go, który stoi przed WordPressem jak bramka?

To nie jest „to samo w innym języku”. To inna mapa zaufania.

Dwie mapy zaufania

Wersja z pluginem wygląda tak:

Klient MCP (Claude) ──▶ WordPress (plugin MCP + baza)
                        agent i baza w jednym procesie

Agent rozmawia bezpośrednio z WordPressem. Granica bezpieczeństwa przebiega w środku WP — między endpointem MCP a resztą aplikacji. Musisz ją zbudować i utrzymać sam, w PHP.

Wersja z bramką odwraca to:

Klient MCP (Claude) ──Streamable HTTP + Bearer──▶ [ BRAMKA (Go) ] ──REST──▶ WordPress
                                                    trzyma sekrety WP        (może być prywatny)

Teraz granica jest jawna i fizyczna: agent gada wyłącznie z bramką. Bramka trzyma poświadczenia do WordPressa, wymusza własne uwierzytelnienie i decyduje, co przepuścić. Endpoint WordPressa możesz w ogóle zamknąć przed światem — dostęp do niego ma tylko bramka.

Ta sama linijka __return_true, która była problemem w części pierwszej, tutaj przestaje istnieć jako problem — bo publiczny nie jest już WordPress, tylko bramka, a ta ma auth wbudowany od pierwszej minuty.

Dlaczego akurat Go

Nie z powodów religijnych. Z trzech konkretnych:

Jeden statyczny plik. go build produkuje jedną binarkę bez zależności runtime. Kopiujesz ją na serwer, odpalasz jako usługę systemd — koniec. Żadnego PHP-FPM, żadnego interpretera, żadnego composer install na produkcji. U mnie ta bramka to 7,9 MB w pełni statycznego ELF-a.

Współbieżność pod strumienie. MCP w nowoczesnym transporcie (Streamable HTTP) używa długożyjących połączeń SSE. Model PHP „jeden request = jeden proces” walczy z tym z natury. Go z goroutines obsługuje setki równoległych sesji agentów bez gimnastyki.

Typy zamiast array. W PHP-owej wersji ręcznie sprawdzaliśmy $body['jsonrpc'] !== '2.0' i budowaliśmy odpowiedzi z tablic asocjacyjnych. W Go schemat wejścia toola generuje się z typu struktury — jeśli agent wyśle zły kształt, dostaje błąd, zanim Twój kod się wykona.

I najważniejsza zmiana od czasu poprzedniego wpisu: jest już oficjalny SDK MCP w Go, rozwijany we współpracy z Google (github.com/modelcontextprotocol/go-sdk). Ma gotowy transport Streamable HTTP z SSE, generowanie schematów z generyków i pakiet do OAuth. Nie musimy — jak w PHP — klepać JSON-RPC ręcznie. (Choć pokażę, że pod spodem to nadal ten sam JSON-RPC 2.0, który znasz z części pierwszej.)

Jedna uwaga o wersjach: w PHP na sztywno wpisaliśmy protocolVersion: "2025-03-26". SDK w Go negocjuje wersję za Ciebie i wspiera nowsze rewizje protokołu (2025-06-18, 2025-11-25) z kompatybilnością wsteczną. Warto wiedzieć, że najnowsza specyfikacja idzie dalej — część funkcji (roots, sampling, logging) jest już oznaczona jako przestarzała z rocznym oknem zgodności. Trzymanie się SDK zdejmuje z Ciebie śledzenie tego ręcznie.

Co budujemy

Świadomie te same trzy tools co w części pierwszej, żebyś mógł porównać 1:1:

  • create_note — zapisuje notatkę
  • list_notes — listuje notatki
  • get_note — pobiera notatkę po ID

Różnica jest pod spodem: w PHP tools zapisywały wprost do bazy WordPressa. Tu tools wołają WordPress REST APIprzez fasadę, a bramka uwierzytelnia się do WP osobnym poświadczeniem (Application Password). Agent tego poświadczenia nigdy nie widzi.

Struktura projektu:

mcp-gateway/
├── go.mod
├── main.go        # konfiguracja, bramka Bearer, montaż serwera MCP
├── wp.go          # klient WordPress REST (fasada, trzyma sekrety)
├── tools.go       # definicje i handlery tools
└── main_test.go   # test end-to-end

Krok 1: Serwer MCP i tools

Zacznijmy od tools.go. Kształt wejścia każdego toola to zwykła struktura Go — tagi jsonschema stają się opisami w schemacie, który zobaczy agent:

go
type createNoteInput struct {
    Title   string `json:"title" jsonschema:"Tytuł notatki — krótki i opisowy"`
    Content string `json:"content" jsonschema:"Treść notatki"`
}

type listNotesInput struct {
    Search string `json:"search,omitempty" jsonschema:"Opcjonalna fraza do filtrowania"`
    Limit  int    `json:"limit,omitempty" jsonschema:"Maksymalna liczba wyników (domyślnie 10, max 50)"`
}

type getNoteInput struct {
    ID int `json:"id" jsonschema:"ID notatki (z wyników list_notes)"`
}

Rejestracja toola to mcp.AddTool. Handler dostaje już zdekodowane, otypowane wejście — nie ma ręcznego parsowania JSON-a:

go
mcp.AddTool(s, &mcp.Tool{
    Name:        "create_note",
    Description: "Zapisz nową notatkę. Używaj, gdy chcesz zapamiętać informację lub pomysł na później.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, in createNoteInput) (*mcp.CallToolResult, any, error) {
    if strings.TrimSpace(in.Title) == "" {
        return toolError("Brakuje wymaganego parametru: title"), nil, nil
    }
    id, err := wp.CreateNote(ctx, in.Title, in.Content)
    if err != nil {
        return toolError("Nie udało się zapisać notatki: %v", err), nil, nil
    }
    return textResult(fmt.Sprintf("Notatka zapisana. ID: %d, Tytuł: %q", id, in.Title)), nil, nil
})

Dwa detale, które w PHP musieliśmy ogarniać ręcznie:

Błąd toola vs błąd protokołu. Kiedy notatki nie da się zapisać, zwracamy wynik z IsError=true — a nie błąd JSON-RPC. Różnica jest praktyczna: dzięki temu model widzi, że coś poszło nie tak, i może się poprawić, zamiast dostać twardy błąd transportu.

go
func toolError(format string, a ...any) *mcp.CallToolResult {
    return &mcp.CallToolResult{
        IsError: true,
        Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf(format, a...)}},
    }
}

Krok 2: Fasada przed WordPressem

Sercem wzorca „bramki” jest wp.go — cienki klient, w którym żyją sekrety:

go
type WPClient struct {
    BaseURL     string // np. https://twojadomena.pl
    User        string // login WP
    AppPassword string // WordPress Application Password
    HTTP        *http.Client
}

create_note mapuje się na zwykły POST do WordPress REST API — z uwierzytelnieniem Basic (Application Password), którego agent nie zna:

go
func (c *WPClient) CreateNote(ctx context.Context, title, content string) (int, error) {
    payload := map[string]any{"title": title, "content": content, "status": "private"}
    resp, err := c.do(ctx, http.MethodPost, "/wp-json/wp/v2/posts", payload)
    // ...
}

func (c *WPClient) do(ctx context.Context, method, path string, body any) (*http.Response, error) {
    // ... budowa requestu ...
    if c.User != "" {
        req.SetBasicAuth(c.User, c.AppPassword) // sekret zostaje w bramce
    }
    return c.HTTP.Do(req)
}

Dodatkowa korzyść: bramka jest też tarczą. Jeśli WordPress zwróci błąd 500 z detalami stosu, nie przepuszczamy tego surowo do agenta — tłumaczymy na zdawkowy komunikat:

go
func backendError(resp *http.Response) error {
    _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 512)) // nie wyciekaj ciała błędu
    return fmt.Errorf("backend WP zwrócił %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
}

Krok 3: Transport i bramka Bearer

W main.go łączymy wszystko. Najpierw serwer MCP na transporcie Streamable HTTP (jedna linijka dzięki SDK):

go
srv := newServer(wp) // = mcp.NewServer(...) + registerTools(...)
handler := mcp.NewStreamableHTTPHandler(
    func(*http.Request) *mcp.Server { return srv },
    nil,
)

Teraz to, czego w PHP brakowało: auth na wejściu. Prosta bramka Bearer, która odrzuca żądanie, zanim dotknie ono logiki MCP. Porównanie w czasie stałym, żeby nie wyciekać tokenu timingiem:

go
func bearerAuth(token string, next http.Handler) http.Handler {
    want := []byte("Bearer " + token)
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        got := []byte(r.Header.Get("Authorization"))
        if len(got) != len(want) || subtle.ConstantTimeCompare(got, want) != 1 {
            w.Header().Set("WWW-Authenticate", `Bearer realm="mcp"`)
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

Montaż i twarde wymaganie sekretu na starcie (bez tokenu bramka się nie podniesie):

go
if token == "" {
    log.Fatal("GATEWAY_TOKEN jest wymagany")
}
mux := http.NewServeMux()
mux.Handle(mcpPath, bearerAuth(token, handler))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
    w.WriteHeader(http.StatusOK); w.Write([]byte("ok"))
})

To jest cała różnica względem __return_true: tu domyślnie nic nie przechodzi.

Krok 4: Uruchomienie i test

Konfiguracja przez zmienne środowiskowe — sekrety nigdy nie trafiają do kodu:

bash
export GATEWAY_TOKEN="dlugi-losowy-sekret"
export WP_BASE_URL="https://twojadomena.pl"
export WP_USER="admin"
export WP_APP_PASSWORD="xxxx xxxx xxxx xxxx xxxx xxxx"

go run .
# 2026/07/01 00:50 MCP gateway (ifox-mcp-gateway v1.0.0) słucha na :8080/mcp -> backend https://twojadomena.pl

Zdrowie i odmowa bez tokenu — najprościej curl-em:

bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/healthz         # 200
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8080/mcp -d '{}' # 401

Ale prawdziwy dowód, że mówimy poprawnym MCP, to pełna sesja klienta. W repo jest test end-to-end, który podnosi mock WordPressa, uruchamia bramkę i łączy się z nią klientem MCP przez Streamable HTTP — z Bearerem doklejonym w transporcie:

go
transport := &mcp.StreamableClientTransport{
    Endpoint:   gw.URL,
    HTTPClient: &http.Client{Transport: authTransport{base: http.DefaultTransport, token: token}},
}
session, err := client.Connect(ctx, transport, nil) // handshake initialize
res, _ := session.CallTool(ctx, &mcp.CallToolParams{
    Name:      "create_note",
    Arguments: map[string]any{"title": "Test bramki", "content": "Działa!"},
})

Wynik go test -v:

tools/list OK: map[create_note:true get_note:true list_notes:true]
create_note OK: Notatka zapisana. ID: 1, Tytuł: "Test bramki MCP"
list_notes OK: Znaleziono 1 notatek: • ID 1: Test bramki MCP ...
get_note OK: # Test bramki MCP ... Bramka w Go działa!
--- PASS: TestGatewayEndToEnd

Zły token nie przechodzi nawet przez handshake — test to sprawdza osobno.

Krok 5: Rejestracja w Claude Desktop

claude_desktop_config.json — tym razem z nagłówkiem autoryzacji:

json
{
  "mcpServers": {
    "ifox-gateway": {
      "transport": {
        "type": "http",
        "url": "https://mcp.twojadomena.pl/mcp",
        "headers": { "Authorization": "Bearer TWOJ_GATEWAY_TOKEN" }
      }
    }
  }
}

Jeśli Twój klient nie umie doklejać nagłówków w konfiguracji HTTP, wstaw przed nim proxy stdio↔HTTP i dodaj Authorization tam.

Krok 6: Deploy — jeden plik, osobny serwer

Tu wzorzec bramki pokazuje pazur. Cały wdrożony artefakt to jedna binarka:

bash
CGO_ENABLED=0 go build -ldflags="-s -w" -o mcp-gateway .
scp mcp-gateway user@vps:/opt/ifox/

Usługa systemd (/etc/systemd/system/mcp-gateway.service), sekrety w pliku z uprawnieniami 600, nie w unicie:

ini
[Service]
EnvironmentFile=/etc/ifox/mcp-gateway.env
ExecStart=/opt/ifox/mcp-gateway
Restart=always
DynamicUser=yes

Przed bramką reverse proxy z TLS (Caddy załatwia certyfikat sam). I krok, który domyka całą historię: zamknij endpoint WordPressa firewallem/VPN-em tak, żeby wołać go mogła tylko bramka. Agent i tak łączy się wyłącznie z bramką — WordPress znika z publicznej powierzchni ataku.

Najczęstsze problemy

401 mimo tokenu — sprawdź, czy klient wysyła dokładnie Authorization: Bearer <token>, bez dodatkowych spacji. Bramka porównuje bajt w bajt.

toolchain upgrade needed przy go build — masz starsze Go niż wymaga wersja SDK. Albo podnieś Go, albo przypnij starszą wersję SDK (go get .../go-sdk@v1.4.0).

Handshake przechodzi, ale tools zwracają błąd backendu — złe WP_APP_PASSWORD albo endpoint wp/v2/postszamknięty. Zweryfikuj Application Password curl-em wprost do WordPressa.

Klient nie widzi serwera — najczęściej brak nagłówka Authorization w konfiguracji albo zły URL (pamiętaj o ścieżce /mcp).

Kiedy plugin, a kiedy bramka

Nie ma tu jednej dobrej odpowiedzi — jest framework decyzyjny.

Zostań przy pluginie w WordPressie (część pierwsza), gdy: masz jeden serwis, jeden backend, hobbystyczny/mały projekt, a każdy dodatkowy proces to koszt, którego nie chcesz. Prostota wygrywa.

Przejdź na bramkę w Go, gdy zachodzi choć jedno z:

  • potrzebujesz twardej izolacji — agent nie ma prawa dotykać CMS-a bezpośrednio,
  • łączysz więcej niż jeden backend (WordPress + zewnętrzne API + baza) pod jednym serwerem MCP,
  • zależy Ci na strumieniowaniu / długich sesjach, w których model PHP-FPM przeszkadza,
  • warstwa tooli ma żyć własnym cyklem życia — deploy, skalowanie i wersjonowanie niezależnie od CMS-a.

Koszt bramki jest realny: dodatkowy hop, drugi serwis do wdrożenia, TLS, monitoring, sekrety do pilnowania. Nie udawajmy, że jest za darmo. Ale w zamian dostajesz granicę zaufania, której w wersji in-WP musiałbyś pilnować ręcznie w każdym toolu.

Co dalej

Bramka z jednym backendem to dopiero początek. Prawdziwa siła tego wzorca to agregacja: jeden serwer MCP, który wystawia tools mapowane na wiele backendów naraz — WordPress do treści, osobne API do płatności, baza do analityki. Agent widzi jeden spójny zestaw narzędzi, a bramka rozdziela ruch i trzyma wszystkie sekrety w jednym miejscu – czysty agentic web.


Pojęcia ze słownika: MCP · Streamable HTTP · JSON-RPC · OAuth dla agentów

Kod z tego wpisu

Table of Contents