Jak zbudować serwer MCP — kompletny tutorial w PHP i WordPress

przez Łukasz | cze 5, 2026

Poprzednie pięć artykułów było o teorii i protokole. Ten jest o kodzie.

Zbudujesz działający, minimalny serwer MCP od zera — jako plugin WordPress, w PHP, przez REST API. Użyjemy najprostszego wariantu komunikacji po HTTP: jeden endpoint POST, JSON-RPC 2.0, initialize, tools/list i tools/call.

To ten sam kierunek architektury, który napędza Webflux Słownik MCP v2.2.0, tylko sprowadzony do małego przykładu z notatkami. Na końcu będziesz mieć serwer, który klient MCP może odkryć i wywołać, a w wariancie z proxy także podłączyć do Claude Desktop.

Czas budowania: około 45 minut, jeśli masz gotowego WordPressa.

Co budujemy

Serwer MCP który wystawia trzy tools do prostego zarządzania notatkami:

  • create_note — zapisuje notatkę w bazie WordPressa
  • list_notes — zwraca listę notatek
  • get_note — pobiera konkretną notatkę po ID

To jest minimalny przykład który pokazuje wszystkie ważne elementy. W produkcji tools będą robić coś bardziej użytecznego — ale mechanizm jest identyczny.

Krok 1: Plugin WordPress — szkielet

Stwórz plik /wp-content/plugins/mcp-server/mcp-server.php:

php
<?php
/**
 * Plugin Name: MCP Server
 * Description: Model Context Protocol server dla WordPress
 * Version: 1.0.0
 */

if (!defined('ABSPATH')) exit;

class MCP_Server {

    const PROTOCOL_VERSION = '2025-03-26';
    const SERVER_NAME      = 'moj-serwer-mcp';
    const SERVER_VERSION   = '1.0.0';

    public function __construct() {
        // Trzy różne hooki — trzy różne momenty bootowania WordPress.
        // register_post_type wymaga hooka 'init'.
        // register_rest_route wymaga hooka 'rest_api_init'.
        // Nie wolno ich mieszać.
        add_action('init',          [$this, 'register_post_types']);
        add_action('rest_api_init', [$this, 'register_routes']);
    }

    public function register_post_types() {
        register_post_type('mcp_note', [
            'label'    => 'Notatki MCP',
            'public'   => false,
            'supports' => ['title', 'editor', 'custom-fields'],
        ]);
    }

    public function register_routes() {
        register_rest_route('mcp/v1', '/server', [
            'methods'  => 'POST',
            'callback' => [$this, 'handle_request'],

            // ⚠️  OSTRZEŻENIE — TYLKO LOKALNIE / DEMO  ⚠️
            //
            // '__return_true' = brak uwierzytelniania — każdy może wywołać endpoint.
            // create_note zapisuje do bazy, więc publiczne wdrożenie z tym callbackiem
            // to otwarta furtka do spamowania Twojej bazy przez dowolny skrypt.
            //
            // Przed wystawieniem na sieć zastąp przez:
            //   • Klucz API w nagłówku Authorization: Bearer  (patrz artykuł 5 serii)
            //   • OAuth 2.0 z PKCE                            (patrz artykuł 5 serii)
            //   • current_user_can() jeśli tylko zalogowani
            //
            // Spec MCP dla autoryzacji HTTP (2025-03-26):
            // https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/authorization/
            'permission_callback' => '__return_true',
        ]);
    }
    
    public function handle_request(WP_REST_Request $request): WP_REST_Response {
        $body = $request->get_json_params();
        
        if (!$body || !isset($body['jsonrpc']) || $body['jsonrpc'] !== '2.0') {
            return $this->error(null, -32600, 'Invalid Request');
        }
        
        $method = $body['method'] ?? '';
        $id     = $body['id'] ?? null;
        $params = $body['params'] ?? [];
        
        // Powiadomienia — brak id, brak odpowiedzi
        if ($id === null && str_starts_with($method, 'notifications/')) {
            return new WP_REST_Response(null, 204);
        }
        
        return match($method) {
            'initialize'  => $this->handle_initialize($id, $params),
            'tools/list'  => $this->handle_tools_list($id),
            'tools/call'  => $this->handle_tools_call($id, $params),
            default       => $this->error($id, -32601, 'Method not found'),
        };
    }
    
