Een MCP-server in PHP bouwen zonder dependencies

Een MCP-server conform de specificatie 2026-07-28, in pure PHP 8, zonder Composer of framework. Geschreven, uitgevoerd en aangeroepen vanuit Codex, met de echte antwoorden.

Een MCP-server in PHP bouwen zonder dependencies
Kort antwoord

Een MCP-server is een dienst die tools aan een AI-agent aanbiedt in JSON-RPC 2.0, beschreven door een schema dat de client bij het uitvoeren leest met tools/list. In PHP past hij in een bestand van 380 regels: een route POST /mcp, een bearer-token, en een functie per tool. Let op, de revisie 2026-07-28 schrapt de handshake initialize, maar Codex en MCP Inspector 2.5.0 gebruiken die nog altijd: je server moet beide beheersen.

Je hebt een interne API, een productcatalogus of een logdatabase, en je zou willen dat Claude Code of Codex die rechtstreeks gebruikt in plaats van je om kopieerwerk te vragen. Dat is het werk van een MCP-server, en die past in één enkel PHP-bestand: dat van deze tutorial telt 380 regels, zonder Composer, zonder framework en zonder andere dependency dan PHP 8.

Wat is een MCP-server precies?

Het Model Context Protocol is een open protocol dat standaardiseert hoe een AI-toepassing zich koppelt aan externe data en tools. De specificatie onderscheidt drie rollen: de hosts, toepassingen die de verbindingen opzetten. De clients, connectoren binnen de host. De servers, diensten die de context en de mogelijkheden leveren. De berichten zijn JSON-RPC 2.0.

Een server kan drie dingen aanbieden: resources (context en data), prompts (berichtsjablonen) en tools (functies die het model uitvoert). Hier implementeren we alleen de tools, omdat dat in de meeste gevallen van pas komt en omdat het het onderdeel is dat je met curl kunt testen.

MCP of REST-API: wat verandert er echt?

MCP vervangt REST niet, het legt er een beschrijvingslaag bovenop. Dit is wat er concreet verschilt.

Punt Klassieke REST-API MCP-server
Ontdekking Documentatie om te lezen, een OpenAPI-bestand om te laden tools/list geeft bij het uitvoeren de JSON-schema’s van elke tool terug
Aanroeper Code die je zelf schrijft Het model, dat de tool kiest op basis van zijn beschrijving
Formaat Wat je zelf wilt JSON-RPC 2.0, verplicht
Fouten HTTP-codes Twee families: protocolfouten en uitvoeringsfouten van een tool
Integratie Eén adapter per client Eén server, alle compatibele agents

Het laatste punt is het enige dat er echt toe doet. Een MCP-server die je één keer schrijft, koppelt aan Claude Code, aan Codex, aan de Inspector, en aan de andere clients van het ecosysteem, zonder één regel aanpassing. Dat is dezelfde belofte als bij skills, waarvan we het formaat uitwerken in de gids over het SKILL.md-bestand.

Waarom je server twee talen moet spreken

Dat is de valkuil van deze tutorial, en die leg je best meteen op tafel. De revisie 2026-07-28 van de specificatie, gepubliceerd op 28 juli 2026 door David Soria Parra en Den Delimarsky, heeft de handshake geschrapt. Geen initialize meer, geen notificatie notifications/initialized meer, geen header Mcp-Session-Id meer. Elk verzoek draagt voortaan zijn protocolversie en de identiteit van de client in een veld _meta, en de server kan zonder gedeelde staat gerepliceerd worden.

De overige wijzigingen van dezelfde revisie raken rechtstreeks het HTTP-transport:

  • het eindpunt accepteert alleen nog POST, de GET-stream en de DELETE voor het einde van de sessie zijn verdwenen,
  • een methode server/discover vervangt de ontdekking, en servers moeten ze implementeren.
  • headers Mcp-Method en Mcp-Name nemen velden uit de body over, zodat een load balancer kan routeren zonder de JSON te lezen.
  • lijstantwoorden dragen ttlMs en cacheScope mee om in de cache te kunnen.

Op papier zou het dus volstaan om een 2026-07-28-server te schrijven. Behalve dat ik de openingsmethode en de User-Agent-header van elke client die zich op 7 september 2026 met mijn server verbond, heb gelogd, en dit is wat ik zag.

