Das Model Context Protocol (MCP) ist die Art, wie ein KI-Assistent aufhört, über Ihre Software zu raten, und anfängt, sie aufzurufen. Statt Datenbankzeilen in ein Chatfenster zu kopieren und zu hoffen, dass das Modell das Schema errät, exponieren Sie eine kleine Menge typisierter Operationen, und der Assistent ruft sie genauso auf wie jedes andere Tool. Das Protokoll ist transport- und sprachunabhängig, also lautet die Frage nicht mehr “kann ich das in PHP machen”, sondern “was ist die kleinste, sicherste Oberfläche, die ich exponieren sollte”.
Lange war die ehrliche Antwort für PHP-Teams “nimm das TypeScript- oder Python-SDK und rufe es per Shell auf”. Das hat sich geändert, als das offizielle PHP-SDK erschien. Sie können einen MCP-Server jetzt in derselben Symfony-Anwendung hosten, der Ihre Domain gehört, und die Repositories, Value Objects und Autorisierung wiederverwenden, denen Sie ohnehin vertrauen. Dies ist ein Durchgang, wie man einen solchen Server richtig baut, vom ersten Tool bis zu den Teilen, die in Produktion wehtun.
1. Wissen Sie, was Sie tatsächlich bauen
Ein MCP-Server ist ein kleiner RPC-Endpunkt, der drei Arten von Fähigkeit exponiert, und sie sind nicht austauschbar:
- Tools sind Aktionen, die der Assistent ausführen kann. Sie haben Seiteneffekte: eine Bestellung anlegen, einen Benutzer deaktivieren, ein Deployment auslösen. Das ist der Teil, den die Leute meinen, wenn sie “MCP” sagen.
- Resources sind lesende Daten, die der Assistent in den Kontext ziehen kann, adressiert über eine URI. Denken Sie an sie wie an GET-Endpunkte: eine Konfigurationsdatei, einen Benutzerdatensatz, die aktuellen Release Notes.
- Prompts sind wiederverwendbare Vorlagen, die ein Benutzer aufrufen kann, parametrisiert durch Argumente.
Der Fehler, den ich zuerst sehe, ist, dass Teams alles als Tool modellieren, weil ein Tool das offensichtlichste Primitiv ist. Ein Lesezugriff ohne Seiteneffekt ist eine Resource. Diese Linie sauber zu halten ist wichtig, denn Tools sind das, was Sie verteidigen müssen, und je kleiner diese Menge ist, desto leichter fällt das Verteidigen.
2. Das SDK installieren und das erste Tool schreiben
Das SDK zielt auf modernes PHP und installiert sich wie alles andere:
composer require mcp/sdk
Ein Tool ist eine public Methode auf einer schlichten Klasse, markiert mit einem Attribut. Weil die Klasse über Ihren Container aufgelöst wird, bekommt der Konstruktor Ihre echten Abhängigkeiten, dieselben Repositories, die auch Ihre Controller verwenden:
namespace App\Mcp;
use App\Repository\OrderRepositoryInterface;
use Mcp\Capability\Attribute\McpTool;
final readonly class OrderTools
{
public function __construct(
private OrderRepositoryInterface $orders,
) {
}
#[McpTool(
name: 'count_open_orders',
description: 'Returns the number of orders that have not yet shipped.',
)]
public function countOpenOrders(): int
{
return $this->orders->countByStatus('open');
}
}
Der Server selbst ist ein Builder. Für den ersten Lauf zeigen Sie die Discovery auf das Verzeichnis mit Ihren Tool-Klassen und lassen sie die Attribute finden:
#!/usr/bin/env php
<?php
declare(strict_types=1);
require __DIR__.'/vendor/autoload.php';
use Mcp\Server;
use Mcp\Server\Transport\StdioTransport;
$server = Server::builder()
->setServerInfo('Acme Orders', '1.0.0')
->setDiscovery(__DIR__, ['src/Mcp'])
->build();
exit($server->run(new StdioTransport()));
Das ist ein funktionierender MCP-Server. Die interessanten Entscheidungen kommen alle danach.
3. Lassen Sie die Attribute den Vertrag tragen
Der Assistent sieht Ihren Code nie. Er sieht den Namen, die Beschreibung und das JSON-Schema, das aus Ihren Parametertypen abgeleitet wird. Diese drei Dinge sind der gesamte Vertrag, behandeln Sie sie also als API-Design, nicht als Annotation-Rauschen.
Die description wird vom Modell gelesen, um zu entscheiden, ob und wie es das Tool aufruft. Eine vage Beschreibung führt dazu, dass ein Tool zum falschen Zeitpunkt mit den falschen Argumenten aufgerufen wird. Schreiben Sie sie für einen kompetenten Fremden: was es tut, was es zurückgibt und jede Grenze, die zählt.
Native Typen werden automatisch ins Schema abgeleitet. Wenn ein Parameter mehr als einen Typ braucht, etwa ein Format, ein Enum oder eine Schranke, deklarieren Sie ihn mit dem Schema-Attribut am Parameter:
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
#[McpTool(
name: 'search_orders',
description: 'Find orders by customer email. Returns at most 20 matches.',
)]
public function searchOrders(
#[Schema(['type' => 'string', 'format' => 'email'])]
string $email,
): array {
return $this->orders->findByEmail($email, limit: 20);
}
Wenn Sie Prosa bevorzugen, fällt das SDK für die Beschreibung auf den Methoden-DocBlock zurück. Ich setze stattdessen auf das Attribut, weil es den Vertrag explizit hält und Refactoring-Tools überlebt, die Kommentare umschreiben.
4. Resources und Prompts, nicht nur Tools
Wenn der Assistent etwas lesen statt etwas tun muss, exponieren Sie eine Resource. Dieselbe Idee, ein anderes Attribut und kein Seiteneffekt:
use Mcp\Capability\Attribute\McpResource;
#[McpResource(
uri: 'config://shipping/rates',
description: 'The current shipping rate card.',
)]
public function shippingRates(): array
{
return $this->rates->all();
}
Prompts sind wiederverwendbare Anweisungen, die ein Benutzer auswählen kann, mit bereits eingesetzten Argumenten:
use Mcp\Capability\Attribute\McpPrompt;
#[McpPrompt(name: 'triage_incident')]
public function triageIncident(string $service): string
{
return \sprintf('Investigate the latest errors for the %s service and propose a likely cause.', $service);
}
Zum richtigen Primitiv zu greifen ist die billigste Qualitätsentscheidung, die Sie hier treffen. Jeder Lesezugriff, den Sie als Resource modellieren, ist ein Tool weniger in der Menge, die Sie auditieren müssen.
5. Wählen Sie einen Transport danach, wie er erreicht wird
Der Transport ist, wie ein Client mit Ihrem Server spricht, und es gibt zwei, die zählen.
Stdio führt Ihren Server als Subprozess des Clients aus. Der Client startet php server.php, spricht über Standard-Eingabe und -Ausgabe und fährt ihn herunter, wenn er fertig ist. Das ist perfekt für einen Server, der auf der Maschine eines Entwicklers gegen einen Assistenten läuft. Die Client-Konfiguration ist nur ein Kommando:
{
"mcpServers": {
"acme-orders": {
"command": "php",
"args": ["/absolute/path/to/bin/mcp-server.php"]
}
}
}
Streamable HTTP führt Ihren Server als normalen Web-Endpunkt aus, den viele Clients über das Netzwerk erreichen können, mit Sessions und Server-Sent Events für Streaming. Das ist der, den Sie deployen. Er spricht PSR-7, was genau der Grund ist, warum der nächste Schritt einfach ist.
Die Falle ist, das Stdio-Skript in Produktion zu schicken, weil es das war, das Sie zuerst gebaut haben. Stdio ist ein lokaler Entwicklungstransport. Alles, was mehrere Benutzer hat oder remote ist, will HTTP.
6. Hosten Sie ihn in Symfony, nicht daneben
Der Grund, Ihren MCP-Server überhaupt in PHP zu schreiben, ist, die Anwendung wiederzuverwenden, die Sie schon haben. Der Streamable-HTTP-Transport spricht PSR-7, und Symfony liefert eine Bridge, also wird der Server zu einem gewöhnlichen Controller:
composer require symfony/psr-http-message-bridge nyholm/psr7
namespace App\Controller;
use Mcp\Server;
use Mcp\Server\Transport\StreamableHttpTransport;
use Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface;
use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/mcp', name: 'mcp_endpoint', methods: ['GET', 'POST'])]
final readonly class McpController
{
public function __construct(
private Server $server,
private HttpMessageFactoryInterface $psrFactory,
private HttpFoundationFactoryInterface $httpFoundationFactory,
) {
}
public function __invoke(Request $request): Response
{
$psrRequest = $this->psrFactory->createRequest($request);
$psrResponse = $this->server->run(new StreamableHttpTransport($psrRequest));
return $this->httpFoundationFactory->createResponse($psrResponse);
}
}
Bauen Sie den Server einmal als Service über eine kleine Factory, damit der Container ihn injizieren kann, so wie Sie jedes andere konfigurierte Objekt verdrahten würden. Von hier an sind Ihre Tools einfach Services. Sie bekommen Ihre Repositories, Ihren Message Bus, Ihren Authorization Checker per Konstruktor-Injection, und Sie pflegen keinen zweiten Bootstrap mehr, der vom echten abdriftet.
7. Discovery ist bequem und langsam
setDiscovery() scannt bei jedem Start des Servers das Dateisystem nach Attributen. Für Stdio wird dieser Aufwand einmal pro Session bezahlt, und niemand bemerkt es. Für einen HTTP-Endpunkt unter Last ist das Scannen bei jedem Kaltstart verschwendete Arbeit. Geben Sie ihm einen PSR-16-Cache, und er scannt einmal und liest danach den Cache:
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Psr16Cache;
$server = Server::builder()
->setServerInfo('Acme Orders', '1.0.0')
->setDiscovery(
basePath: __DIR__,
scanDirs: ['src/Mcp'],
cache: new Psr16Cache(new FilesystemAdapter('mcp')),
)
->build();
Wenn Sie lieber gar nicht scannen wollen, registrieren Sie Tools explizit mit addTool() am Builder. Explizite Registrierung ist mehr Tipparbeit und null Magie, was auf einem Server, der zugleich eine Sicherheitsgrenze ist, ein Vorteil ist, kein Preis.
8. Behandeln Sie jedes Tool als Sicherheitsgrenze
Das ist der Abschnitt, den die Leute überspringen und bereuen. Ein MCP-Tool ist ein Remote Procedure Call, dessen Aufrufer ein Sprachmodell ist, gesteuert von dem, der hineintippt, und manchmal von Text, den das Modell anderswo gelesen hat. Das Modell ist ein nicht vertrauenswürdiger Aufrufer. Autorisierung ist Ihre Aufgabe, nicht die des Protokolls.
Die Regeln, an denen ich Teams festhalte:
- Exponieren Sie niemals ein rohes Query-Tool. Ein
run_sql- odereval-Tool ist ein Remote-Code-Execution-Endpunkt mit einer freundlichen Beschreibung. Exponieren Sie konkrete Operationen, keine universellen Hintertüren. - Validieren Sie an der Grenze. Sie haben bereits Value Objects und
webmozart/assert. Ein Tool-Argument ist externe Eingabe, also bekommt es dieselbe Behandlung wie ein Request-Payload: parsen Sie es in ein Value Object, weisen Sie zurück, was nicht passt. - Begrenzen Sie nach dem authentifizierten Principal, nicht nach Vertrauen ins Modell. Die Session trägt, wer aufruft. Ein Tool muss prüfen, was dieser Benutzer tun darf, genau wie ein Controller. “Das Modell würde das nicht verlangen” ist keine Zugriffsrichtlinie.
- Rechnen Sie mit Prompt Injection. Ein Tool, das freien Text annimmt und darauf handelt, kann von Inhalten gesteuert werden, die der Benutzer nie geschrieben hat, etwa Anweisungen, die in einem Dokument versteckt sind, das der Assistent zusammenfassen sollte. Halten Sie destruktive Tools eng, und verlangen Sie einen bewussten Bestätigungsschritt für alles, was nicht rückgängig zu machen ist.
Je kleiner Ihre Tool-Menge, desto mehr davon können Sie tatsächlich durchdenken. Das ist der wahre Grund, warum Abschnitt 1 auf der Linie zwischen Tool und Resource bestanden hat.
9. Fehler und Logging, die der Client nutzen kann
Wenn ein Tool seine Aufgabe nicht erledigen kann, werfen Sie. Das SDK verwandelt die Exception in einen strukturierten Fehler, den der Assistent erhält und auf den er reagieren kann, also ist die Nachricht, die Sie werfen, Teil Ihrer Schnittstelle:
#[McpTool(name: 'cancel_order')]
public function cancelOrder(string $orderId): string
{
$order = $this->orders->get(OrderId::fromString($orderId));
if (!$order->isCancellable()) {
throw new \RuntimeException('Order has already shipped and cannot be cancelled.');
}
$order->cancel();
$this->orders->save($order);
return 'Order cancelled.';
}
Schreiben Sie diese Nachrichten für einen Leser: nennen Sie, was schiefging und was es zum Laufen brächte, und verraten Sie nichts über Interna, denn das Modell und oft der Benutzer werden sie sehen. Für Fortschritt und Diagnose fordern Sie einen RequestContext an und nutzen den Client-Logger, der strukturierte Nachrichten an den Aufrufer zurückschickt statt in eine Logdatei, die er nicht lesen kann:
use Mcp\Server\RequestContext;
#[McpTool(name: 'rebuild_search_index')]
public function rebuildSearchIndex(RequestContext $context): string
{
$context->getClientLogger()->info('Rebuilding search index');
// ... work ...
return 'Search index rebuilt.';
}
Halten Sie Ihr eigenes serverseitiges Logging getrennt und so detailliert wie bei jedem anderen Produktionsdienst. Das Client-Log ist für den Assistenten, Ihr Anwendungslog ist für Sie.
10. Die Checkliste, bevor Sie einen echten Assistenten anschließen
Die Punchlist, die ich durchgehe, bevor ein MCP-Server auf irgendetwas Wichtiges zeigt:
- Jeder Lesezugriff ohne Seiteneffekt ist eine Resource, kein Tool.
- Jedes Tool hat eine Beschreibung, die für einen Fremden geschrieben ist, und Parameter-Schemas, die die Eingabe einschränken.
- Kein Tool ist eine universelle Query- oder Eval-Hintertür.
- Tool-Argumente werden in Value Objects geparst und validiert, nicht vertraut.
- Autorisierung wird pro Aufruf gegen den authentifizierten Principal geprüft.
- Alles Destruktive ist eng und verlangt eine Bestätigung.
- Produktion läuft über Streamable HTTP, nicht über das Stdio-Entwicklungsskript.
- Discovery ist gecacht, oder Tools werden explizit registriert.
- Geworfene Exceptions tragen Nachrichten, die für das Modell und den Benutzer sicher zu lesen sind.
- Serverseitiges Logging ist so gut wie bei jedem anderen Produktionsendpunkt.
Die meisten davon sind eine Stunde Arbeit pro Punkt. Zusammen sind sie der Unterschied zwischen einer Demo, die ein Standup beeindruckt, und einem Server, den Sie an ein System angeschlossen lassen wollen, das Geld verlieren kann.
Wenn Sie abwägen, wo KI-Assistenten in Ihrem Unternehmen handeln dürfen, ist das genau das Gespräch, für das unsere Business-Alignment-Begleitung gebaut ist. Wir kartieren, welche Operationen sicher als MCP-Tools zu exponieren sind, wo die Autorisierungsgrenze hingehört und wie die Oberfläche klein genug bleibt, dass Sie noch durchdenken können, was ein Assistent tun kann.
Referenzen
- Model Context Protocol : die Protokollspezifikation, einschließlich der Tool-, Resource- und Prompt-Primitive und der Transport-Definitionen.
- MCP PHP SDK : das offizielle PHP-SDK mit den Attributen, dem Server-Builder und den Transports, die in diesem Artikel verwendet werden.
- Symfony PSR-7 Bridge : die Bridge, die zwischen Symfony- und PSR-7-Nachrichten konvertiert, und genau das macht den HTTP-Transport zu einer Sache von einem Controller.
- PSR-16 Simple Cache : die Caching-Schnittstelle, die der Server-Builder annimmt, um Dateisystem-Discovery bei jedem Start zu vermeiden.