Stand: 4. November 2026. Versionen: PHP 8.4, offizielles MCP PHP SDK 0.9.0 (mcp/sdk, 18. Oktober 2026), MCP-Spezifikation 2026-07-28. Das SDK ist vom Projekt als experimentell und "pre-1.0" gekennzeichnet; die Beispiele sind gegen die verlinkte Dokumentation geprüft, Signaturen können sich bis zur 1.0 ändern.
Kurz gesagt
Das Model Context Protocol (MCP) ist der Standard, über den KI-Modelle Werkzeuge aufrufen: Claude Code, Claude in Slack, die Messages API und die Clients anderer Anbieter sprechen ihn. Ein MCP-Server vor Ihrem ERP, CRM oder Ticketsystem macht aus "Das Modell weiß nichts über unsere Daten" ein "Das Modell darf genau diese sieben Dinge abfragen und zwei Dinge ändern, protokolliert und pro Nutzer berechtigt".
In PHP geht das seit Oktober 2026 mit dem offiziellen SDK der PHP Foundation: Eine Klasse mit Attributen, ein Handler in Ihrer Mezzio-, Symfony- oder Laravel-Anwendung, fertig ist der Server. Die Arbeit steckt woanders: in Tool-Beschreibungen, die ein Modell versteht, in Berechtigungen, die nicht im Prompt stehen, und in der Entscheidung, was das Modell nie dürfen soll.
Warum ein MCP-Server und keine REST-API
Ihre Anwendung hat vermutlich schon eine API. Der Unterschied liegt beim Aufrufer. Eine REST-API wird von Entwicklern gelesen, die Dokumentation und Fehlermeldungen interpretieren. Ein MCP-Server wird von einem Sprachmodell gelesen, das zur Laufzeit entscheidet, welches Werkzeug es mit welchen Parametern aufruft. Dafür liefert MCP drei Dinge, die REST nicht standardisiert: eine maschinenlesbare Liste der Werkzeuge mit Beschreibung und JSON-Schema, ein einheitliches Aufrufformat und Hinweise (Annotations), ob ein Werkzeug nur liest, Daten ändert oder löscht.
Die Alternative, dem Modell direkt eine OpenAPI-Spezifikation mit 140 Endpunkten zu geben, scheitert in der Praxis an zwei Stellen: Das Modell wählt aus zu vielen Optionen, und Ihre API ist für Menschen mit Kontext geschrieben, nicht für einen Aufrufer, der nur die Beschreibung hat. Ein MCP-Server ist eine bewusst kleine, kuratierte Schnittstelle: fünf bis fünfzehn Werkzeuge, jedes mit einem Satz, der sagt, wann es zu benutzen ist.
Was sich mit der Spezifikation 2026-07-28 geändert hat
Wer MCP-Server aus 2025 kennt, sollte vier Änderungen kennen, bevor er in PHP anfängt. Sie machen das Protokoll für PHP deutlich einfacher, weil es jetzt so funktioniert, wie PHP ohnehin arbeitet: eine Anfrage, eine Antwort, kein Zustand.
- Keine Sessions mehr. Der Header Mcp-Session-Id und der initialize-Handshake sind weg. Jede Anfrage trägt Protokollversion und Client-Fähigkeiten in einem _meta-Feld. Ein PHP-FPM-Worker kann jede Anfrage unabhängig beantworten, ohne Session-Speicher.
- server/discover ist Pflicht: Der Server nennt darüber unterstützte Versionen, Fähigkeiten und Identität. Das SDK beantwortet das für Sie.
- Werkzeuglisten sollen deterministisch sortiert sein, und Listenantworten tragen ttlMs und cacheScope. Grund: Clients und Modelle cachen die Werkzeugliste, und eine stabile Reihenfolge erhöht die Trefferquote im Prompt Cache des Modells. Das spart bei jedem Aufruf Geld.
- Roots, Sampling und Logging sind veraltet. Ein Server, der etwas vom Client braucht, nutzt das neue Multi-Round-Trip-Muster (Ergebnis mit resultType "input_required"), nicht mehr serverseitig ausgelöste Anfragen. Für eine Fachanwendung heißt das: Rückfragen an den Nutzer ("Welcher der drei Kunden?") sind möglich, aber als Rückgabewert, nicht als Gegenanfrage.
Bei der Autorisierung ersetzt die Spezifikation die dynamische Client-Registrierung durch Client ID Metadata Documents. Für einen internen Server, den Sie selbst betreiben und dessen Clients Sie kennen, ist das zunächst nicht relevant; dort reichen Bearer-Tokens, die Sie ausgeben. Das SDK 0.9.0 unterstützt beide Protokollversionen (2026-07-28 und 2025-11-25) parallel, ältere Clients bleiben also angebunden.
Der Server in fünf Schritten
01Erst die Regeln, dann der Code
Bevor Sie eine Zeile schreiben, legen Sie fünf Dinge schriftlich fest. Sie bestimmen das Design stärker als jede Bibliothek.
- Nur lesen, bis das Gegenteil bewiesen ist. Die erste Version hat keine schreibenden Werkzeuge. Erst wenn das Lesen zwei Wochen fehlerfrei läuft, kommt das erste schreibende Werkzeug dazu, und zwar das harmloseste (eine Notiz anlegen, nicht einen Auftrag stornieren).
- Berechtigung hängt am Token, nicht am Prompt. Jedes Token gehört zu einem Nutzer oder System und hat Scopes. Der Server prüft bei jedem Aufruf, nicht das Modell.
- Kein generisches Werkzeug. Kein "run_sql", kein "http_request". Jedes Werkzeug tut genau eine fachliche Sache mit validierten Parametern.
- Jeder Aufruf wird protokolliert: wer, welches Werkzeug, welche Parameter, wie viele Ergebnisse. Das Log ist Ihre Antwort auf die Frage "Was hat die KI gestern gemacht?".
- Ergebnisse sind begrenzt. Maximal 50 Treffer, maximal einige Kilobyte pro Antwort. Ein Werkzeug, das 20.000 Zeilen zurückgibt, sprengt das Kontextfenster und kostet bei jedem Aufruf.
02Installation
composer require mcp/sdk symfony/finder
symfony/finder braucht das SDK für die Attribut-Erkennung. Eine PSR-7-Implementierung haben Sie in Mezzio (laminas-diactoros), Symfony (über die PSR-Bridge) oder Laravel bereits; das SDK findet die PSR-17-Fabriken von nyholm/psr7, guzzlehttp/psr7, slim/psr7 und laminas-diactoros automatisch.
03Werkzeuge als Klasse mit Attributen
Ein Werkzeug ist eine öffentliche Methode mit dem Attribut McpTool. Das SDK erzeugt das JSON-Schema aus den Typen der Parameter und die Beschreibungen aus dem Docblock: Der Kommentar über der Methode wird zur Werkzeugbeschreibung, jede @param-Zeile zur Beschreibung des Parameters. Diese Texte liest das Modell. Schreiben Sie sie so, dass klar wird, wann das Werkzeug zu benutzen ist und wann nicht.
namespace App\Mcp;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\ToolAnnotations;
enum OrderStatus: string
{
case Open = 'open';
case Shipped = 'shipped';
case Invoiced = 'invoiced';
case Cancelled = 'cancelled';
}
final readonly class OrderTools
{
public function __construct(
private OrderRepository $orders,
private CurrentPrincipal $principal,
private McpAuditLog $audit,
) {}
/**
* Sucht Aufträge eines Kunden anhand der Kundennummer. Liefert neueste zuerst,
* höchstens 50. Für Details zu einem einzelnen Auftrag get_order verwenden.
*
* @param string $customerNumber Kundennummer im Format K-12345
* @param OrderStatus|null $status Optionaler Filter auf den Auftragsstatus
* @param int $limit Anzahl Treffer, 1 bis 50
* @return list<array{order_number: string, status: string, total_net: string, created_at: string}>
*/
#[McpTool(
name: 'find_orders',
annotations: new ToolAnnotations(readOnlyHint: true, idempotentHint: true),
)]
public function findOrders(
#[Schema(pattern: '^K-\d{5}$')]
string $customerNumber,
?OrderStatus $status = null,
#[Schema(minimum: 1, maximum: 50)]
int $limit = 20,
): array {
$this->principal->requireScope('orders:read');
$this->principal->requireCustomerAccess($customerNumber);
$rows = $this->orders->search($customerNumber, $status, $limit);
$this->audit->record($this->principal, 'find_orders', ['customer' => $customerNumber, 'status' => $status?->value], count($rows));
return array_map(
static fn(Order $o): array => [
'order_number' => $o->number,
'status' => $o->status->value,
'total_net' => $o->totalNet->format(),
'created_at' => $o->createdAt->format(DATE_ATOM),
],
$rows,
);
}
/**
* Legt eine interne Notiz an einem Auftrag an. Ändert keine Auftragsdaten.
* Nur verwenden, wenn der Nutzer ausdrücklich eine Notiz wünscht.
*
* @param string $orderNumber Auftragsnummer im Format A-2026-00001
* @param string $text Notiztext, höchstens 2000 Zeichen
*/
#[McpTool(
name: 'add_order_note',
annotations: new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: false),
)]
public function addOrderNote(
#[Schema(pattern: '^A-\d{4}-\d{5}$')]
string $orderNumber,
string $text,
): string {
$this->principal->requireScope('orders:write');
if (mb_strlen($text) > 2000) {
throw new ToolCallException('Notiz ist länger als 2000 Zeichen.');
}
$order = $this->orders->findByNumber($orderNumber)
?? throw new ToolCallException(sprintf('Auftrag %s nicht gefunden.', $orderNumber));
$this->principal->requireCustomerAccess($order->customerNumber);
$noteId = $this->orders->addNote($order, $text, author: $this->principal->name());
$this->audit->record($this->principal, 'add_order_note', ['order' => $orderNumber, 'note_id' => $noteId], 1);
return sprintf('Notiz %d an Auftrag %s angelegt.', $noteId, $orderNumber);
}
}
Vier Details, die in der Praxis den Unterschied machen:
- Enum statt String. OrderStatus wird im Schema zu einer Aufzählung der vier Werte. Das Modell kann keinen fünften erfinden, und Sie bekommen den Enum-Fall statt eines Strings.
- Schema-Attribute für Formate und Grenzen. Die Kundennummer hat ein Muster, das Limit eine Ober- und Untergrenze. Ungültige Aufrufe scheitern, bevor Ihr Code läuft.
- ToolCallException für fachliche Fehler. Das SDK macht daraus ein Ergebnis mit isError, das Modell liest den Text und kann korrigieren ("Auftrag nicht gefunden" führt meist zu einer Nachfrage beim Nutzer). Jede andere Exception wird zu einem generischen JSON-RPC-Fehler ohne Details, das ist gewollt: Stacktraces gehören nicht in Modellantworten.
- Annotations sind Hinweise, keine Sicherheit. readOnlyHint sagt dem Client, dass er ohne Rückfrage aufrufen darf. Die Berechtigung prüft trotzdem Ihr Code in jeder Methode.
04Server bauen und an den HTTP-Stack hängen
Der Server wird einmal konfiguriert und pro Anfrage mit einem Transport ausgeführt. Im Beispiel eine Mezzio-Anwendung; in Symfony und Laravel sieht der Handler bis auf die Umwandlung in PSR-7 gleich aus. Der Container ist wichtig: Über ihn bekommt OrderTools seine Abhängigkeiten, und darüber kommt auch der aktuelle Nutzer in das Werkzeug.
use Mcp\Server;
use Psr\Container\ContainerInterface;
final class McpServerFactory
{
public function __invoke(ContainerInterface $container): Server
{
return Server::builder()
->setServerInfo('erp-orders', '1.0.0')
->setInstructions('Werkzeuge für Auftragsdaten. Kundennummern haben das Format K-12345, Auftragsnummern A-2026-00001.')
->setContainer($container)
->setDiscovery(__DIR__ . '/../Mcp', ['.'])
->setLogger($container->get(LoggerInterface::class))
->build();
}
}
use Mcp\Server;
use Mcp\Server\Transport\StreamableHttpTransport;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
final readonly class McpHandler implements RequestHandlerInterface
{
public function __construct(private Server $server) {}
public function handle(ServerRequestInterface $request): ResponseInterface
{
return $this->server->run(new StreamableHttpTransport($request));
}
}
Die Route bekommt zwei Middlewares davor: eine für die Authentifizierung, eine für die Begrenzung der Aufrufe pro Token. Die Authentifizierung prüft das Bearer-Token gegen Ihre Tabelle und legt den Nutzer in dem Objekt ab, das OrderTools per Konstruktor bekommt.
final readonly class McpAuthMiddleware implements MiddlewareInterface
{
public function __construct(
private McpTokenRepository $tokens,
private CurrentPrincipal $principal,
private ResponseFactoryInterface $responses,
) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$header = $request->getHeaderLine('Authorization');
if (!str_starts_with($header, 'Bearer ')) {
return $this->responses->createResponse(401)
->withHeader('WWW-Authenticate', 'Bearer realm="mcp"');
}
$token = $this->tokens->findActive(hash('sha256', substr($header, 7)));
if ($token === null) {
return $this->responses->createResponse(401);
}
$this->principal->set($token->userId, $token->scopes, $token->customerNumbers);
return $handler->handle($request);
}
}
Tokens werden nur als Hash gespeichert, haben ein Ablaufdatum und sind pro Nutzer auf Kundennummern und Scopes beschränkt. Ein Vertriebsmitarbeiter bekommt orders:read für seine Kunden, ein Automatisierungssystem bekommt orders:read für alle und nichts weiter. Wenn Sie später externe Clients über OAuth anbinden wollen, sitzt der Austausch an genau dieser Stelle: Statt Ihrer Token-Tabelle validieren Sie dann ein Access-Token Ihres Authorization-Servers, der Rest bleibt.
MCP-Server für Ihr ERP, CRM oder Ticketsystem
Wir bauen die Schnittstelle in Ihre bestehende PHP-Anwendung ein: Werkzeugkatalog mit der Fachabteilung, Berechtigungen, Audit-Log, Anbindung an Claude Code oder Ihre eigene Agenten-Anwendung. Erfahrung mit Mezzio, Symfony und Laravel.
KI-Integration ansehen Erstgespräch buchen05Anbinden: Claude Code, Messages API, eigene Agenten
Claude Code als erster Client ist der schnellste Test. Ein Entwickler im Team bindet den Server mit einem Befehl ein und fragt in natürlicher Sprache nach Aufträgen:
claude mcp add --transport http erp-orders https://erp.example.de/mcp \
--header "Authorization: Bearer ${ERP_MCP_TOKEN}"
Für das ganze Team legen Sie die Konfiguration als .mcp.json ins Repository, mit dem Token als Umgebungsvariable:
{
"mcpServers": {
"erp-orders": {
"type": "http",
"url": "https://erp.example.de/mcp",
"headers": { "Authorization": "Bearer ${ERP_MCP_TOKEN}" }
}
}
}
Aus Ihrer eigenen Anwendung binden Sie den Server über den MCP-Connector der Messages API an. Anthropic ruft Ihren Server dann direkt auf; Sie schreiben keine Tool-Schleife. Der Server muss dafür über HTTPS öffentlich erreichbar sein, und Sie können pro Werkzeug festlegen, ob das Modell es benutzen darf. Hier erlauben wir nur das Lesen:
use Anthropic\Client;
$client = new Client();
$message = $client->beta->messages->create(
model: 'claude-opus-5-5',
maxTokens: 2048,
messages: [
['role' => 'user', 'content' => 'Welche offenen Aufträge hat Kunde K-10234, und welcher ist der älteste?'],
],
mcpServers: [[
'type' => 'url',
'url' => 'https://erp.example.de/mcp',
'name' => 'erp-orders',
'authorization_token' => $tokenForThisUser,
]],
tools: [[
'type' => 'mcp_toolset',
'mcp_server_name' => 'erp-orders',
'configs' => [
'add_order_note' => ['enabled' => false],
],
]],
betas: ['mcp-client-2025-11-20'],
);
Das Token in diesem Aufruf ist das des angemeldeten Nutzers, nicht ein Systemtoken. So gelten im MCP-Server dieselben Berechtigungen, die der Nutzer in der Anwendung hat, und das Audit-Log zeigt seinen Namen. Der MCP-Connector ist in der API noch als Beta gekennzeichnet, daher der Beta-Header.
Zum Testen ohne Modell eignet sich der MCP Inspector des Protokollprojekts (npx @modelcontextprotocol/inspector), der Werkzeugliste und Aufrufe in einer Oberfläche zeigt. Für die Pipeline schreiben Sie PHPUnit-Tests gegen OrderTools direkt: Die Klasse ist gewöhnlicher PHP-Code, das Protokoll kommt erst im Handler dazu.
Was Sie in der zweiten Version ergänzen
- Ressourcen für Stammdaten, die sich selten ändern (Preisliste, Produktkatalog). MCP unterscheidet Werkzeuge (das Modell ruft sie auf) von Ressourcen (der Client lädt sie als Kontext). Das SDK stellt dafür das Attribut McpResource mit einer URI bereit.
- Strukturierte Ausgabe über outputSchema am Werkzeug, wenn ein Client die Antwort nicht nur lesen, sondern weiterverarbeiten soll. Das SDK validiert das Ergebnis dann gegen das Schema.
- Rückfragen nach dem Multi-Round-Trip-Muster, wenn ein Aufruf mehrdeutig ist. Beispiel: "Kunde Müller" trifft auf drei Kundennummern, das Werkzeug gibt die drei zur Auswahl zurück statt zu raten.
- Ein zweiter Server für das nächste System. Ein Server pro Fachsystem mit eigener Token-Tabelle ist leichter zu betreiben als ein Server, der alles kann.
Fehler, die wir vermeiden würden
- Die API 1:1 spiegeln. 40 Werkzeuge, die den 40 Endpunkten entsprechen, führen zu schlechten Entscheidungen des Modells und hohem Tokenverbrauch pro Anfrage, weil die ganze Liste im Kontext steht.
- Beschreibungen aus Sicht der Datenbank. "Gibt Datensätze aus tbl_auftrag zurück" hilft dem Modell nicht. "Sucht Aufträge eines Kunden, neueste zuerst; für Details get_order" schon.
- Ein Systemtoken für alle. Dann steht im Audit-Log bei jedem Aufruf "mcp-service", und die Berechtigungsprüfung im Werkzeug ist wirkungslos.
- Fehlertexte mit Interna. SQL-Fehler, Pfade und Stacktraces landen in der Modellantwort und damit beim Nutzer. ToolCallException mit fachlichem Text, alles andere generisch.
- Lange Antworten. Ein Werkzeug, das ein 3 MB großes PDF als Text zurückgibt, kostet bei jedem Aufruf und verdrängt den Rest des Gesprächs aus dem Kontext. Zusammenfassen, paginieren, begrenzen.
- Schreibende Werkzeuge ohne Bestätigung. Alles, was Geld bewegt oder Daten löscht, bleibt entweder außerhalb des Servers oder verlangt eine zweite, explizite Bestätigung durch den Nutzer in der Oberfläche des Clients.
Ihr Team soll MCP selbst beherrschen?
Wir setzen den ersten Server gemeinsam mit Ihren Entwicklern um, von der Werkzeugliste mit der Fachabteilung bis zur Anbindung in Claude Code, und übergeben Code, Tests und die Entscheidungen dahinter.
KI für Entwicklerteams Kostenloses ErstgesprächHäufige Fragen
Fazit
Ein MCP-Server ist technisch klein: eine Klasse mit Attributen, ein Handler, eine Middleware. Sein Wert entsteht durch das, was er weglässt. Zehn gut beschriebene, berechtigte und protokollierte Werkzeuge vor Ihrem ERP sind für ein Modell nützlicher als jede vollständige API, und für Sie sicherer, weil Sie bei jedem Aufruf wissen, wer was warum getan hat. Mit der Spezifikation 2026-07-28 und dem offiziellen SDK passt das Protokoll erstmals ohne Verrenkungen in den Anfrage-Antwort-Zyklus von PHP. Der richtige Zeitpunkt, die eigene Fachanwendung zu öffnen, ist deshalb jetzt, mit lesenden Werkzeugen und einem Entwickler in Claude Code als erstem Nutzer.
Für die Suche in Dokumenten statt in Stammdaten lesen Sie den Beitrag RAG in eine bestehende PHP-Anwendung einbauen. Beides zusammen, Werkzeuge und Dokumente, ist die übliche erste Ausbaustufe eines Assistenten für Vertrieb oder Support.
Quellen (Stand 4. November 2026): MCP-Spezifikation 2026-07-28: Changelog, Offizielles MCP PHP SDK auf GitHub, mcp/sdk auf Packagist (0.9.0), SDK-Dokumentation: Tools, SDK-Dokumentation: Schema-Generierung, SDK-Dokumentation: HTTP-Transport, Anthropic: MCP Connector (Messages API), Claude Code: MCP-Server einbinden, MCP Inspector.