Client User-Agent Opening Aangekondigde versie
Codex codex-mcp-client/0.153.4 initialize 2025-06-18
MCP Inspector 2.5.0 node initialize 2025-11-25

Geen van beide stuurde een server/discover, en geen van beide zette _meta in zijn verzoeken. Een server die alleen de julirevisie zou spreken, zou vandaag onbruikbaar zijn met deze clients. De specificatie heeft dit geval voorzien: ze noemt een implementatie die beide beheerst dual-era, en staat een server expliciet toe om beide tijdperken op hetzelfde eindpunt te bedienen. Dat is wat we gaan doen, en het kost een tiental regels.

Het skelet: één bestand, één route

De demonstratieserver heet regexlab en stelt twee tools beschikbaar: regex_test, die een reguliere expressie op voorbeelden test, en blog_search, die de publieke REST-API van gekkode.com alleen-lezend bevraagt. Je start hem met de ingebouwde webserver van PHP.

bash
cd docker/articles/2026-09-07/lab/creer-serveur-mcp-php
MCP_TOKEN=demo-token-local php -S 127.0.0.1:8765 mcp.php

We beginnen met twee omhullende functies. Alle uitvoer loopt erdoorheen, wat garandeert dat geen enkel antwoord vertrekt zonder expliciete HTTP-code.

php
<?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]);
}

Dan komt de toegangspoort: HTTP-methode, pad, origin, token. Vier controles, en de retourcodes die de specificatie oplegt.

php
function header_value(string $name): ?string
{
    $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
    return isset($_SERVER[$key]) ? trim((string) $_SERVER[$key]) : null;
}

// GET en DELETE maken geen deel meer uit van de revisie 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: verdediging tegen DNS-rebinding, 403 vereist door de specificatie.
$origin = header_value('Origin');
if ($origin !== null && !in_array($origin, ALLOWED_ORIGINS, true)) {
    fail(403, -32600, 'Origin not allowed');
}

// Bearer-token.
$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');
    }
}

De validatie van de header Origin is niet decoratief. De specificatie classificeert dit als MUST en eist een 403, precies omdat zonder die controle een webpagina die open staat in je browser, via DNS-rebinding, kan praten met de MCP-server die op je machine draait. Laten we de drie poorten controleren:

bash
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":{}}'
json
405
403
{"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"Unauthorized"}}

Het verzoek lezen en het tijdperk herkennen

De body is JSON-RPC. Daar komen drie gegevens uit: de methode, de parameters, het id. De aanwezigheid van io.modelcontextprotocol/protocolVersion in _meta volstaat om te weten of de client modern is.

php
$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'] ?? '-'
));

// Een notificatie heeft geen id: je bevestigt ontvangst en stopt.
if (!array_key_exists('id', $req)) {
    send(202, null);
}

De aanroep van error_log hierboven is wat me in staat stelde de tabel met clients hierboven op te stellen. De ingebouwde server van PHP schrijft naar de foutuitvoer, dus het logboek verschijnt in de terminal die hem heeft gestart. Houd ze aan tijdens de hele ontwikkeling.

De verwerking van notificaties verdient een woord uitleg. Een JSON-RPC-notificatie is een bericht zonder id: de client verwacht geen antwoord. De specificatie is categoriek, de server moet antwoorden met 202 Accepted zonder body. Daar loopt de notifications/initialized van de oudere clients doorheen.

bash
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"}'
bash
status=202 octets=0

Waarom deze server alleen JSON teruggeeft

Een woord over het antwoordformaat, want het is een keuze en geen verplichting. Op een verzoek kan de server antwoorden met een enkel object in Content-Type: application/json, of met een SSE-stream in text/event-stream, eigen aan dat verzoek, die notificaties draagt vóór het uiteindelijke antwoord. De client moet beide kunnen lezen: dat is de reden voor de header Accept: application/json, text/event-stream die hij systematisch meestuurt.

Deze server houdt het bij JSON. Dat is het eenvoudigst, en het volstaat zolang geen enkele tool verslag hoeft te doen van zijn voortgang. De stream wordt nodig in twee gevallen: een lang lopende tool die tijdens zijn werk notifications/progress wil versturen, en het verzoek subscriptions/listen, waarvan het antwoord open blijft om lijstwijzigingen te dragen. Wanneer die dag komt, maakt de revisie 2026-07-28 van het sluiten van de stream door de client het annuleringssignaal van het verzoek maakt, en de header X-Accel-Buffering: no aanraadt zodat nginx de events niet buffert.

