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 WordPressalist_notes— zwraca listę notatekget_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
/**
* 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:
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:
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ź:
{
"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:
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:
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
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:
{
"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):
{
"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:
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











