◀ Retour au blog
PHP / Symfony

Créer un serveur MCP en PHP

Publié le 22 Sep 2026· 9 min de lecture
#PHP#MCP#IA#Symfony

MCP : donner des mains à un assistant IA

Le Model Context Protocol (MCP) est un protocole ouvert qui standardise la façon dont une application d'IA (Claude, un IDE, un agent) se connecte à des sources de données et à des actions externes. Au lieu d'écrire une intégration spécifique pour chaque assistant, vous écrivez un serveur MCP, et tous les clients compatibles savent l'utiliser.

Un serveur MCP expose trois types de capacités :

  • Tools : des actions que le modèle peut appeler (vérifier un service, créer un ticket, lancer une requête SQL en lecture…)
  • Resources : des données que le client peut lire (configuration, documentation, état d'un serveur)
  • Prompts : des modèles de messages réutilisables, paramétrables par l'utilisateur

Le client et le serveur échangent des messages JSON-RPC 2.0, soit via l'entrée/sortie standard (stdio, idéal en local), soit via HTTP (Streamable HTTP, pour un serveur distant).

Le SDK PHP officiel

Depuis 2025, PHP dispose d'un SDK officiel : mcp/sdk, développé conjointement par la PHP Foundation et le projet Symfony, à partir du travail de PHP-MCP et de Symfony AI. Il est agnostique du framework et suit la promesse de rétrocompatibilité de Symfony. Il reste marqué expérimental avant sa version 1.0 : figez la version dans votre composer.json.

Prérequis : PHP 8.1 minimum. Installation :

composer require mcp/sdk symfony/finder

Piège n°1 : symfony/finder n'est qu'une dépendance suggérée, mais elle est indispensable à la découverte automatique des outils par attributs. Sans elle, le serveur échoue au démarrage… et comme l'erreur part sur la sortie d'erreur, le client voit simplement un serveur sans aucun outil.

Un premier serveur : un assistant DevOps

Construisons un serveur utile au quotidien : il vérifie qu'une URL répond, contrôle l'espace disque, expose des informations système et propose un prompt de rapport d'incident. Commencez par déclarer l'autoload de vos classes :

{
    "require": {
        "mcp/sdk": "^0.8",
        "symfony/finder": "^8.1"
    },
    "autoload": {
        "psr-4": { "App\\": "src/" }
    }
}

Les capacités sont de simples méthodes PHP annotées. Le SDK génère le schéma JSON des paramètres à partir des types PHP, et la description à partir du docblock :

<?php

namespace App;

use Mcp\Capability\Attribute\McpPrompt;
use Mcp\Capability\Attribute\McpResource;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;

final class DevOpsTools
{
    /**
     * Vérifie qu'une URL répond et renvoie son code HTTP et son temps de réponse.
     */
    #[McpTool(name: 'check_url')]
    public function checkUrl(
        #[Schema(format: 'uri', description: 'URL complète, ex. https://benmacha.tn')]
        string $url,
    ): array {
        if (!preg_match('#^https?://#', $url)) {
            throw new ToolCallException('Seules les URL http(s) sont acceptées.');
        }

        $start = microtime(true);
        $context = stream_context_create(['http' => ['method' => 'HEAD', 'timeout' => 5, 'ignore_errors' => true]]);
        $headers = @get_headers($url, true, $context);

        if ($headers === false) {
            return ['url' => $url, 'up' => false, 'error' => 'Hôte injoignable'];
        }

        preg_match('#\s(\d{3})\s#', $headers[0], $m);
        $status = (int) ($m[1] ?? 0);

        return [
            'url' => $url,
            'up' => $status > 0 && $status < 400,
            'status' => $status,
            'time_ms' => (int) round((microtime(true) - $start) * 1000),
        ];
    }

    /**
     * Retourne l'espace disque utilisé et disponible pour un chemin.
     */
    #[McpTool(name: 'disk_usage')]
    public function diskUsage(
        #[Schema(description: 'Chemin à analyser')]
        string $path = '/',
    ): array {
        $total = @disk_total_space($path);
        $free = @disk_free_space($path);

        if ($total === false || $free === false) {
            throw new ToolCallException(sprintf('Chemin illisible : %s', $path));
        }

        return [
            'path' => $path,
            'total_gb' => round($total / 1e9, 1),
            'free_gb' => round($free / 1e9, 1),
            'used_percent' => round(100 * ($total - $free) / $total, 1),
        ];
    }

    #[McpResource(uri: 'server://info', name: 'server_info', mimeType: 'application/json')]
    public function serverInfo(): array
    {
        return ['hostname' => gethostname(), 'os' => PHP_OS_FAMILY, 'php' => PHP_VERSION];
    }

    /**
     * Prépare un rapport d'incident à partir d'un service et d'un symptôme.
     */
    #[McpPrompt(name: 'incident_report')]
    public function incidentReport(string $service, string $symptom): array
    {
        return [[
            'role' => 'user',
            'content' => "Le service « $service » présente ce symptôme : $symptom. "
                . "Utilise check_url et disk_usage pour diagnostiquer, puis rédige un rapport "
                . "d'incident court : impact, cause probable, actions immédiates.",
        ]];
    }
}

Le point d'entrée tient en quelques lignes : on déclare le serveur, on lui demande de scanner le dossier src, et on le lance sur le transport stdio.

#!/usr/bin/env php
<?php

require __DIR__.'/vendor/autoload.php';

use Mcp\Server;
use Mcp\Server\Transport\StdioTransport;

exit(Server::builder()
    ->setServerInfo('DevOps Assistant', '1.0.0')
    ->setDiscovery(__DIR__, ['src'])
    ->build()
    ->run(new StdioTransport()));

Ce que voit le client

À partir de la signature checkUrl(string $url), du docblock et de l'attribut #[Schema], le SDK publie cette définition d'outil :

{
  "name": "check_url",
  "description": "Vérifie qu'une URL répond et renvoie son code HTTP et son temps de réponse.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "url": { "type": "string", "format": "uri", "description": "URL complète, ex. https://benmacha.tn" }
    },
    "required": ["url"]
  }
}

