
Serwer MCP to usługa, która udostępnia narzędzia agentowi AI w formacie JSON-RPC 2.0, opisane schematem, który klient czyta w czasie wykonania przez tools/list. W PHP mieści się w pliku liczącym 380 linii: trasa POST /mcp, token bearer, i jedna funkcja na narzędzie. Uwaga, rewizja 2026-07-28 znosi uzgadnianie initialize, ale Codex i MCP Inspector 2.5.0 wciąż go używają: Twój serwer musi obsługiwać oba.
Masz wewnętrzne API, katalog produktów albo bazę logów, i chciałbyś, żeby Claude Code albo Codex korzystały z tego bezpośrednio, zamiast prosić Cię o kopiowanie i wklejanie. To zadanie dla serwera MCP, i mieści się ono w jednym pliku PHP: ten z tego tutorialu ma 380 linii, bez Composera, bez frameworka i bez żadnej innej zależności poza PHP 8.
Czym jest serwer MCP?
Model Context Protocol to otwarty protokół, który standaryzuje sposób, w jaki aplikacja AI podłącza się do danych i zewnętrznych narzędzi. Specyfikacja wyróżnia trzy role: hosty, aplikacje, które uruchamiają połączenia. Klienci, łączniki wewnątrz hosta. Serwery, usługi dostarczające kontekst i możliwości. Wiadomości są w formacie JSON-RPC 2.0.
Serwer może oferować trzy rzeczy: zasoby (kontekst i dane), prompty (szablony wiadomości) i narzędzia (funkcje, które wykonuje model). Tutaj implementujemy tylko narzędzia, bo to one przydają się w większości przypadków i bo to część, którą da się przetestować curlem.
MCP czy API REST: co naprawdę się zmienia?
MCP nie zastępuje REST, dokłada nad nim warstwę opisu. Oto, co konkretnie się różni.
| Punkt | Klasyczne API REST | Serwer MCP |
|---|---|---|
| Odkrywanie | Dokumentacja do przeczytania, OpenAPI do wczytania | tools/list zwraca schematy JSON każdego narzędzia, w czasie wykonania |
| Wywołujący | Kod, który sam piszesz | Model, który wybiera narzędzie na podstawie jego opisu |
| Format | Cokolwiek chcesz | JSON-RPC 2.0, narzucony |
| Błędy | Kody HTTP | Dwie rodziny: błędy protokołu i błędy wykonania narzędzia |
| Integracja | Jeden adapter na klienta | Jeden serwer, wszyscy zgodni agenci |
Ostatni punkt jest jedynym, który się naprawdę liczy. Serwer MCP napisany raz podłącza się do Claude Code, do Codex, do Inspectora i do innych klientów ekosystemu bez ani jednej linijki adaptacji. To ta sama obietnica co przy skillach, których format opisujemy szczegółowo w przewodniku po pliku SKILL.md.
Dlaczego Twój serwer musi mówić dwoma językami
To pułapka tego tutorialu, i lepiej postawić ją od razu. Rewizja 2026-07-28 specyfikacji, opublikowana 28 lipca 2026 przez Davida Sorię Parrę i Dena Delimarsky’ego, zniosła uzgadnianie. Koniec z initialize, koniec z powiadomieniem notifications/initialized, koniec z nagłówkiem Mcp-Session-Id. Każde żądanie niesie teraz swoją wersję protokołu i tożsamość klienta w polu _meta, a serwer może być replikowany bez współdzielonego stanu.
Pozostałe zmiany tej samej rewizji dotyczą bezpośrednio transportu HTTP:
- punkt wejścia akceptuje już tylko
POST, strumieńGETiDELETEkończący sesję zniknęły, - metoda
server/discoverzastępuje odkrywanie, i serwery muszą ją zaimplementować. - nagłówki
Mcp-MethodiMcp-Namepowtarzają pola z ciała żądania, żeby load balancer mógł kierować ruch bez czytania JSON-a. - odpowiedzi list niosą
ttlMsicacheScope, żeby dało się je cache’ować.
Na papierze wystarczyłoby więc napisać serwer 2026-07-28. Tyle że zalogowałem metodę otwarcia i nagłówek User-Agent każdego klienta, który połączył się z moim serwerem 7 września 2026, i oto, co zobaczyłem.
| Klient | User-Agent | Otwarcie | Zapowiedziana wersja |
|---|---|---|---|
| Codex | codex-mcp-client/0.153.4 | initialize | 2025-06-18 |
| MCP Inspector 2.5.0 | node | initialize | 2025-11-25 |
Żaden z nich nie wysłał server/discover, i żaden nie umieścił _meta w swoich żądaniach. Serwer, który mówiłby wyłącznie rewizją lipcową, byłby dziś bezużyteczny z tymi klientami. Specyfikacja przewidziała ten przypadek: nazywa dual-era implementację, która obsługuje obie, i wyraźnie zezwala serwerowi obsługiwać obie epoki na tym samym punkcie wejścia. Właśnie to zrobimy, i kosztuje to jakieś dziesięć linii.
Szkielet: jeden plik, jedna trasa
Serwer demonstracyjny nazywa się regexlab i udostępnia dwa narzędzia: regex_test, które testuje wyrażenie regularne na przykładach, i blog_search, które odpytuje publiczne API REST gekkode.com wyłącznie do odczytu. Uruchamiamy go wbudowanym serwerem WWW PHP.
cd docker/articles/2026-09-07/lab/creer-serveur-mcp-php
MCP_TOKEN=demo-token-local php -S 127.0.0.1:8765 mcp.phpZaczynamy od dwóch funkcji opakowujących. Wszystkie odpowiedzi przechodzą przez nie, co gwarantuje, że żadna odpowiedź nie wychodzi bez jawnego kodu HTTP.
<?php
declare(strict_types=1);
const SERVER_NAME = 'regexlab';
const SERVER_VERSION = '0.1.0';
const SUPPORTED_VERSIONS = ['2026-07-28', '2025-11-25', '2025-06-18'];
const ALLOWED_ORIGINS = ['http://127.0.0.1:8765', 'http://localhost:8765'];
function send(int $status, ?array $payload = null): never
{
http_response_code($status);
if ($payload === null) {
exit;
}
header('Content-Type: application/json');
echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n";
exit;
}
function fail(int $status, int $code, string $message, mixed $id = null, ?array $data = null): never
{
$error = ['code' => $code, 'message' => $message];
if ($data !== null) {
$error['data'] = $data;
}
send($status, ['jsonrpc' => '2.0', 'id' => $id, 'error' => $error]);
}Potem przychodzi brama wejściowa: metoda HTTP, ścieżka, origin, token. Cztery kontrole i kody zwrotne narzucone przez specyfikację.
function header_value(string $name): ?string
{
$key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
return isset($_SERVER[$key]) ? trim((string) $_SERVER[$key]) : null;
}
// GET i DELETE nie są już częścią rewizji 2026-07-28.
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
header('Allow: POST');
send(405, ['jsonrpc' => '2.0', 'error' => ['code' => -32600, 'message' => 'Use POST on /mcp']]);
}
if (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) !== '/mcp') {
fail(404, -32601, 'Unknown endpoint');
}
// Origin: obrona przed DNS rebinding, 403 wymagane przez specyfikację.
$origin = header_value('Origin');
if ($origin !== null && !in_array($origin, ALLOWED_ORIGINS, true)) {
fail(403, -32600, 'Origin not allowed');
}
// Token bearer.
$expected = getenv('MCP_TOKEN') ?: '';
if ($expected !== '') {
$sent = (string) preg_replace('/^Bearer\s+/i', '', header_value('Authorization') ?? '');
if (!hash_equals($expected, $sent)) {
header('WWW-Authenticate: Bearer realm="regexlab"');
fail(401, -32001, 'Unauthorized');
}
}Walidacja nagłówka Origin nie jest dekoracyjna. Specyfikacja klasyfikuje ją jako MUST i wymaga 403, właśnie dlatego, że bez niej strona internetowa otwarta w Twojej przeglądarce może, przez DNS rebinding, rozmawiać z serwerem MCP działającym na Twojej maszynie. Sprawdźmy trzy bramy:
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8765/mcp
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8765/mcp \
-H 'Origin: https://evil.example' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
curl -s -X POST http://127.0.0.1:8765/mcp -H 'Content-Type: application/json' \
-H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: server/discover' \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}'405
403
{"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"Unauthorized"}}Odczytać żądanie i rozpoznać epokę
Ciało żądania to JSON-RPC. Wychodzą z niego trzy informacje: metoda, parametry, identyfikator. Obecność io.modelcontextprotocol/protocolVersion w _meta wystarczy, żeby wiedzieć, czy klient jest nowoczesny.
$raw = file_get_contents('php://input') ?: '';
try {
$req = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException) {
fail(400, -32700, 'Parse error');
}
if (!is_array($req) || !isset($req['method'])) {
fail(400, -32600, 'Invalid Request');
}
$method = (string) $req['method'];
$params = is_array($req['params'] ?? null) ? $req['params'] : [];
$id = $req['id'] ?? null;
$meta = is_array($params['_meta'] ?? null) ? $params['_meta'] : [];
$bodyVersion = $meta['io.modelcontextprotocol/protocolVersion'] ?? null;
$modern = is_string($bodyVersion);
error_log(sprintf(
'[mcp] %s | version=%s | agent=%s',
$method,
is_string($bodyVersion) ? $bodyVersion : ($params['protocolVersion'] ?? '-'),
$_SERVER['HTTP_USER_AGENT'] ?? '-'
));
// Powiadomienie nie ma identyfikatora: potwierdzamy odbiór i kończymy.
if (!array_key_exists('id', $req)) {
send(202, null);
}Wywołanie error_log powyżej to dzięki czemu mogłem sporządzić tabelę klientów wyżej. Wbudowany serwer PHP pisze na wyjściu błędów, więc log pojawia się w terminalu, który go uruchomił. Zachowaj je przez cały okres programowania.
Obsługa powiadomień zasługuje na słowo komentarza. Powiadomienie JSON-RPC to wiadomość bez id: klient nie czeka na odpowiedź. Specyfikacja jest kategoryczna, serwer musi odpowiedzieć 202 Accepted bez ciała. Tędy właśnie przechodzi notifications/initialized klientów dziedziczonych.
curl -s -o /dev/null -w "status=%{http_code} octets=%{size_download}\n" \
-X POST http://127.0.0.1:8765/mcp -H 'Authorization: Bearer demo-token-local' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'status=202 octets=0Dlaczego ten serwer zwraca tylko JSON
Słowo o formacie odpowiedzi, bo to wybór, a nie obowiązek. W odpowiedzi na żądanie serwer może zwrócić albo pojedynczy obiekt w Content-Type: application/json, albo strumień SSE w text/event-stream, właściwy temu żądaniu, który niesie powiadomienia przed odpowiedzią końcową. Klient z kolei musi umieć czytać oba: stąd nagłówek Accept: application/json, text/event-stream, który wysyła systematycznie.
Ten serwer trzyma się JSON-a. To najprostsze rozwiązanie, i wystarcza, dopóki żadne narzędzie nie musi zdawać sprawy ze swojego postępu. Strumień staje się konieczny w dwóch przypadkach: długie narzędzie, które chce emitować notifications/progress podczas pracy, oraz żądanie subscriptions/listen, którego odpowiedź zostaje otwarta, żeby nieść zmiany list. Gdy ten dzień nadejdzie, rewizja 2026-07-28 traktuje zamknięcie strumienia przez klienta jako sygnał anulowania żądania, i zaleca nagłówek X-Accel-Buffering: no, żeby nginx nie buforował zdarzeń.
Zwalidować nagłówki lustrzane
To część najbardziej charakterystyczna dla rewizji 2026-07-28, i ta, o której się zapomina. Gdy nowoczesny klient wysyła żądanie metodą POST, musi powtórzyć trzy wartości z ciała w nagłówkach: MCP-Protocol-Version, Mcp-Method, i Mcp-Name dla tools/call. Serwer musi sprawdzić, że nagłówek i ciało się zgadzają, i odrzucić kodem 400 z kodem błędu -32020, jeśli nie.
Powód to klasyczna luka: jeśli load balancer decyduje o routingu na podstawie nagłówka, podczas gdy serwer wykonuje na podstawie ciała, złośliwy klient może podszyć jedno wywołanie pod inne. Specyfikacja nazywa ten błąd HeaderMismatch.
if ($modern) {
$headerVersion = header_value('MCP-Protocol-Version');
if ($headerVersion !== $bodyVersion) {
fail(400, -32020, sprintf(
'Header mismatch: MCP-Protocol-Version %s does not match body value %s',
var_export($headerVersion, true),
var_export($bodyVersion, true)
), $id);
}
if (header_value('Mcp-Method') !== $method) {
fail(400, -32020, 'Header mismatch: Mcp-Method', $id);
}
if ($method === 'tools/call' && header_value('Mcp-Name') !== ($params['name'] ?? null)) {
fail(400, -32020, 'Header mismatch: Mcp-Name', $id);
}
if (!in_array($bodyVersion, SUPPORTED_VERSIONS, true)) {
fail(400, -32022, 'Unsupported protocol version', $id, [
'supported' => SUPPORTED_VERSIONS,
'requested' => $bodyVersion,
]);
}
}Test, który kłamie na temat Mcp-Name: nagłówek zapowiada blog_search, ciało żąda regex_test.
curl -s -X POST http://127.0.0.1:8765/mcp \
-H 'Authorization: Bearer demo-token-local' -H 'Content-Type: application/json' \
-H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: blog_search' \
-d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"regex_test","arguments":{"pattern":"a","subjects":["a"]},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'{"jsonrpc":"2.0","id":5,"error":{"code":-32020,"message":"Header mismatch: Mcp-Name"}}I wersja, której serwer nie zna. Specyfikacja narzuca odpowiedź -32022 z listą akceptowanych wersji, żeby klient mógł spróbować ponownie bez interwencji człowieka.
{
"jsonrpc": "2.0",
"id": 6,
"error": {
"code": -32022,
"message": "Unsupported protocol version",
"data": {
"supported": ["2026-07-28", "2025-11-25", "2025-06-18"],
"requested": "1900-01-01"
}
}
}Odpowiedzieć na odkrywanie, z obu stron
Oto, co czyni serwer dwuepokowym: jeden case dla initialize, jeden dla server/discover, i oba zwracają te same możliwości w dwóch różnych opakowaniach. Pierwszy to te dziesięć linii, które kosztuje pozostanie kompatybilnym.
switch ($method) {
// Odziedziczone uzgadnianie (wersje 2025-11-25 i wcześniejsze).
case 'initialize':
$asked = (string) ($params['protocolVersion'] ?? '2025-11-25');
ok($id, [
'protocolVersion' => in_array($asked, SUPPORTED_VERSIONS, true) ? $asked : '2025-11-25',
'capabilities' => ['tools' => ['listChanged' => false]],
'serverInfo' => ['name' => SERVER_NAME, 'version' => SERVER_VERSION],
'instructions' => 'regex_test teste une expression régulière ; blog_search interroge gekkode.com.',
]);
// Bezstanowe odkrywanie (wersja 2026-07-28).
case 'server/discover':
ok($id, [
'resultType' => 'complete',
'supportedVersions' => SUPPORTED_VERSIONS,
'capabilities' => ['tools' => ['listChanged' => false]],
'_meta' => ['io.modelcontextprotocol/serverInfo' => [
'name' => SERVER_NAME,
'version' => SERVER_VERSION,
]],
'instructions' => 'regex_test teste une expression régulière ; blog_search interroge gekkode.com.',
'ttlMs' => 3600000,
'cacheScope' => 'public',
]);
default:
fail($modern ? 404 : 200, -32601, 'Method not found: ' . $method, $id);
}Dwa szczegóły, których nie można przegapić. serverInfo przemieszcza się: jest w korzeniu wyniku w trybie dziedziczonym, a pod _meta['io.modelcontextprotocol/serverInfo'] w 2026-07-28. A nieznana metoda nie jest traktowana tak samo: w trybie nowoczesnym specyfikacja wymaga 404 Not Found z towarzyszącym -32601, bo to ciało JSON-RPC odróżnia ten przypadek od 404 starego serwera, który nie hostuje tego punktu wejścia.
Oto prawdziwa odpowiedź na server/discover:
curl -s -X POST http://127.0.0.1:8765/mcp \
-H 'Authorization: Bearer demo-token-local' -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: server/discover' \
-d '{"jsonrpc":"2.0","id":"d1","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"8.7.1"},"io.modelcontextprotocol/clientCapabilities":{}}}}'{"jsonrpc":"2.0","id":"d1","result":{"resultType":"complete","supportedVersions":["2026-07-28","2025-11-25","2025-06-18"],"capabilities":{"tools":{"listChanged":false}},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"regexlab","version":"0.1.0"}},"instructions":"regex_test teste une expression régulière ; blog_search interroge gekkode.com.","ttlMs":3600000,"cacheScope":"public"}}A ta na initialize, dokładnie taka, jaką odbiera Codex:
{
"jsonrpc": "2.0",
"id": 0,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": {"tools": {"listChanged": false}},
"serverInfo": {"name": "regexlab", "version": "0.1.0"},
"instructions": "regex_test teste une expression régulière ; blog_search interroge gekkode.com."
}
}Opisać narzędzia: tools/list
Narzędzie to nazwa, opis i schemat JSON wejścia. Opis to to, co czyta model, żeby zdecydować, czy wywołać narzędzie: pisz go dla niego, nie dla dewelopera. outputSchema jest opcjonalny, ale zalecany, pozwala klientowi zwalidować to, co zwracasz.
[
'name' => 'regex_test',
'title' => 'Testeur d\'expression régulière',
'description' => 'Teste une expression régulière PCRE sur une liste de chaînes et retourne, '
. 'pour chacune, si elle correspond et les groupes capturés.',
'inputSchema' => [
'type' => 'object',
'properties' => [
'pattern' => ['type' => 'string', 'description' => 'Motif PCRE, sans délimiteurs.'],
'flags' => ['type' => 'string', 'description' => 'Modificateurs parmi i, m, s, x, u.'],
'subjects' => [
'type' => 'array',
'items' => ['type' => 'string'],
'description' => 'Chaînes à tester (20 au maximum).',
],
],
'required' => ['pattern', 'subjects'],
'additionalProperties' => false,
],
]Odpowiedź na tools/list dodaje resultType i dwa pola cache wprowadzone przez lipcową rewizję.
case 'tools/list':
ok($id, [
'resultType' => 'complete',
'tools' => tool_definitions(),
'ttlMs' => 300000,
'cacheScope' => 'public',
]);Trzy zasady nazewnictwa do przestrzegania: nazwa narzędzia musi mieścić się między 1 a 128 znaków, ograniczać się do liter ASCII, cyfr, _, - i ., i być unikalna w obrębie serwera. Spacje i przecinki są zabronione.
Wykonać narzędzie: tools/call i jego dwie rodziny błędów
Jakość serwera MCP rozgrywa się tutaj, i wiele tutoriali to przegapia. Specyfikacja rozróżnia dwa mechanizmy zgłaszania błędów, a pomylenie ich kosztuje modelu dodatkowe rundy:
- błąd protokołu (nieznane narzędzie, źle sformułowane żądanie) to klasyczny błąd JSON-RPC, model ma niewielkie szanse poradzić sobie sam,
- błąd wykonania (argument poza zakresem, awaria API, nieprawidłowa data) zwraca się w normalnym wyniku z
isError: true, a klient musi przekazać go modelowi, żeby mógł się poprawić.
Tester wyrażeń regularnych to podręcznikowy przypadek: źle napisany wzorzec musi wrócić do modelu z komunikatem PCRE, nie z „błędem 500”.
function run_regex_test(array $args): array
{
$pattern = (string) ($args['pattern'] ?? '');
$flags = (string) ($args['flags'] ?? '');
$subjects = is_array($args['subjects'] ?? null) ? array_values($args['subjects']) : [];
if ($pattern === '' || strlen($pattern) > 512) {
return tool_error('Le motif doit faire entre 1 et 512 caractères.');
}
if ($flags !== '' && !preg_match('/^[imsxu]{1,5}$/', $flags)) {
return tool_error('Modificateurs acceptés : i, m, s, x, u.');
}
if ($subjects === [] || count($subjects) > 20) {
return tool_error('Fournissez entre 1 et 20 chaînes à tester.');
}
// Escapujemy niezabezpieczone ograniczniki, zamiast akceptować już ograniczony wzorzec.
$delimited = '/' . preg_replace('~(?<!\\\\)/~', '\\/', $pattern) . '/' . $flags;
$compileError = null;
set_error_handler(static function (int $no, string $msg) use (&$compileError): bool {
$compileError = $msg;
return true;
});
$compiles = preg_match($delimited, '');
restore_error_handler();
if ($compiles === false) {
// Komunikat PCRE mówi, gdzie wzorzec się psuje: model może sam się poprawić.
return tool_error('Motif invalide : ' . ($compileError ?? preg_last_error_msg()));
}
// …
}Dwa środki ostrożności w tych kilku liniach. Nigdy nie akceptujemy wzorca już ograniczonego, sami dodajemy ograniczniki, escapując niezabezpieczone /. Zaakceptowanie /wzorzec/flagi wprost oznaczałoby pozwolenie wywołującemu wybrać modyfikatory, czego zabrania wcześniejsza walidacja. A tymczasowa obsługa błędów przechwytuje dokładny komunikat PCRE, tam gdzie preg_last_error_msg() zadowala się lakonicznym „Internal error”.
Różnica widać przy wywołaniu. Wzorzec z brakującym nawiasem:
{
"jsonrpc": "2.0",
"id": 8,
"result": {
"resultType": "complete",
"content": [
{
"type": "text",
"text": "Motif invalide : preg_match(): Compilation failed: missing closing parenthesis at offset 7"
}
],
"isError": true
}
}Model, który otrzymuje ten tekst, zamyka nawias i ponownie wywołuje narzędzie. Gdyby otrzymał błąd JSON-RPC, miałby dużo mniejsze szanse, żeby sobie poradzić: specyfikacja zauważa, że błędy protokołu rzadko prowadzą do poprawki.
Pozostaje ryzyko właściwe wyrażeniom regularnym: eksplozja kombinatoryczna. Wzorzec taki jak (a+)+$ na ciągu trzydziestu sześciu „a” zakończonym „b” zajmuje procesor bardzo długo. Zabezpieczenie mieści się w jednej linii na początku pliku, ini_set('pcre.backtrack_limit', '200000');, i w teście wyniku zwracanego przez preg_match.
{
"jsonrpc": "2.0",
"id": 9,
"result": {
"resultType": "complete",
"content": [{"type": "text", "text": "Échec du moteur PCRE : Backtrack limit exhausted"}],
"isError": true
}
}Udane wywołanie, tym razem, z jego structuredContent zgodnym z zadeklarowanym outputSchema:
curl -s -X POST http://127.0.0.1:8765/mcp \
-H 'Authorization: Bearer demo-token-local' -H 'Content-Type: application/json' \
-H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: regex_test' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"regex_test","arguments":{"pattern":"^([A-Z]{2})-(\\d{4})$","subjects":["FR-2026","fr-2026","XX-12"]},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'{
"jsonrpc": "2.0",
"id": 3,
"result": {
"resultType": "complete",
"content": [
{
"type": "text",
"text": "1 correspondance(s) sur 3 chaîne(s).\nOUI FR-2026 -> FR | 2026\nNON fr-2026\nNON XX-12"
}
],
"structuredContent": {
"pattern": "^([A-Z]{2})-(\\d{4})$",
"matches": 1,
"results": [
{"subject": "FR-2026", "matched": true, "groups": ["FR", "2026"]},
{"subject": "fr-2026", "matched": false, "groups": []},
{"subject": "XX-12", "matched": false, "groups": []}
]
},
"isError": false
}
}Zauważ, że czytelny tekst jest obecny oprócz treści ustrukturyzowanej. Specyfikacja wyraźnie to zaleca: narzędzie, które zwraca dane ustrukturyzowane, powinno też dostarczyć wersję zserializowaną w bloku tekstowym, dla klientów, którzy czytają tylko content.
Drugie narzędzie: odpytać API wyłącznie do odczytu
blog_search pokazuje najczęstszy przypadek w firmie: udostępnienie istniejącej usługi. Host jest zakodowany na sztywno, metoda to GET, żaden parametr wywołującego nie konstruuje dowolnego adresu URL.
function run_blog_search(array $args): array
{
$query = trim((string) ($args['query'] ?? ''));
$limit = max(1, min(5, (int) ($args['limit'] ?? 3)));
if ($query === '' || mb_strlen($query) > 120) {
return tool_error('La requête doit faire entre 1 et 120 caractères.');
}
$url = BLOG_API . '?' . http_build_query([
'search' => $query,
'per_page' => $limit,
'orderby' => 'relevance',
'_fields' => 'id,date,link,title',
]);
$context = stream_context_create(['http' => [
'method' => 'GET',
'timeout' => 8,
'header' => "Accept: application/json\r\nUser-Agent: regexlab-mcp/0.1\r\n",
'ignore_errors' => true,
]]);
$body = @file_get_contents($url, false, $context);
if ($body === false) {
return tool_error('API du blog injoignable.');
}
// …
}_fields z API REST WordPressa wykonuje mnóstwo pracy: ogranicza odpowiedź do czterech przydatnych pól, a więc tokenów naliczanych przy przejściu przez kontekst modelu. To ten sam odruch, który opisujemy w naszym artykule o redukcji tokenów, zastosowany do serwera zamiast do klienta. Wynik wywołania:
2024-08-11 — Les 10 meilleurs packages Laravel pour créer votre site web
Les 10 meilleurs packages Laravel pour créer votre site web
2023-02-03 — Comment installer Redis sur Debian et Laravel
https://www.gekkode.com/developpement/comment-installer-redis-sur-debian-et-laravel/Podłączyć serwer do Codex
Codex czyta swoje serwery MCP z ~/.codex/config.toml, ale opcja -c pozwala zadeklarować je na jedno uruchomienie, bez zapisywania czegokolwiek na dysku. Idealne do testu, i tą właśnie drogą serwer został zweryfikowany.
export REGEXLAB_TOKEN=demo-token-local
codex exec \
-c 'mcp_servers.regexlab.url="http://127.0.0.1:8765/mcp"' \
-c 'mcp_servers.regexlab.bearer_token_env_var="REGEXLAB_TOKEN"' \
-c 'mcp_servers.regexlab.default_tools_approval_mode="approve"' \
-m gpt-6-astra --skip-git-repo-check \
"Utilise UNIQUEMENT l'outil MCP regexlab.regex_test. Teste le motif ^([A-Z]{2})-(\d{4})\$ sur les chaînes FR-2026, fr-2026 et XX-12."codex
Je vais tester ce motif sur les trois chaînes avec l'outil demandé.
mcp: regexlab/regex_test started
mcp: regexlab/regex_test (completed)
codex
Il y a 1 correspondance sur 3 : « FR-2026 », avec les groupes capturés « FR » et « 2026 ».
tokens used
9 342Trzecie -c to to, które kosztowało mnie dwie próby. Bez niego Codex łączy się z serwerem, wyświetla listę narzędzi i odmawia wywołania z jednoznacznym komunikatem:
mcp: regexlab/regex_test started
mcp: regexlab/regex_test (failed)
MCP tool call requires approval, but approval policy is neverW trybie exec polityka zatwierdzania ma wartość never i żaden człowiek nie jest obecny, żeby zatwierdzić. Klucz default_tools_approval_mode przyjmuje cztery wartości, auto, prompt, writes i approve, tylko ta ostatnia przepuszcza wywołanie bez interwencji. Zarezerwuj to dla serwerów, które sam napisałeś, z powodów opisanych szczegółowo w naszym artykule o sandboksie i uprawnieniach.
Dla trwałej instalacji oficjalna komenda zapisuje to samo w konfiguracji:
codex mcp add regexlab --url http://127.0.0.1:8765/mcp \
--bearer-token-env-var REGEXLAB_TOKEN
codex mcp list
codex mcp get regexlab
codex mcp remove regexlabPodłączyć serwer do Claude Code
Po stronie Claude Code dodanie serwera HTTP mieści się w jednej komendzie, a nagłówek autoryzacji przekazuje się przez --header.
claude mcp add --transport http regexlab http://127.0.0.1:8765/mcp \
--header "Authorization: Bearer demo-token-local"
claude mcp list
claude mcp get regexlabŻeby udostępnić serwer zespołowi, zakres project zapisuje plik .mcp.json w katalogu głównym repozytorium, możliwy do wersjonowania. Dokumentacja akceptuje rozwijanie zmiennych środowiskowych, co pozwala uniknąć commitowania tokena:
{
"mcpServers": {
"regexlab": {
"type": "http",
"url": "http://127.0.0.1:8765/mcp",
"headers": {
"Authorization": "Bearer ${REGEXLAB_TOKEN}"
}
}
}
}Składnia ${VAR} i ${VAR:-wartość domyślna} jest rozpoznawana w polach url, headers, command, args i env. Z poziomu sesji /mcp pokazuje stan serwerów i pozwala połączyć się z tymi, które wymagają OAuth.
Zweryfikować za pomocą MCP Inspector, bez instalowania czegokolwiek
Oficjalne narzędzie testowe używa się w trybie graficznym, ale jego tryb --cli jest dużo praktyczniejszy do szybkiego testu i uruchamia się przez npx, czyli bez globalnej instalacji.
npx -y @modelcontextprotocol/inspector@2.5.0 --cli http://127.0.0.1:8765/mcp \
--transport http --header "Authorization: Bearer demo-token-local" \
--method tools/call --tool-name regex_test \
--tool-arg 'pattern=^\d{5}$' --tool-arg 'subjects=["62500","6250"]'{
"content": [
{"type": "text", "text": "1 correspondance(s) sur 2 chaîne(s).\nOUI 62500\nNON 6250"}
],
"structuredContent": {
"pattern": "^\\d{5}$",
"matches": 1,
"results": [
{"subject": "62500", "matched": true, "groups": []},
{"subject": "6250", "matched": false, "groups": []}
]
},
"isError": false
}Inspector 2.5.0, opublikowany 2 września 2026, również otwiera przez initialize, zapowiadając wersję 2025-11-25. Pozostaje najszybszym narzędziem, żeby zobaczyć, co Twój serwer naprawdę zwraca, zanim podłączysz do niego agenta.
Czego nie potrafi wbudowany serwer PHP
php -S obsługuje jedno żądanie naraz. Dopóki Twoje narzędzia odpowiadają w kilka milisekund, tego nie widać. Gdy tylko jakieś narzędzie wywołuje sieć, cały serwer się blokuje. Uruchomiłem dwa wywołania równolegle, blog_search, które odpytuje API bloga, a potem regex_test, które robi tylko lokalne obliczenia:
| Konfiguracja | blog_search | regex_test uruchomiony 50 ms później |
|---|---|---|
php -S domyślnie | 0,601 s | 0,548 s |
PHP_CLI_SERVER_WORKERS=4 | 0,578 s | 0,002 s |
Bez dodatkowych procesów szybkie wywołanie grzecznie czeka na koniec wolnego wywołania: 0,548 s zamiast 13 ms, które zajmuje samo. Z czterema pracownikami odpowiada natychmiast. To wystarcza do programowania, nie zastępuje PHP-FPM za nginksem na produkcji.
Przejść na produkcję: oficjalne SDK PHP i Laravel MCP
Pisanie własnego serwera ręcznie to dobry sposób na zrozumienie protokołu. Dla kodu, który żyje na produkcji, dwa pakiety zasługują na uwagę, i oba ruszyły się tego lata.
Oficjalne SDK PHP instaluje się przez composer require mcp/sdk. Prezentuje się jako oficjalne SDK protokołu dla PHP, utrzymywane we współpracy z Fundacją PHP i przejmujące praktyki projektu Symfony. Jest niezależne od frameworka, wymaga minimum PHP 8.1, a jego repozytorium zapowiada obsługę obu epok protokołu, uzgadniania initialize i bezstanowej rewizji 2026-07-28. Ostatnia wersja na 7 września 2026: v0.8.1, opublikowana 29 sierpnia.
Laravel MCP (composer require laravel/mcp) celuje w drugi kraniec spektrum: deklarujesz swoje serwery w routes/ai.php, z Mcp::web('/mcp/weather', WeatherServer::class) dla serwera HTTP albo Mcp::local('weather', …) dla komendy Artisan, a każde narzędzie staje się klasą rozszerzającą Tool, z metodą handle() i schema(). Middleware Laravela stosuje się bez zmian, łącznie z throttle. Pierwsza stabilna wersja jest w przygotowaniu: v1.0.0-beta.1 pochodzi z 14 sierpnia 2026 i wymaga PHP 8.2 z Laravel 11.45.3, 12.41.1 albo 13.
Jeśli Twoją potrzebą jest WordPress albo PrestaShop, a nie generyczny serwer, dwa artykuły z tej serii omawiają ten przypadek: MCP dla WordPressa i MCP dla PrestaShop.
Pięć linijek bezpieczeństwa, które nic nie kosztują
Serwer MCP to powierzchnia wykonania kodu dostępna dla modelu. Specyfikacja poświęca temu tematowi całą stronę, zapamiętaj przynajmniej te zasady, wszystkie przestrzegane przez plik z tego tutorialu.
- Nasłuchuj na 127.0.0.1, nigdy na 0.0.0.0, w przypadku serwera lokalnego. Specyfikacja klasyfikuje to jako SHOULD.
- Waliduj nagłówek
Origini odpowiadaj 403, gdy nie figuruje on na Twojej liście. Bez tego strona internetowa otwarta w Twojej przeglądarce może rozmawiać z Twoim serwerem. - Wymagaj tokena, porównywanego przez
hash_equals(), żeby nie wyciekać informacji przez czas porównania. - Pozostań tylko do odczytu, dopóki nie potrzebujesz zapisu, i ogranicz wszystko: długość ciągów, liczbę elementów, limit czasu sieci, limit backtrackingu.
- Zakoduj hosta na sztywno w narzędziach, które wywołują sieć. Dowolny parametr URL zamienia Twój serwer w przekaźnik do eksfiltracji.
Ten ostatni punkt nie jest teoretyczny. Publiczne incydenty ekosystemu MCP odnotowane do tej pory dotyczą prawie wszystkie serwerów, które robiły więcej, niż zapowiadały: pakiet opublikowany na npm, który przy okazji kopiował e-maile, które miał wysyłać, przekaźnik, którego luka otwierała wykonanie komend. Narzędzie niezdolne do zrobienia niczego poza tym, co mówi jego opis, to narzędzie, które można audytować. Kwestia zakresu agenta i jego narzędzi jest omówiona szczegółowo w artykule o sandboksie i uprawnieniach.
Co warto zapamiętać
- Użyteczny serwer MCP mieści się w pliku PHP liczącym 380 linii: trasa
POST /mcp, JSON-RPC 2.0, i dwie funkcje na narzędzie. - Rewizja 2026-07-28 znosi
initialize, sesje i strumieńGET, ale Codex i Inspector wciąż otwierają połączenie dziedziczonym uzgadnianiem: napisz serwer, który obsługuje obie epoki. - W trybie nowoczesnym powtarzaj i sprawdzaj
MCP-Protocol-Version,Mcp-MethodiMcp-Name, z-32020w razie rozbieżności i-32022dla nieznanej wersji. - Zwracaj błędy biznesowe w wyniku z
isError: true, nie jako błąd JSON-RPC: to właśnie pozwala modelowi poprawić się samodzielnie. - Testuj curlem, potem przez
npx @modelcontextprotocol/inspector --cli, zanim podłączysz agenta. php -Ssłuży tylko do programowania: wolne wywołanie sieciowe blokuje cały serwer, dopókiPHP_CLI_SERVER_WORKERSnie jest ustawione.- Do produkcji wyjdź od oficjalnego SDK
mcp/sdkalbolaravel/mcp, zamiast utrzymywać własną warstwę transportu.
Częste błędy
initialize. Zachowaj przypadek initialize obok server/discover: specyfikacja zezwala serwerowi obsługiwać obie epoki na tym samym punkcie wejścia.MCP-Protocol-Version, Mcp-Method i Mcp-Name muszą odpowiadać ciału. Rozbieżność odrzuca się kodem 400 z kodem -32020, inaczej pośrednik i serwer mogą odczytać dwie różne rzeczy.id nie czeka na odpowiedź. Zwróć 202 Accepted bez ciała, inaczej klienci dziedziczeni utkną na notifications/initialized.isError: true i komunikatem, na którym da się coś oprzeć. Błąd protokołu sprawia, że model się poddaje, błąd wykonania sprawia, że się poprawia.codex exec bez ustawienia zatwierdzania Polityka ma wartość never i Codex odmawia komunikatem „MCP tool call requires approval”. Dodaj -c 'mcp_servers.NAZWA.default_tools_approval_mode="approve"', i tylko dla serwera, który sam napisałeś.