De spiegelheaders valideren

Dit is het onderdeel dat het meest specifiek is voor de revisie 2026-07-28, en dat je vergeet. Wanneer een moderne client een verzoek post, moet hij drie waarden uit de body overnemen in headers: MCP-Protocol-Version, Mcp-Method, en Mcp-Name voor een tools/call. De server moet controleren dat de header en de body overeenkomen, en anders afwijzen met 400 en de foutcode -32020.

De reden is een klassiek lek: als een load balancer op basis van de header routeert terwijl de server op basis van de body uitvoert, kan een kwaadwillende client de ene aanroep voor een andere laten doorgaan. De specificatie noemt deze fout HeaderMismatch.

php
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,
        ]);
    }
}

Een test die liegt over Mcp-Name: de header kondigt blog_search aan, de body vraagt om regex_test.

bash
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"}}}'
json
{"jsonrpc":"2.0","id":5,"error":{"code":-32020,"message":"Header mismatch: Mcp-Name"}}

En een versie die de server niet kent. De specificatie legt op te antwoorden met -32022 en daarbij de geaccepteerde versies op te sommen, zodat de client zonder menselijke tussenkomst opnieuw kan proberen.

json
{
  "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"
    }
  }
}

Antwoorden op de ontdekking, aan beide kanten

Dit is wat de server bi-temporeel maakt: een case voor initialize, één voor server/discover, en beide geven dezelfde mogelijkheden terug in twee verschillende verpakkingen. Dit is het tiental regels dat het kost om compatibel te blijven.

php
switch ($method) {
    // Oudere handshake (revisies 2025-11-25 en eerder).
    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.',
        ]);

    // Staatloze ontdekking (revisie 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);
}

Twee details om niet te missen. De serverInfo verhuist: hij staat aan de wortel van het resultaat in de oudere modus, en onder _meta['io.modelcontextprotocol/serverInfo'] in 2026-07-28. En de onbekende methode wordt niet hetzelfde behandeld: in moderne modus vraagt de specificatie een 404 Not Found vergezeld van een -32601, omdat de JSON-RPC-body dit geval onderscheidt van de 404 van een oude server die het eindpunt niet host.

Hier is het echte antwoord op server/discover:

bash
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":{}}}}'
json
{"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"}}

En dat op initialize, zoals Codex het ontvangt:

json
{
  "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."
  }
}

De tools beschrijven: tools/list

Een tool is een naam, een beschrijving, en een JSON-invoerschema. De beschrijving is wat het model leest om te beslissen of het de tool aanroept: schrijf ze voor hem, niet voor een ontwikkelaar. De outputSchema is optioneel maar aanbevolen, hij laat de client valideren wat je teruggeeft.

php
[
    '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,
    ],
]

Het antwoord op tools/list voegt resultType toe, en de twee cachevelden die de julirevisie heeft geïntroduceerd.

php
case 'tools/list':
    ok($id, [
        'resultType' => 'complete',
        'tools' => tool_definitions(),
        'ttlMs' => 300000,
        'cacheScope' => 'public',
    ]);

Drie naamgevingsregels om te respecteren: de naam van een tool moet tussen 1 en 128 tekens lang zijn, zich beperken tot ASCII-letters, cijfers, _, - en ., en uniek zijn binnen de server. Spaties en komma’s zijn verboden.

Een tool uitvoeren: tools/call en zijn twee foutfamilies

De kwaliteit van een MCP-server wordt hier bepaald, en veel tutorials gaan eraan voorbij. De specificatie onderscheidt twee mechanismen om fouten te melden, en ze door elkaar halen kost het model heen-en-weer-rondes:

  • een protocolfout (onbekende tool, misvormd verzoek) is een klassieke JSON-RPC-fout, het model heeft weinig kans om er zelf uit te komen,
  • een uitvoeringsfout (argument buiten bereik, API onbereikbaar, ongeldige datum) wordt teruggegeven in een normaal resultaat met isError: true, en de client moet die aan het model doorgeven zodat het zichzelf kan corrigeren.