    // ────────────────────────────────────────────────────
    // HELPERS
    // ────────────────────────────────────────────────────
    
    private function ok($id, $result): WP_REST_Response {
        return new WP_REST_Response([
            'jsonrpc' => '2.0',
            'id'      => $id,
            'result'  => $result,
        ]);
    }
    
    private function error($id, int $code, string $message, $data = null): WP_REST_Response {
        $error = ['code' => $code, 'message' => $message];
        if ($data !== null) $error['data'] = $data;
        
        return new WP_REST_Response([
            'jsonrpc' => '2.0',
            'id'      => $id,
            'error'   => $error,
        ], $code === -32001 ? 401 : 200);
    }
    
    private function text_content(string $text): array {
        return ['content' => [['type' => 'text', 'text' => $text]]];
    }
}

// Instancja — konstruktor sam podpina hooki 'init' i 'rest_api_init'
new MCP_Server();

Aktywuj plugin. Endpoint jest teraz dostępny pod:

POST https://twojadomena.pl/wp-json/mcp/v1/server

Krok 2: Capability negotiation — initialize

Dodaj metodę do klasy MCP_Server:

php
private function handle_initialize($id, array $params): WP_REST_Response {
    // Sprawdź wersję protokołu
    $client_version = $params['protocolVersion'] ?? '';
    
    // MCP jest backward-compatible — akceptujemy starsze wersje
    $negotiated_version = version_compare($client_version, self::PROTOCOL_VERSION, '<=')
        ? $client_version
        : self::PROTOCOL_VERSION;
    
    return $this->ok($id, [
        'protocolVersion' => $negotiated_version,
        'capabilities'    => [
            'tools' => new stdClass(),     // pustý obiekt = obsługujemy tools
            // 'resources' => new stdClass(), // odkomentuj jeśli dodasz resources
            // 'prompts'   => new stdClass(), // odkomentuj jeśli dodasz prompts
        ],
        'serverInfo' => [
            'name'    => self::SERVER_NAME,
            'version' => self::SERVER_VERSION,
        ],
    ]);
}

Test:

bash
curl -X POST https://twojadomena.pl/wp-json/mcp/v1/server \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-03-26",
      "capabilities": {},
      "clientInfo": {"name": "test", "version": "1.0"}
    }
  }'

Oczekiwana odpowiedź:

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-03-26",
    "capabilities": {"tools": {}},
    "serverInfo": {"name": "moj-serwer-mcp", "version": "1.0.0"}
  }
}

Krok 3: Definicje tools

Dodaj do klasy metodę która zwraca listę tools z ich schematami:

php
private function get_tools_definitions(): array {
    return [
        [
            'name'        => 'create_note',
            'description' => 'Zapisz nową notatkę. Używaj gdy chcesz zapamiętać informację, zadanie lub pomysł na później.',
            'inputSchema' => [
                'type'       => 'object',
                'properties' => [
                    'title'   => [
                        'type'        => 'string',
                        'description' => 'Tytuł notatki — krótki i opisowy',
                    ],
                    'content' => [
                        'type'        => 'string',
                        'description' => 'Treść notatki',
                    ],
                    'tags'    => [
                        'type'        => 'array',
                        'items'       => ['type' => 'string'],
                        'description' => 'Opcjonalne tagi do kategoryzacji (np. ["projekt-x", "pilne"])',
                    ],
                ],
                'required' => ['title', 'content'],
            ],
        ],
        [
            'name'        => 'list_notes',
            'description' => 'Wylistuj wszystkie notatki. Używaj żeby zobaczyć co jest zapisane lub znaleźć notatkę po tytule.',
            'inputSchema' => [
                'type'       => 'object',
                'properties' => [
                    'search' => [
                        'type'        => 'string',
                        'description' => 'Opcjonalna fraza do filtrowania notatek po tytule lub treści',
                    ],
                    'limit'  => [
                        'type'        => 'integer',
                        'description' => 'Maksymalna liczba wyników (domyślnie: 10)',
                        'default'     => 10,
                    ],
                ],
                'required' => [],
            ],
        ],
        [
            'name'        => 'get_note',
            'description' => 'Pobierz pełną treść konkretnej notatki po jej ID.',
            'inputSchema' => [
                'type'       => 'object',
                'properties' => [
                    'id' => [
                        'type'        => 'integer',
                        'description' => 'ID notatki (z wyników list_notes)',
                    ],
                ],
                'required' => ['id'],
            ],
        ],
    ];
}

