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

Spis treści

Kiedy nie budować agenta

Kiedy nie budować agenta

Cały ten hub uczy, jak budować agenty. Ten wpis jest o tym, że najczęściej nie powinieneś. Jest taka pokusa, która przychodzi po przeczytaniu kilku tekstów o agentach: zbudujmy agenta. Do obsługi maili. Do raportów. Do tego procesu, który teraz robi się ręcznie. Agent...