De tester voor reguliere expressies is een schoolvoorbeeld: een verkeerd geschreven patroon moet met het PCRE-bericht naar het model terugkeren, niet met een “error 500”.

php
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.');
    }

    // We escapen de niet-beveiligde scheidingstekens in plaats van een al afgebakend patroon te accepteren.
    $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) {
        // Het PCRE-bericht zegt waar het patroon breekt: het model kan zichzelf corrigeren.
        return tool_error('Motif invalide : ' . ($compileError ?? preg_last_error_msg()));
    }
    // …
}

Twee voorzorgsmaatregelen in deze paar regels. We accepteren nooit een al afgebakend patroon, we voegen zelf de scheidingstekens toe door de niet-beveiligde / te escapen. /motif/flags zomaar accepteren zou erop neerkomen dat je de aanroeper de modifiers laat kiezen, wat de validatie stroomopwaarts verbiedt. En de tijdelijke foutafhandelaar haalt het exacte bericht van PCRE op, daar waar preg_last_error_msg() het houdt bij een laconiek “Internal error”.

Het verschil zie je bij de aanroep. Patroon met een ontbrekend haakje:

json
{
  "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
  }
}

Een model dat deze tekst ontvangt, sluit het haakje en roept de tool opnieuw aan. Had het een JSON-RPC-fout ontvangen, dan had het veel minder kans gehad om eruit te komen: de specificatie merkt op dat protocolfouten zelden tot een correctie leiden.

Er blijft een risico dat eigen is aan reguliere expressies: de combinatorische explosie. Een patroon als (a+)+$ op een reeks van zesendertig “a”’s gevolgd door een “b” houdt de processor extreem lang bezig. De remedie past in één regel bovenaan het bestand, ini_set('pcre.backtrack_limit', '200000');, en in een test van de returnwaarde van preg_match.

json
{
  "jsonrpc": "2.0",
  "id": 9,
  "result": {
    "resultType": "complete",
    "content": [{"type": "text", "text": "Échec du moteur PCRE : Backtrack limit exhausted"}],
    "isError": true
  }
}

Nu een aanroep die slaagt, met zijn structuredContent conform het aangegeven outputSchema:

bash
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"}}}'
json
{
  "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
  }
}

Merk op dat de leesbare tekst aanwezig is naast de gestructureerde inhoud. De specificatie beveelt dit expliciet aan: een tool die iets gestructureerds teruggeeft, zou ook de geserialiseerde versie in een tekstblok moeten leveren, voor clients die alleen content lezen.

De tweede tool: een API alleen-lezend bevragen

blog_search toont het meest voorkomende geval in een bedrijf: een bestaande dienst blootstellen. De host staat hardgecodeerd, de methode is een GET, geen enkele parameter van de aanroeper bouwt een willekeurige URL op.

php
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.');
    }
    // …
}

De _fields van de REST-API van WordPress doet veel werk: hij beperkt het antwoord tot de vier nuttige velden, en dus de tokens die worden aangerekend zodra ze in de context van het model belanden. Dat is dezelfde reflex als in ons artikel over het verminderen van tokens, hier toegepast op de server in plaats van op de client. Resultaat van de aanroep:

bash
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/

De server koppelen aan Codex

Codex leest zijn MCP-servers in ~/.codex/config.toml, maar met de optie -c kun je ze voor één enkele uitvoering declareren, zonder iets op schijf te schrijven. Ideaal voor een test, en langs die weg is de server hier gecontroleerd.

bash
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."
bash
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 342

De derde -c is degene die me twee pogingen heeft gekost. Zonder die verbindt Codex zich met de server, somt de tools op, en weigert de aanroep met een ondubbelzinnig bericht:

bash
mcp: regexlab/regex_test started
mcp: regexlab/regex_test (failed)
MCP tool call requires approval, but approval policy is never

In modus exec staat het goedkeuringsbeleid op never en is er geen mens om te valideren. De sleutel default_tools_approval_mode accepteert vier waarden, auto, prompt, writes en approve, alleen de laatste laat de aanroep zonder tussenkomst door. Voorbehouden aan servers die je zelf hebt geschreven, om de redenen die worden uitgelegd in ons artikel over sandboxen en permissies.