private function handle_tools_list($id): WP_REST_Response {
    return $this->ok($id, ['tools' => $this->get_tools_definitions()]);
}

Test:

bash
curl -X POST https://twojadomena.pl/wp-json/mcp/v1/server \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

Krok 4: Logika tools — router i implementacja

php
private function handle_tools_call($id, array $params): WP_REST_Response {
    $name      = $params['name'] ?? '';
    $arguments = $params['arguments'] ?? [];
    
    // Walidacja — czy tool istnieje
    $known = array_column($this->get_tools_definitions(), 'name');
    if (!in_array($name, $known, true)) {
        return $this->error($id, -32601, "Unknown tool: {$name}");
    }
    
    // Router
    return match($name) {
        'create_note' => $this->tool_create_note($id, $arguments),
        'list_notes'  => $this->tool_list_notes($id, $arguments),
        'get_note'    => $this->tool_get_note($id, $arguments),
        default       => $this->error($id, -32601, 'Tool not found'),
    };
}

// ── create_note ──────────────────────────────────────
private function tool_create_note($id, array $args): WP_REST_Response {
    // Walidacja wymaganych pól
    if (empty($args['title'])) {
        return $this->error($id, -32602, 'Brakuje wymaganego parametru: title');
    }
    if (empty($args['content'])) {
        return $this->error($id, -32602, 'Brakuje wymaganego parametru: content');
    }
    
    // Zapis jako WordPress post (custom post type lub opcja)
    $tags = $args['tags'] ?? [];
    
    $post_id = wp_insert_post([
        'post_title'   => sanitize_text_field($args['title']),
        'post_content' => wp_kses_post($args['content']),
        'post_status'  => 'private',
        'post_type'    => 'mcp_note',
        'meta_input'   => [
            '_mcp_tags' => implode(',', array_map('sanitize_text_field', $tags)),
        ],
    ]);
    
    if (is_wp_error($post_id)) {
        return $this->error($id, -32603, 'Błąd zapisu: ' . $post_id->get_error_message());
    }
    
    return $this->ok($id, $this->text_content(
        "Notatka zapisana pomyślnie. ID: {$post_id}, Tytuł: \"{$args['title']}\""
    ));
}

// ── list_notes ───────────────────────────────────────
private function tool_list_notes($id, array $args): WP_REST_Response {
    $limit  = min((int) ($args['limit'] ?? 10), 50); // max 50
    $search = sanitize_text_field($args['search'] ?? '');
    
    $query_args = [
        'post_type'      => 'mcp_note',
        'post_status'    => 'private',
        'posts_per_page' => $limit,
        'orderby'        => 'date',
        'order'          => 'DESC',
    ];
    
    if ($search) {
        $query_args['s'] = $search;
    }
    
    $posts = get_posts($query_args);
    
    if (empty($posts)) {
        $msg = $search
            ? "Nie znaleziono notatek pasujących do \"{$search}\"."
            : 'Brak zapisanych notatek.';
        return $this->ok($id, $this->text_content($msg));
    }
    
    $lines = ["Znaleziono " . count($posts) . " notatek:\n"];
    foreach ($posts as $post) {
        $tags = get_post_meta($post->ID, '_mcp_tags', true);
        $tag_str = $tags ? " [tagi: {$tags}]" : '';
        $preview = mb_substr(strip_tags($post->post_content), 0, 80);
        $lines[] = "• ID {$post->ID}: {$post->post_title}{$tag_str}\n  {$preview}...";
    }
    
    return $this->ok($id, $this->text_content(implode("\n", $lines)));
}

