
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, deGET-stream en deDELETEvoor het einde van de sessie zijn verdwenen, - een methode
server/discoververvangt de ontdekking, en servers moeten ze implementeren. - headers
Mcp-MethodenMcp-Namenemen velden uit de body over, zodat een load balancer kan routeren zonder de JSON te lezen. - lijstantwoorden dragen
ttlMsencacheScopemee 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.
cd docker/articles/2026-09-07/lab/creer-serveur-mcp-php
MCP_TOKEN=demo-token-local php -S 127.0.0.1:8765 mcp.phpWe beginnen met twee omhullende functies. Alle uitvoer loopt erdoorheen, wat garandeert dat geen enkel antwoord vertrekt zonder expliciete HTTP-code.
<?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.
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:
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"}}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.
$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.
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=0Waarom 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.
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.
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"}}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.
{
"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.
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:
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"}}En dat op initialize, zoals Codex het ontvangt:
{
"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.
[
'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.
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”.
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:
{
"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.
{
"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:
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
}
}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.
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:
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.
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 342De 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:
mcp: regexlab/regex_test started
mcp: regexlab/regex_test (failed)
MCP tool call requires approval, but approval policy is neverIn 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:
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 regexlabDe 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.
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 regexlabOm 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:
{
"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.
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
}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
Originen 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 deGET-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-MethodenMcp-Nameover en controleer je ze, met-32020bij een afwijking en-32022voor 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 -Sdient alleen voor ontwikkeling: een trage netwerkaanroep blokkeert de hele server zolangPHP_CLI_SERVER_WORKERSniet is ingesteld.- Vertrek voor productie vanuit de officiële SDK
mcp/sdkoflaravel/mcp, in plaats van je eigen transportlaag te onderhouden.
Veelgemaakte fouten
initialize. Houd de case initialize naast server/discover: de specificatie staat een server toe om beide tijdperken op hetzelfde eindpunt te bedienen.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.id verwacht geen antwoord. Geef 202 Accepted terug zonder body, anders lopen oudere clients vast op notifications/initialized.isError: true en een bruikbaar bericht. Een protocolfout doet het model opgeven, een uitvoeringsfout laat het zichzelf corrigeren.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.