Voor een blijvende installatie schrijft het officiële commando hetzelfde weg in de configuratie:

bash
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 regexlab

De server koppelen aan Claude Code

Bij Claude Code past het toevoegen van een HTTP-server in één commando, en de autorisatieheader geef je mee met --header.

bash
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

Om de server met het team te delen, schrijft de scope project een bestand .mcp.json in de root van de repository, dat je kunt versiebeheren. De documentatie accepteert de expansie van omgevingsvariabelen, wat voorkomt dat je het token commit:

json
{
  "mcpServers": {
    "regexlab": {
      "type": "http",
      "url": "http://127.0.0.1:8765/mcp",
      "headers": {
        "Authorization": "Bearer ${REGEXLAB_TOKEN}"
      }
    }
  }
}

De syntax ${VAR} en ${VAR:-standaardwaarde} wordt herkend in de velden url, headers, command, args en env. Vanuit de sessie toont /mcp de status van de servers en laat het je verbinden met servers die OAuth vereisen.

Controleren met MCP Inspector, zonder iets te installeren

De officiële testtool gebruik je normaal in grafische modus, maar zijn modus --cli is veel praktischer voor een snelle test en start via npx, dus zonder globale installatie.

bash
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"]'
json
{
  "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
}

De Inspector 2.5.0, gepubliceerd op 2 september 2026, opent ook met een initialize, met aankondiging van versie 2025-11-25. Het blijft de snelste tool om te zien wat je server werkelijk teruggeeft, voordat je er een agent op aansluit.

Wat de ingebouwde server van PHP niet kan

php -S verwerkt telkens één verzoek. Zolang je tools binnen enkele milliseconden antwoorden, merk je daar niets van. Zodra een tool het netwerk aanroept, blokkeert de hele server. Ik heb twee aanroepen parallel gestart, blog_search die de API van de blog bevraagt en dan regex_test die alleen lokale berekeningen doet:

Configuratie blog_search regex_test 50 ms later gestart
php -S standaard 0,601 s 0,548 s
PHP_CLI_SERVER_WORKERS=4 0,578 s 0,002 s

Zonder extra processen wacht de snelle aanroep braaf op het einde van de trage aanroep: 0,548 s in plaats van de 13 ms die hij alleen nodig heeft. Met vier workers antwoordt hij meteen. Dat volstaat om te ontwikkelen, het vervangt PHP-FPM achter nginx in productie niet.

Naar productie: de officiële PHP-SDK en Laravel MCP

Je server met de hand schrijven is de juiste manier om het protocol te begrijpen. Voor code die blijft leven, zijn twee packages een omweg waard, en allebei zijn ze deze zomer veranderd.

De officiële PHP-SDK installeer je met composer require mcp/sdk. Hij presenteert zich als de officiële SDK van het protocol voor PHP, onderhouden in samenwerking met de PHP Foundation en met overname van de praktijken van het Symfony-project. Hij is framework-agnostisch, vereist minimaal PHP 8.1, en zijn repository kondigt ondersteuning aan voor beide protocoltijdperken, de handshake initialize en de staatloze revisie 2026-07-28. Laatste versie op 7 september 2026: v0.8.1, gepubliceerd op 29 augustus.

Laravel MCP (composer require laravel/mcp) mikt op het andere eind van het spectrum: je declareert je servers in routes/ai.php, met Mcp::web('/mcp/weather', WeatherServer::class) voor een HTTP-server of Mcp::local('weather', …) voor een Artisan-commando, en elke tool wordt een klasse die Tool uitbreidt met een methode handle() en een schema(). De Laravel-middleware is gewoon van toepassing, inclusief throttle. De eerste stabiele versie is in voorbereiding: v1.0.0-beta.1 dateert van 14 augustus 2026 en vereist PHP 8.2 met Laravel 11.45.3, 12.41.1 of 13.

Is je behoefte WordPress of PrestaShop in plaats van een generieke server, dan behandelen twee artikelen uit deze reeks dat geval: MCP voor WordPress en MCP voor PrestaShop.

Vijf regels beveiliging die niets kosten