// ── get_note ─────────────────────────────────────────
private function tool_get_note($id, array $args): WP_REST_Response {
    if (empty($args['id'])) {
        return $this->error($id, -32602, 'Brakuje wymaganego parametru: id');
    }
    
    $post = get_post((int) $args['id']);
    
    if (!$post || $post->post_type !== 'mcp_note') {
        return $this->error($id, -32602, "Notatka ID {$args['id']} nie istnieje");
    }
    
    $tags = get_post_meta($post->ID, '_mcp_tags', true);
    $tag_str = $tags ? "\nTagi: {$tags}" : '';
    $date = get_the_date('Y-m-d H:i', $post);
    
    return $this->ok($id, $this->text_content(
        "# {$post->post_title}\nData: {$date}{$tag_str}\n\n{$post->post_content}"
    ));
}

Krok 5: Rejestracja w Claude Desktop

Otwórz claude_desktop_config.json:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json

Dodaj wpis:

json
{
  "mcpServers": {
    "moj-wordpress": {
      "command": "npx",
      "args": [
        "-y",
        "@modelcontextprotocol/server-http-proxy",
        "https://twojadomena.pl/wp-json/mcp/v1/server"
      ]
    }
  }
}

Alternatywnie — jeśli używasz klienta który obsługuje Streamable HTTP bezpośrednio (niektóre wersje Claude Code):

json
{
  "mcpServers": {
    "moj-wordpress": {
      "transport": {
        "type": "http",
        "url": "https://twojadomena.pl/wp-json/mcp/v1/server"
      }
    }
  }
}

Zrestartuj Claude Desktop. W interfejsie powinna pojawić się ikona narzędzi — kliknij żeby zobaczyć czy serwer jest widoczny.

Krok 6: Pełny test przez curl

Symulacja pełnej sesji:

bash
BASE="https://twojadomena.pl/wp-json/mcp/v1/server"

# 1. Initialize
echo "=== INITIALIZE ==="
curl -s -X POST $BASE \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | python3 -m json.tool

# 2. Lista tools
echo -e "\n=== TOOLS/LIST ==="
curl -s -X POST $BASE \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | python3 -m json.tool

# 3. Utwórz notatkę
echo -e "\n=== CREATE_NOTE ==="
curl -s -X POST $BASE \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc":"2.0","id":3,"method":"tools/call",
    "params":{
      "name":"create_note",
      "arguments":{"title":"Test MCP","content":"Serwer MCP działa!","tags":["test","mcp"]}
    }
  }' | python3 -m json.tool

# 4. Lista notatek
echo -e "\n=== LIST_NOTES ==="
curl -s -X POST $BASE \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_notes","arguments":{}}}' | python3 -m json.tool

Najczęstsze problemy

Method not found na tools/list — serwer nie zadeklarował tools w capabilities podczas initialize. Sprawdź czy capabilitieszawiera "tools": {}.

404 Not Found — zły URL. Sprawdź czy plugin jest aktywny, czy permalink structure jest włączona (Ustawienia → Bezpośrednie odnośniki → dowolna opcja inna niż „Zwykłe”).

Puste tools w tools/list — metoda get_tools_definitions() zwraca pustą tablicę. Sprawdź czy PHP nie zwraca błędu który ukrywa odpowiedź.

Internal Server Error — włącz WP_DEBUG i sprawdź logi (wp-content/debug.log). Najczęściej: błąd składni PHP lub nieistniejący post type.

Claude Desktop nie widzi serwera — sprawdź logi Claude Desktop (macOS: ~/Library/Logs/Claude/mcp.log). Najczęstszy problem: timeout przy pierwszym połączeniu lub błąd konfiguracji JSON.

Co dalej — rozszerzanie serwera

Ten serwer to punkt startowy. W produkcji:

Dodaj resources — udostępnij pliki, konfiguracje lub dane jako resources do odczytu. Zadeklaruj "resources": {} w capabilities i dodaj metody resources/list i resources/read.