Pour disk_usage, la valeur par défaut '/' devient un "default" et le paramètre n'est pas obligatoire. Quand un outil retourne un tableau, le SDK le renvoie à la fois en texte JSON et en structuredContent, directement exploitable par le client.

Gérer les erreurs correctement

Piège n°2 : n'importe quelle exception ne convient pas. Une exception quelconque (InvalidArgumentException, RuntimeException…) est transformée en erreur JSON-RPC générique, « Error while executing tool » : le modèle ne sait pas ce qui s'est passé. Levez plutôt une Mcp\Exception\ToolCallException : le message est renvoyé dans un résultat marqué isError: true, que le modèle peut lire pour corriger son appel (par exemple, réessayer avec une URL en https).

Tester sans assistant : le client PHP et l'Inspector

Le SDK contient aussi un client, parfait pour des tests automatisés :

use Mcp\Client;
use Mcp\Client\Transport\StdioTransport;

$client = Client::builder()->setClientInfo('Tests', '1.0.0')->build();
$client->connect(new StdioTransport(command: 'php', args: [__DIR__.'/server.php']));

foreach ($client->listTools()->tools as $tool) {
    echo $tool->name, ' : ', $tool->description, PHP_EOL;
}

$result = $client->callTool('check_url', ['url' => 'https://benmacha.tn']);
var_dump($result->structuredContent); // ['url' => ..., 'up' => true, 'status' => 200, 'time_ms' => ...]

$client->disconnect();

Pour explorer le serveur visuellement, l'MCP Inspector officiel lance le serveur et affiche outils, ressources et prompts :

npx @modelcontextprotocol/inspector php server.php

Brancher le serveur sur Claude

Avec Claude Code, une seule commande suffit :

claude mcp add devops -- php /chemin/absolu/vers/server.php

Avec Claude Desktop, ajoutez le serveur dans claude_desktop_config.json :

{
  "mcpServers": {
    "devops": {
      "command": "php",
      "args": ["/chemin/absolu/vers/server.php"]
    }
  }
}

Demandez ensuite : « benmacha.tn répond-il correctement, et reste-t-il de la place sur le disque ? ». L'assistant appelle check_url puis disk_usage, et synthétise les résultats.

Les règles d'or en production

  • Ne jamais écrire sur stdout en mode stdio : la sortie standard est réservée au protocole. Un echo ou un var_dump oublié corrompt les échanges. Loggez sur stderr ou dans un fichier (le builder accepte un logger PSR-3).
  • Des outils étroits plutôt qu'un outil « exécuter une commande » : exposer un shell ou du SQL libre revient à donner les clés du serveur au modèle.
  • Valider chaque entrée : le schéma JSON aide le modèle, mais ne remplace pas la validation côté serveur (listes blanches de chemins, d'hôtes, de tables).
  • Moindre privilège : faites tourner le serveur avec un utilisateur système dédié et un compte de base de données en lecture seule quand c'est possible.
  • Des descriptions soignées : c'est la seule documentation que lit le modèle pour choisir le bon outil et le bon paramètre.

Aller plus loin

Le SDK fournit aussi un transport HTTP (Streamable HTTP) pour héberger un serveur distant partagé par une équipe, avec gestion des sessions et de l'autorisation, et il prend en charge les deux générations du protocole, y compris la révision sans état 2026-07-28. Côté frameworks, symfony/mcp-bundle intègre le SDK à Symfony (vos services deviennent des outils MCP, avec l'injection de dépendances), et api-platform/mcp expose directement vos ressources API Platform.

C'est l'approche que j'utilise pour connecter des assistants IA aux données métier : quelques outils bien délimités, en lecture seule, avec des descriptions précises, et l'IA devient capable de répondre à des questions qui demandaient auparavant une requête SQL ou un export manuel.