Een MCP-server is een uitvoeringsoppervlak voor code dat toegankelijk is voor een model. De specificatie wijdt er een hele pagina aan, onthoud minstens deze regels, allemaal nageleefd door het bestand van deze tutorial.

  • Luister op 127.0.0.1, nooit op 0.0.0.0, voor een lokale server. De specificatie classificeert dit als SHOULD.
  • Valideer de header Origin en antwoord met 403 wanneer die niet in je lijst staat. Zonder dat kan een webpagina die open staat in je browser met je server praten.
  • Eis een token, vergeleken met hash_equals() om geen informatie te lekken via de vergelijkingstijd.
  • Blijf alleen-lezend zolang je niet hoeft te schrijven, en begrens alles: lengte van strings, aantal elementen, netwerktime-out, limiet van de backtracking.
  • Codeer de host hard in de tools die het netwerk aanroepen. Een vrije URL-parameter maakt van je server een doorgeefluik voor exfiltratie.

Dit laatste punt is niet theoretisch. De tot nu toe opgetekende publieke incidenten in het MCP-ecosysteem gaan bijna allemaal over servers die meer deden dan aangekondigd: een op npm gepubliceerd package dat en passant de e-mails kopieerde die het geacht werd te versturen, een relay waarvan een lek de uitvoering van commando’s opende. Een tool die niets anders kan doen dan wat zijn beschrijving zegt, is een tool die je kunt auditeren. De vraag naar de reikwijdte van een agent en zijn tools wordt uitgebreid behandeld in het artikel over sandboxen en permissies.

Wat je moet onthouden

  • Een bruikbare MCP-server past in een PHP-bestand van 380 regels: een route POST /mcp, JSON-RPC 2.0, en twee functies per tool.
  • De revisie 2026-07-28 schrapt initialize, de sessies en de GET-stream, maar Codex en de Inspector openen nog altijd met de oudere handshake: schrijf een server die beide tijdperken beheerst.
  • In moderne modus neem je MCP-Protocol-Version, Mcp-Method en Mcp-Name over en controleer je ze, met -32020 bij een afwijking en -32022 voor een onbekende versie.
  • Geef bedrijfsfouten terug in het resultaat met isError: true, niet als JSON-RPC-fout: dat is wat het model in staat stelt zichzelf te corrigeren.
  • Test met curl, en vervolgens met npx @modelcontextprotocol/inspector --cli, voordat je er een agent op aansluit.
  • php -S dient alleen voor ontwikkeling: een trage netwerkaanroep blokkeert de hele server zolang PHP_CLI_SERVER_WORKERS niet is ingesteld.
  • Vertrek voor productie vanuit de officiële SDK mcp/sdk of laravel/mcp, in plaats van je eigen transportlaag te onderhouden.

Veelgemaakte fouten

Een server schrijven die alleen de revisie 2026-07-28 spreekt De op 7 september 2026 geteste clients openen nog altijd met initialize. Houd de case initialize naast server/discover: de specificatie staat een server toe om beide tijdperken op hetzelfde eindpunt te bedienen.
Vergeten de spiegelheaders te valideren In 2026-07-28 moeten MCP-Protocol-Version, Mcp-Method en Mcp-Name overeenkomen met de body. Een afwijking wordt afgewezen met 400 en code -32020, anders kunnen een tussenpartij en de server twee verschillende dingen lezen.
Antwoorden op een notificatie Een bericht zonder id verwacht geen antwoord. Geef 202 Accepted terug zonder body, anders lopen oudere clients vast op notifications/initialized.
Een bedrijfsfout als JSON-RPC-fout melden Een ongeldig argument geef je terug in een resultaat met isError: true en een bruikbaar bericht. Een protocolfout doet het model opgeven, een uitvoeringsfout laat het zichzelf corrigeren.
De tool aanroepen vanuit codex exec zonder de goedkeuring te regelen Het beleid staat op never en Codex weigert met “MCP tool call requires approval”. Voeg -c 'mcp_servers.NOM.default_tools_approval_mode="approve"' toe, en alleen voor een server die je zelf hebt geschreven.

Claude CodeCodexLaravelMCPPHP

Damien Flandrin Webdeveloper sinds 2010, maker van Gekkode en Email Impact. Elk artikel wordt vóór publicatie getest op een echt project. Contact
Nieuwsbrief

Nieuwe tests, tutorials en projecten, per e-mail.

Reproduceerbare tests, geversioneerde code, gedateerde resultaten. Nooit spam.