
Ein MCP-Server ist ein Dienst, der einem KI-Agenten Werkzeuge über JSON-RPC 2.0 bereitstellt, beschrieben durch ein Schema, das der Client zur Laufzeit mit tools/list liest. In PHP passt das in eine Datei mit 380 Zeilen: eine Route POST /mcp, ein Bearer-Token, und eine Funktion pro Werkzeug. Achtung, die Revision 2026-07-28 streicht den initialize-Handshake, aber Codex und MCP Inspector 2.5.0 verwenden ihn noch: Dein Server muss beide beherrschen.
Du hast eine interne API, einen Produktkatalog oder eine Logs-Datenbank, und du würdest gern, dass Claude Code oder Codex direkt darauf zugreift, statt dich um Copy-paste-Schnipsel zu bitten. Das ist die Aufgabe eines MCP-Servers, und er passt in eine einzige PHP-Datei: die aus diesem Tutorial hat 380 Zeilen, ohne Composer, ohne Framework und ohne andere Abhängigkeit als PHP 8.
Was ist eigentlich ein MCP-Server?
Das Model Context Protocol ist ein offenes Protokoll, das standardisiert, wie sich eine KI-Anwendung an externe Daten und Werkzeuge anbindet. Die Spezifikation unterscheidet drei Rollen: die Hosts, Anwendungen, die Verbindungen aufbauen. Die Clients, Konnektoren innerhalb des Hosts. Die Server, Dienste, die Kontext und Fähigkeiten liefern. Die Nachrichten sind JSON-RPC 2.0.
Ein Server kann drei Dinge anbieten: Ressourcen (Kontext und Daten), Prompts (Nachrichtenvorlagen) und Werkzeuge (Funktionen, die das Modell ausführt). Wir implementieren hier nur die Werkzeuge, weil das in den meisten Fällen gebraucht wird und weil es der Teil ist, der sich mit curl testen lässt.
MCP oder REST-API: Was ändert sich wirklich?
MCP ersetzt REST nicht, es legt eine Beschreibungsschicht darüber. Hier, was sich konkret unterscheidet.
| Punkt | Klassische REST-API | MCP-Server |
|---|---|---|
| Entdeckung | Eine Dokumentation zu lesen, eine OpenAPI-Datei zu laden | tools/list liefert die JSON-Schemas jedes Werkzeugs, zur Laufzeit |
| Aufrufer | Code, den du schreibst | Das Modell, das das Werkzeug anhand seiner Beschreibung wählt |
| Format | Was du willst | JSON-RPC 2.0, vorgeschrieben |
| Fehler | HTTP-Codes | Zwei Familien: Protokollfehler und Fehler bei der Werkzeugausführung |
| Integration | Ein Adapter pro Client | Ein Server, alle kompatiblen Agenten |
Der letzte Punkt ist der einzige, der wirklich zählt. Ein einmal geschriebener MCP-Server bindet sich an Claude Code, an Codex, an den Inspector und an die übrigen Clients des Ökosystems, ohne eine einzige Zeile Anpassung. Das ist dasselbe Versprechen wie bei den Skills, deren Format wir im Detail in unserem Leitfaden zur SKILL.md-Datei beschreiben.
Warum dein Server zwei Sprachen sprechen muss
Das ist die Falle dieses Tutorials, und die legt man besser gleich offen. Die Revision 2026-07-28 der Spezifikation, veröffentlicht am 28. Juli 2026 von David Soria Parra und Den Delimarsky, hat den Handshake gestrichen. Kein initialize mehr, keine notifications/initialized-Benachrichtigung mehr, kein Mcp-Session-Id-Header mehr. Jede Anfrage trägt jetzt ihre eigene Protokollversion und die Identität des Clients in einem Feld _meta, und der Server kann ohne geteilten Zustand repliziert werden.
Die übrigen Änderungen derselben Revision betreffen direkt den HTTP-Transport:
- der Endpunkt akzeptiert nur noch
POST, derGET-Stream und dasDELETEzum Sitzungsende sind verschwunden, - eine Methode
server/discoverersetzt die Entdeckung, und Server müssen sie implementieren. - die Header
Mcp-MethodundMcp-Namespiegeln Felder aus dem Body, damit ein Load Balancer routen kann, ohne das JSON zu lesen. - Listenantworten tragen
ttlMsundcacheScope, damit sie sich cachen lassen.
Auf dem Papier würde es also reichen, einen 2026-07-28-Server zu schreiben. Nur habe ich die Eröffnungsmethode und den User-Agent-Header jedes Clients protokolliert, der sich am 7. September 2026 mit meinem Server verbunden hat, und hier ist, was dabei herauskam.
| Client | User-Agent | Eröffnung | Angekündigte Version |
|---|---|---|---|
| Codex | codex-mcp-client/0.153.4 | initialize | 2025-06-18 |
| MCP Inspector 2.5.0 | node | initialize | 2025-11-25 |
Keiner von beiden hat ein server/discover geschickt, und keiner hat _meta in seine Anfragen gepackt. Ein Server, der nur die Juli-Revision spräche, wäre mit diesen Clients heute unbrauchbar. Die Spezifikation hat den Fall vorgesehen: Sie nennt eine Implementierung, die beides beherrscht, dual-era, und erlaubt einem Server ausdrücklich, beide Epochen über denselben Endpunkt zu bedienen. Genau das machen wir jetzt, und das kostet ungefähr zehn Zeilen.
Das Skelett: eine Datei, eine Route
Der Demo-Server heißt regexlab und stellt zwei Werkzeuge bereit: regex_test, das eine reguläre Expression an Beispielen testet, und blog_search, das die öffentliche REST-API von gekkode.com lesend abfragt. Gestartet wird er mit dem in PHP integrierten Webserver.
cd docker/articles/2026-09-07/lab/creer-serveur-mcp-php
MCP_TOKEN=demo-token-local php -S 127.0.0.1:8765 mcp.phpWir beginnen mit zwei Hüllfunktionen. Jede Ausgabe läuft durch sie, was garantiert, dass keine Antwort ohne expliziten HTTP-Code hinausgeht.
<?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]);
}Es folgt die Eingangstür: HTTP-Methode, Pfad, Origin, Token. Vier Prüfungen, und die Rückgabecodes, die die Spezifikation verlangt.
function header_value(string $name): ?string
{
$key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
return isset($_SERVER[$key]) ? trim((string) $_SERVER[$key]) : null;
}
// GET und DELETE sind nicht mehr Teil der Revision 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: Schutz gegen DNS-Rebinding, 403 von der Spezifikation verlangt.
$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');
}
}Die Validierung des Origin-Headers ist nicht dekorativ. Die Spezifikation stuft sie als MUST ein und verlangt einen 403, genau weil ohne sie eine in deinem Browser geöffnete Website per DNS-Rebinding mit dem auf deiner Maschine laufenden MCP-Server sprechen kann. Prüfen wir die drei Tore:
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"}}Die Anfrage lesen und die Epoche erkennen
Der Body ist JSON-RPC. Drei Informationen kommen daraus hervor: die Methode, die Parameter, der Identifikator. Ob io.modelcontextprotocol/protocolVersion in _meta vorhanden ist, genügt, um zu wissen, ob der Client modern ist.
$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'] ?? '-'
));
// Eine Benachrichtigung hat keinen Identifikator: Wir bestätigen den Empfang und hören auf.
if (!array_key_exists('id', $req)) {
send(202, null);
}Der obige Aufruf von error_log ist das, was mir erlaubt hat, die Client-Tabelle weiter oben zu erstellen. Der integrierte Server von PHP schreibt auf die Fehlerausgabe, das Log erscheint also im Terminal, das ihn gestartet hat. Behalte diese Aufrufe während der gesamten Entwicklung bei.
Die Behandlung von Benachrichtigungen verdient ein Wort. Eine JSON-RPC-Benachrichtigung ist eine Nachricht ohne id: Der Client erwartet keine Antwort. Die Spezifikation ist kategorisch, der Server muss mit 202 Accepted ohne Body antworten. Genau darüber läuft das notifications/initialized älterer Clients.
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=0Warum dieser Server nur JSON zurückgibt
Ein Wort zum Antwortformat, denn das ist eine Wahl und keine Pflicht. Auf eine Anfrage kann der Server entweder mit einem einzelnen Objekt in Content-Type: application/json antworten oder mit einem SSE-Stream in text/event-stream, spezifisch für diese Anfrage, der Benachrichtigungen vor der endgültigen Antwort trägt. Der Client wiederum muss beides lesen können: Das ist der Grund für den Header Accept: application/json, text/event-stream, den er systematisch mitschickt.
Dieser Server bleibt bei JSON. Das ist am einfachsten, und es reicht, solange kein Werkzeug über seinen eigenen Fortschritt berichten muss. Der Stream wird in zwei Fällen nötig: ein langlaufendes Werkzeug, das während seiner Arbeit notifications/progress senden will, und die Anfrage subscriptions/listen, deren Antwort offen bleibt, um Listenänderungen zu tragen. Wenn es so weit ist, macht die Revision 2026-07-28 das Schließen des Streams durch den Client zum Signal für den Abbruch der Anfrage macht und den Header X-Accel-Buffering: no empfiehlt, damit nginx die Ereignisse nicht puffert.
Die Spiegel-Header validieren
Das ist der Teil, der am spezifischsten für die Revision 2026-07-28 ist, und derjenige, den man vergisst. Wenn ein moderner Client eine Anfrage postet, muss er drei Werte aus dem Body in Header spiegeln: MCP-Protocol-Version, Mcp-Method, und Mcp-Name bei einem tools/call. Der Server muss prüfen, dass Header und Body übereinstimmen, und sonst mit 400 und dem Fehlercode -32020 ablehnen.
Der Grund ist eine klassische Schwachstelle: Wenn ein Load Balancer das Routing anhand des Headers entscheidet, während der Server nach dem Body ausführt, kann ein böswilliger Client einen Aufruf als einen anderen ausgeben. Die Spezifikation nennt diesen Fehler 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,
]);
}
}Ein Test, der bei Mcp-Name lügt: Der Header kündigt blog_search an, der Body verlangt 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"}}Und eine Version, die der Server nicht kennt. Die Spezifikation verlangt eine Antwort mit -32022 und einer Liste der akzeptierten Versionen, damit der Client es ohne menschliches Zutun erneut versuchen kann.
{
"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"
}
}
}Auf die Entdeckung antworten, von beiden Seiten
Das macht den Server zu einem Zwei-Epochen-Server: ein case für initialize, eines für server/discover, und beide liefern dieselben Fähigkeiten in zwei unterschiedlichen Verpackungen zurück. Ersteres sind die rund zehn Zeilen, die es kostet, kompatibel zu bleiben.
switch ($method) {
// Alter Handshake (Revisionen 2025-11-25 und früher).
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.',
]);
// Zustandslose Entdeckung (Revision 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);
}Zwei Details, die man nicht verpassen sollte. serverInfo wandert: Im alten Modus steht es an der Wurzel des Ergebnisses, in 2026-07-28 unter _meta['io.modelcontextprotocol/serverInfo']. Und eine unbekannte Methode wird nicht gleich behandelt: Im modernen Modus verlangt die Spezifikation einen 404 Not Found zusammen mit einem -32601, weil der JSON-RPC-Body diesen Fall vom 404 eines alten Servers unterscheidet, der den Endpunkt gar nicht hostet.
Hier die tatsächliche Antwort auf 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"}}Und die auf initialize, so wie Codex sie erhält:
{
"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."
}
}Die Werkzeuge beschreiben: tools/list
Ein Werkzeug besteht aus einem Namen, einer Beschreibung und einem JSON-Eingabeschema. Die Beschreibung ist das, was das Modell liest, um zu entscheiden, ob es das Werkzeug aufruft: Schreib sie für das Modell, nicht für eine Entwicklerin. outputSchema ist optional, aber empfohlen, es erlaubt dem Client, zu validieren, was du zurückgibst.
[
'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,
],
]Die Antwort auf tools/list fügt resultType hinzu, sowie die beiden Cache-Felder, die die Juli-Revision eingeführt hat.
case 'tools/list':
ok($id, [
'resultType' => 'complete',
'tools' => tool_definitions(),
'ttlMs' => 300000,
'cacheScope' => 'public',
]);Drei Benennungsregeln sind einzuhalten: Der Name eines Werkzeugs muss zwischen 1 und 128 Zeichen liegen, sich auf ASCII-Buchstaben, Ziffern, _, - und . beschränken und innerhalb des Servers eindeutig sein. Leerzeichen und Kommas sind verboten.
Ein Werkzeug ausführen: tools/call und seine zwei Fehlerfamilien
Die Qualität eines MCP-Servers entscheidet sich hier, und viele Tutorials gehen daran vorbei. Die Spezifikation unterscheidet zwei Mechanismen der Fehlermeldung, und sie zu verwechseln kostet das Modell zusätzliche Runden:
- ein Protokollfehler (unbekanntes Werkzeug, fehlerhafte Anfrage) ist ein klassischer JSON-RPC-Fehler, das Modell hat wenig Chancen, allein damit klarzukommen,
- ein Ausführungsfehler (Argument außerhalb des Bereichs, API ausgefallen, ungültiges Datum) wird in einem normalen Ergebnis mit
isError: truezurückgegeben, und der Client muss ihn an das Modell weiterreichen, damit es sich korrigiert.
Der Regex-Tester ist ein Paradebeispiel: Ein schlecht geschriebenes Muster muss mit der PCRE-Meldung zum Modell zurückkommen, nicht mit einem „Fehler 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.');
}
// Wir escapen ungeschützte Begrenzer, statt ein bereits abgegrenztes Muster zu akzeptieren.
$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) {
// Die PCRE-Meldung sagt, wo das Muster bricht: Das Modell kann sich selbst korrigieren.
return tool_error('Motif invalide : ' . ($compileError ?? preg_last_error_msg()));
}
// …
}Zwei Vorsichtsmaßnahmen in diesen wenigen Zeilen. Wir akzeptieren nie ein bereits abgegrenztes Muster, wir fügen die Begrenzer selbst hinzu und escapen dabei ungeschützte /. /motif/flags so zu akzeptieren, wie es ist, käme darauf hinaus, den Aufrufer die Modifikatoren wählen zu lassen, was die vorgelagerte Validierung verbietet. Und der temporäre Fehlerhandler fängt die exakte PCRE-Meldung ab, wo preg_last_error_msg() sich mit einem lakonischen „Internal error“ begnügt.
Der Unterschied zeigt sich beim Aufruf. Ein Muster mit einer fehlenden Klammer:
{
"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
}
}Ein Modell, das diesen Text erhält, schließt die Klammer und ruft das Werkzeug erneut auf. Hätte es stattdessen einen JSON-RPC-Fehler erhalten, wären seine Chancen deutlich schlechter gewesen: Die Spezifikation merkt an, dass Protokollfehler selten zu einer Korrektur führen.
Es bleibt ein Risiko, das speziell für reguläre Ausdrücke gilt: die kombinatorische Explosion. Ein Muster wie (a+)+$ auf einer Kette aus sechsunddreißig „a“, gefolgt von einem „b“, hält den Prozessor sehr lange beschäftigt. Die Abhilfe passt in eine Zeile am Dateianfang, ini_set('pcre.backtrack_limit', '200000');, plus eine Prüfung des Rückgabewerts von preg_match.
{
"jsonrpc": "2.0",
"id": 9,
"result": {
"resultType": "complete",
"content": [{"type": "text", "text": "Échec du moteur PCRE : Backtrack limit exhausted"}],
"isError": true
}
}Nun ein erfolgreicher Aufruf, mit seinem structuredContent passend zum deklarierten 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
}
}Beachte, dass der lesbare Text zusätzlich zum strukturierten Inhalt vorhanden ist. Die Spezifikation empfiehlt das ausdrücklich: Ein Werkzeug, das Strukturiertes zurückgibt, sollte auch die serialisierte Version in einem Textblock liefern, für Clients, die nur content lesen.
Das zweite Werkzeug: eine API lesend abfragen
blog_search zeigt den häufigsten Fall im Unternehmen: einen bestehenden Dienst bereitzustellen. Der Host ist fest einprogrammiert, die Methode ist ein GET, und kein Parameter des Aufrufers baut eine beliebige URL zusammen.
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.');
}
// …
}Der _fields-Parameter der REST-API von WordPress leistet viel Arbeit: Er reduziert die Antwort auf die vier nützlichen Felder und damit die Tokens, die beim Einzug in den Kontext des Modells berechnet werden. Das ist derselbe Reflex wie in unserem Artikel zur Reduzierung von Tokens beschrieben, nur angewendet auf den Server statt auf den Client. Ergebnis des Aufrufs:
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/Den Server an Codex anbinden
Codex liest seine MCP-Server aus ~/.codex/config.toml, aber mit der Option -c lassen sie sich für einen einzelnen Lauf deklarieren, ohne irgendetwas auf die Platte zu schreiben. Ideal für einen Test, und auf diesem Weg wurde der Server hier geprüft.
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 342Das dritte -c ist das, das mich zwei Versuche gekostet hat. Ohne es verbindet sich Codex mit dem Server, listet die Werkzeuge auf und lehnt den Aufruf mit einer unmissverständlichen Meldung ab:
mcp: regexlab/regex_test started
mcp: regexlab/regex_test (failed)
MCP tool call requires approval, but approval policy is neverIm Modus exec steht die Freigabe-Richtlinie auf never, und niemand ist da, um zuzustimmen. Der Schlüssel default_tools_approval_mode akzeptiert vier Werte, auto, prompt, writes und approve, nur der letzte lässt den Aufruf ohne Eingriff durch. Reserviere ihn für Server, die du selbst geschrieben hast, aus den Gründen, die in unserem Artikel über Sandboxes und Berechtigungen im Detail stehen.
Für eine dauerhafte Installation schreibt der offizielle Befehl dasselbe in die Konfiguration:
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 regexlabDen Server an Claude Code anbinden
Bei Claude Code passt das Hinzufügen eines HTTP-Servers in einen Befehl, und der Autorisierungs-Header wird mit --header übergeben.
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 regexlabUm den Server mit dem Team zu teilen, schreibt der Geltungsbereich project eine versionierbare Datei .mcp.json im Wurzelverzeichnis des Repositorys. Die Dokumentation unterstützt die Erweiterung von Umgebungsvariablen, was verhindert, dass das Token committet wird:
{
"mcpServers": {
"regexlab": {
"type": "http",
"url": "http://127.0.0.1:8765/mcp",
"headers": {
"Authorization": "Bearer ${REGEXLAB_TOKEN}"
}
}
}
}Die Syntax ${VAR} und ${VAR:-Standardwert} wird in den Feldern url, headers, command, args und env erkannt. Aus der Sitzung heraus zeigt /mcp den Status der Server und erlaubt es, sich mit denen zu verbinden, die OAuth verlangen.
Mit MCP Inspector prüfen, ohne irgendetwas zu installieren
Das offizielle Testwerkzeug lässt sich grafisch nutzen, aber sein Modus --cli ist für einen schnellen Test viel praktischer und startet über npx, also ohne globale Installation.
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
}Der Inspector 2.5.0, veröffentlicht am 2. September 2026, öffnet ebenfalls mit einem initialize und kündigt Version 2025-11-25 an. Er bleibt das schnellste Werkzeug, um zu sehen, was dein Server wirklich zurückgibt, bevor du einen Agenten daran anschließt.
Was der integrierte PHP-Server nicht kann
php -S verarbeitet jeweils eine Anfrage. Solange deine Werkzeuge in wenigen Millisekunden antworten, fällt das nicht auf. Sobald ein Werkzeug das Netzwerk aufruft, blockiert der gesamte Server. Ich habe zwei Aufrufe parallel gestartet, blog_search, das die Blog-API abfragt, dann regex_test, das nur lokal rechnet:
| Konfiguration | blog_search | regex_test, 50 ms später gestartet |
|---|---|---|
php -S standardmäßig | 0,601 s | 0,548 s |
PHP_CLI_SERVER_WORKERS=4 | 0,578 s | 0,002 s |
Ohne zusätzliche Prozesse wartet der schnelle Aufruf brav auf das Ende des langsamen: 0,548 s statt der 13 ms, die er allein braucht. Mit vier Arbeitern antwortet er sofort. Das reicht für die Entwicklung, es ersetzt nicht PHP-FPM hinter nginx in Produktion.
In Produktion gehen: das offizielle PHP-SDK und Laravel MCP
Den eigenen Server von Hand zu schreiben ist der richtige Weg, das Protokoll zu verstehen. Für Code, der weiterlebt, lohnen sich zwei Pakete, und beide haben sich diesen Sommer bewegt.
Das offizielle PHP-SDK installiert sich per composer require mcp/sdk. Es versteht sich als das offizielle SDK des Protokolls für PHP, gepflegt in Zusammenarbeit mit der PHP Foundation und angelehnt an die Praktiken des Symfony-Projekts. Es ist framework-agnostisch, verlangt mindestens PHP 8.1, und sein Repository kündigt die Unterstützung beider Protokoll-Epochen an, den initialize-Handshake und die zustandslose Revision 2026-07-28. Neueste Version am 7. September 2026: v0.8.1, veröffentlicht am 29. August.
Laravel MCP (composer require laravel/mcp) zielt auf das andere Ende des Spektrums: Du deklarierst deine Server in routes/ai.php, mit Mcp::web('/mcp/weather', WeatherServer::class) für einen HTTP-Server oder Mcp::local('weather', …) für einen Artisan-Befehl, und jedes Werkzeug wird zu einer Klasse, die Tool erweitert, mit einer Methode handle() und einem schema(). Die Laravel-Middleware greift unverändert, throttle eingeschlossen. Die erste stabile Version ist in Vorbereitung: v1.0.0-beta.1 datiert vom 14. August 2026 und verlangt PHP 8.2 mit Laravel 11.45.3, 12.41.1 oder 13.
Wenn dein Bedarf WordPress oder PrestaShop ist statt eines generischen Servers, behandeln zwei Artikel dieser Reihe genau das: MCP für WordPress und MCP für PrestaShop.
Fünf Sicherheitszeilen, die nichts kosten
Ein MCP-Server ist eine Angriffsfläche für Codeausführung, die einem Modell zugänglich ist. Die Spezifikation widmet dem Thema eine ganze Seite, merke dir mindestens diese Regeln, die alle von der Datei dieses Tutorials eingehalten werden.
- Lausche auf 127.0.0.1, nie auf 0.0.0.0, bei einem lokalen Server. Die Spezifikation stuft das als SHOULD ein.
- Validiere den Header
Originund antworte mit 403, wenn er nicht auf deiner Liste steht. Ohne das kann eine in deinem Browser geöffnete Webseite mit deinem Server sprechen. - Verlange ein Token, verglichen mit
hash_equals(), um keine Information über die Vergleichszeit preiszugeben. - Bleib schreibgeschützt, solange du nicht schreiben musst, und begrenze alles: Länge der Zeichenketten, Anzahl der Elemente, Netzwerk-Timeout, Backtracking-Limit.
- Programmiere den Host fest ein in Werkzeugen, die das Netzwerk aufrufen. Ein frei wählbarer URL-Parameter macht deinen Server zu einem Relais für Exfiltration.
Dieser letzte Punkt ist nicht theoretisch. Die bislang erfassten öffentlichen Vorfälle im MCP-Ökosystem betreffen fast alle Server, die mehr taten, als angekündigt: ein auf npm veröffentlichtes Paket, das nebenbei die E-Mails kopierte, die es eigentlich versenden sollte, ein Relais, dessen Schwachstelle die Ausführung von Befehlen öffnete. Ein Werkzeug, das nichts anderes tun kann, als das, was seine Beschreibung sagt, ist ein Werkzeug, das sich auditieren lässt. Die Frage nach dem Geltungsbereich eines Agenten und seiner Werkzeuge wird im Detail im Artikel über Sandboxes und Berechtigungen behandelt.
Was du dir merken solltest
- Ein nützlicher MCP-Server passt in eine PHP-Datei mit 380 Zeilen: eine Route
POST /mcp, JSON-RPC 2.0, und zwei Funktionen pro Werkzeug. - Die Revision 2026-07-28 streicht
initialize, die Sitzungen und denGET-Stream, aber Codex und der Inspector öffnen noch immer mit dem alten Handshake: Schreibe einen Server, der beide Epochen beherrscht. - Im modernen Modus spiegle und prüfe
MCP-Protocol-Version,Mcp-MethodundMcp-Name, mit-32020bei einer Abweichung und-32022für eine unbekannte Version. - Gib Fachfehler im Ergebnis mit
isError: truezurück, nicht als JSON-RPC-Fehler: Das ist es, was dem Modell erlaubt, sich selbst zu korrigieren. - Teste mit curl, dann mit
npx @modelcontextprotocol/inspector --cli, bevor du einen Agenten anschließt. php -Sdient nur der Entwicklung: Ein langsamer Netzwerkaufruf blockiert den gesamten Server, solangePHP_CLI_SERVER_WORKERSnicht gesetzt ist.- Für die Produktion geh vom offiziellen SDK
mcp/sdkoder vonlaravel/mcpaus, statt eine eigene Transportschicht zu pflegen.
Häufige Fehler
initialize. Behalte den Fall initialize neben server/discover: Die Spezifikation erlaubt einem Server, beide Epochen über denselben Endpunkt zu bedienen.MCP-Protocol-Version, Mcp-Method und Mcp-Name mit dem Body übereinstimmen. Eine Abweichung wird mit 400 und dem Code -32020 abgelehnt, sonst können ein Zwischenglied und der Server zwei unterschiedliche Dinge lesen.id erwartet keine Antwort. Gib 202 Accepted ohne Body zurück, sonst bleiben ältere Clients bei notifications/initialized hängen.isError: true und einer verwertbaren Meldung zurückgegeben. Ein Protokollfehler lässt das Modell aufgeben, ein Ausführungsfehler lässt es sich korrigieren.codex exec aufrufen, ohne die Freigabe zu regeln Die Richtlinie steht auf never, und Codex lehnt ab mit „MCP tool call requires approval“. Füge -c 'mcp_servers.NOM.default_tools_approval_mode="approve"' hinzu, und das nur für einen Server, den du selbst geschrieben hast.

