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:
'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 notatkiget_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:
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:
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.
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:
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:
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:
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):
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:
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):
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:
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:
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:
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:
{
"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:
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:
[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