Dodaj autoryzację — z artykułu 5: klucz API w nagłówku Authorization: Bearer, walidowany przed każdym żądaniem.

Dodaj cache — responses na tools/list są identyczne dla każdej sesji. WordPress transients jako prosty cache.

Dodaj logging — każde wywołanie tool loguj do osobnej tabeli. Kto wywołał, kiedy, z jakimi parametrami. Niezbędne przy jakimkolwiek poważnym wdrożeniu.

 

W następnym artykule: zbudowałeś serwer MCP w WordPress. A może Twój WordPress powinien stać się klientem MCP — łączącym się z innymi serwerami? WordPress jako środowisko dla agentów i MCP jako most do ekosystemu narzędzi.


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

Agent kończy, ale zadania nie wykonał

Agent kończy, ale zadania nie wykonał

Objawy Agent odpowiada: „Przygotowałem i wysłałem potwierdzenie do klienta." Potwierdzenie nie zostało wysłane. Albo: „Zaktualizowałem wszystkie rekordy" — zaktualizował trzy z dwunastu. Albo: „Nie znalazłem żadnych zamówień" — bo narzędzie zwróciło błąd, którego nikt...

Agent zrobił coś, o co nikt nie prosił

Agent zrobił coś, o co nikt nie prosił

Objawy Operacja, której nikt nie zlecił. Dane wysłane pod adres, którego nie ma w żadnej konfiguracji. Rekord zmieniony poza zakresem zadania. Odpowiedź, z której wynika, że agent dostał instrukcje od kogoś innego niż ty. Cecha wspólna: technicznie wszystko zadziałało...

Agent zrobił to samo dwa razy

Agent zrobił to samo dwa razy

Objawy Ten syndrom różni się od pozostałych jedną rzeczą: objaw widzi klient, nie ty. Trzy identyczne wiadomości w skrzynce. Dwa zamówienia zamiast jednego. Podwójne obciążenie. Duplikaty rekordów, które ktoś zauważa tydzień później. W twoim dzienniku wszystko wygląda...

Agent gubi wątek w długim zadaniu

Agent gubi wątek w długim zadaniu

Objawy Pierwsze kroki idą wzorowo. Po kilkunastu agent zaczyna się rozjeżdżać. Przestaje przestrzegać reguły z promptu systemowego, której trzymał się na początku. Zmienia format odpowiedzi w połowie zadania. Wraca do czegoś, co już ustalił, i ustala to inaczej....

Agent podaje dane, których nie ma

Agent podaje dane, których nie ma

Objawy Numer zamówienia w idealnym formacie, którego nie ma w bazie. Kwota, której w dokumencie nie ma. Nazwa pola API, które nigdy nie istniało. Cytat z regulaminu, brzmiący dokładnie jak reszta regulaminu i w nim nieobecny. Cecha wspólna wszystkich tych przypadków:...

Działał wczoraj, dziś nie działa

Działał wczoraj, dziś nie działa

Objawy Nic się nie wywala. Nie ma wyjątków, nie ma timeoutów, dziennik wygląda tak samo jak zawsze. Po prostu odpowiedzi są gorsze. Postacie, w jakich to się objawia: Jakość. Agent zaczyna pomijać kroki, które wcześniej wykonywał, albo odpowiada ogólniej. Format....

Agent wybiera złe narzędzie

Agent wybiera złe narzędzie

Objawy Agent odpowiada nie na to pytanie. Pobiera listę zamówień, gdy pytano o jedno konkretne. Odpowiada z pamięci, choć miał sprawdzić w bazie. Albo sięga po właściwe narzędzie i wpisuje w argumenty coś, czego nie da się użyć. Sygnał, który odróżnia ten syndrom od...

Agent kręci się w kółko

Agent kręci się w kółko

Objawy Agent wykonuje kolejne obroty pętli, nie zbliżając się do zakończenia zadania. W dzienniku widać jedną z trzech postaci. Powtórzenie. To samo narzędzie, te same argumenty, raz za razem — czasem dziesiątki razy pod rząd. Oscylacja. Agent wywołuje na przemian dwa...