Qu’est-ce qu’un serveur MCP (explication avancée)
Model Context Protocol (MCP) est une spécification qui permet à des clients IA (Claude, Cursor, GitHub Copilot, etc.) et à des applications de communiquer via un protocole standardisé. MCP repose sur trois primitives principales.
| Primitive | Rôle | Usages principaux |
|---|
| Outils (Tools) | Fonctions que l’IA peut invoquer | Manipulation de données, intégration d’API externes, calculs |
| Ressources (Resources) | Contexte que l’IA peut charger | Documents, informations de configuration, données dynamiques |
| Prompts (Prompts) | Modèles réutilisables | Requêtes types, guidage de workflow |
L’avantage de construire un serveur MCP avec Laravel est de pouvoir mettre à profit l’écosystème Laravel — Eloquent, cache, authentification, validation — tel quel.
Installation et configuration initiale
Installer le package
Installez le package via Composer.composer require laravel/mcp
Publier le fichier de routes
Avec vendor:publish, générez routes/ai.php, l’emplacement où enregistrer votre serveur MCP.php artisan vendor:publish --tag=ai-routes
Générer la classe serveur
Créez la classe serveur via une commande Artisan.php artisan make:mcp-server DatabaseServer
Enregistrez les outils, ressources et prompts dans le fichier généré app/Mcp/Servers/DatabaseServer.php. Enregistrer le serveur
Enregistrez le serveur en tant que route dans routes/ai.php.use App\Mcp\Servers\DatabaseServer;
use Laravel\Mcp\Facades\Mcp;
Mcp::web('/mcp/database', DatabaseServer::class);
use App\Mcp\Servers\DatabaseServer;
use Laravel\Mcp\Facades\Mcp;
Mcp::local('database', DatabaseServer::class);
Le serveur web est accédé via HTTP POST. Le serveur local fonctionne comme une commande Artisan et sert à s’intégrer aux clients IA en ligne de commande.
Implémentation des outils
Un outil est une fonction que le client IA peut invoquer. Vous pouvez utiliser directement le service container, la validation et Eloquent de Laravel.
Créer un outil
php artisan make:mcp-tool SearchUsersTool
Implémentez les méthodes handle et schema dans la classe générée.
Définition des paramètres (schéma)
Dans la méthode schema, utilisez le builder Illuminate\Contracts\JsonSchema\JsonSchema pour définir les paramètres acceptés.
<?php
namespace App\Mcp\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Recherche des utilisateurs par nom ou adresse e-mail.')]
class SearchUsersTool extends Tool
{
public function handle(Request $request): Response
{
$validated = $request->validate([
'query' => 'required|string|max:100',
'limit' => 'integer|min:1|max:50',
'role' => 'nullable|string|in:admin,editor,viewer',
], [
'query.required' => 'Veuillez indiquer un mot-clé de recherche. Ex. : "Dupont" ou "[email protected]"',
'limit.max' => 'Le nombre maximum de résultats est de 50.',
'role.in' => 'Le rôle doit être admin, editor ou viewer.',
]);
$users = \App\Models\User::query()
->where(function ($q) use ($validated) {
$q->where('name', 'like', "%{$validated['query']}%")
->orWhere('email', 'like', "%{$validated['query']}%");
})
->when(isset($validated['role']), fn ($q) => $q->where('role', $validated['role']))
->limit($validated['limit'] ?? 10)
->get(['id', 'name', 'email', 'role']);
if ($users->isEmpty()) {
return Response::text('Aucun utilisateur ne correspond aux critères.');
}
$result = $users->map(fn ($u) => "ID:{$u->id} {$u->name} <{$u->email}> [{$u->role}]")
->implode("\n");
return Response::text($result);
}
public function schema(JsonSchema $schema): array
{
return [
'query' => $schema->string()
->description('Mot-clé de recherche. Recherche par nom ou adresse e-mail.')
->required(),
'limit' => $schema->integer()
->description('Nombre maximum de résultats (défaut : 10, max : 50).')
->minimum(1)
->maximum(50)
->default(10),
'role' => $schema->string()
->description('Rôle pour filtrer. admin, editor ou viewer.')
->enum(['admin', 'editor', 'viewer']),
];
}
}
Annotations d’outils
Les annotations du protocole MCP permettent au client IA de juger de la sécurité de l’outil.
use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly] // Outil en lecture seule qui ne modifie pas l'environnement
#[IsIdempotent] // Le résultat reste le même en cas d'appels multiples avec les mêmes arguments
class SearchUsersTool extends Tool
{
// ...
}
| Annotation | Description |
|---|
#[IsReadOnly] | Ne modifie pas les données |
#[IsDestructive] | Effectue une opération destructive comme une suppression |
#[IsIdempotent] | Réexécution sûre avec les mêmes arguments |
#[IsOpenWorld] | Communique avec des entités externes |
Réponses structurées
Pour renvoyer une réponse au format JSON facilement analysable par le client IA, utilisez Response::structured.
return Response::structured([
'total' => $users->count(),
'users' => $users->map(fn ($u) => [
'id' => $u->id,
'name' => $u->name,
'email' => $u->email,
])->toArray(),
]);
Réponses en streaming
Pour des traitements longs, renvoyer un Generator permet de streamer la progression.
use Generator;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
public function handle(Request $request): Generator
{
$items = \App\Models\Product::all();
$total = $items->count();
foreach ($items as $index => $item) {
yield Response::notification('processing/progress', [
'current' => $index + 1,
'total' => $total,
'name' => $item->name,
]);
// Traitement lourd...
yield Response::text("Traitement terminé : {$item->name}");
}
}
Sur un serveur web, les réponses en streaming sont envoyées automatiquement sous forme de flux SSE (Server-Sent Events).
Enregistrement conditionnel
Vous pouvez ne rendre l’outil disponible qu’aux utilisateurs remplissant certaines conditions.
public function shouldRegister(Request $request): bool
{
return $request?->user()?->hasRole('admin') ?? false;
}
Implémentation des ressources
Une ressource est une donnée que le client IA charge comme contexte. Elle fournit des documents, des informations de configuration ou des données dynamiques.
Ressources statiques
php artisan make:mcp-resource AppDocumentationResource
<?php
namespace App\Mcp\Resources;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\MimeType;
use Laravel\Mcp\Server\Attributes\Uri;
use Laravel\Mcp\Server\Resource;
#[Uri('app://resources/documentation')]
#[MimeType('text/markdown')]
#[Description("Aperçu de la documentation d'API et des règles métier de l'application.")]
class AppDocumentationResource extends Resource
{
public function handle(Request $request): Response
{
$content = \Illuminate\Support\Facades\Storage::get('docs/api-overview.md')
?? 'Documentation introuvable.';
return Response::text($content);
}
}
Ressources dynamiques (templates d’URI)
Les templates d’URI permettent de fournir des ressources dynamiques en fonction des paramètres de l’URL.
<?php
namespace App\Mcp\Resources;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\MimeType;
use Laravel\Mcp\Server\Contracts\HasUriTemplate;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Support\UriTemplate;
#[MimeType('application/json')]
#[Description("Récupère les informations d'un utilisateur d'après son ID.")]
class UserProfileResource extends Resource implements HasUriTemplate
{
public function uriTemplate(): UriTemplate
{
return new UriTemplate('app://users/{userId}/profile');
}
public function handle(Request $request): Response
{
$userId = $request->get('userId');
$user = \App\Models\User::find($userId);
if (! $user) {
return Response::error("Utilisateur d'ID {$userId} introuvable.");
}
return Response::text(json_encode([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'created_at' => $user->created_at->toIso8601String(),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
}
}
Le client IA demande la ressource via une URI comme app://users/42/profile, et la valeur de {userId} s’obtient avec $request->get('userId').
Annotations de ressources
Vous pouvez expliciter la priorité ou l’audience d’une ressource.
use Laravel\Mcp\Enums\Role;
use Laravel\Mcp\Server\Annotations\Audience;
use Laravel\Mcp\Server\Annotations\Priority;
#[Audience(Role::Assistant)] // Destinée à l'assistant IA
#[Priority(0.8)] // Importance (0.0 à 1.0)
class AppDocumentationResource extends Resource
{
// ...
}
Implémentation des prompts
Un prompt est un modèle réutilisable que le client IA peut invoquer. Il standardise des requêtes types ou des workflows complexes.
Créer un prompt
php artisan make:mcp-prompt DataAnalysisPrompt
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Prompts\Argument;
#[Description("Modèle de prompt permettant de générer un rapport d'analyse de données.")]
class DataAnalysisPrompt extends Prompt
{
public function arguments(): array
{
return [
new Argument(
name: 'target',
description: "Cible de l'analyse. Ex. : \"ventes du mois dernier\", \"évolution du nombre d'inscriptions\"",
required: true,
),
new Argument(
name: 'format',
description: 'Format de sortie. "summary" (résumé) ou "detail" (détaillé)',
required: false,
),
];
}
public function handle(Request $request): array
{
$validated = $request->validate([
'target' => 'required|string|max:200',
'format' => 'nullable|in:summary,detail',
]);
$target = $validated['target'];
$format = $validated['format'] ?? 'summary';
$instruction = $format === 'detail'
? 'Rédigez un rapport détaillé incluant chiffres, tendances, anomalies et actions recommandées.'
: 'Rédigez un résumé concis incluant les indicateurs clés et trois enseignements majeurs.';
return [
Response::text(
"Vous êtes un analyste de données. {$instruction}"
)->asAssistant(),
Response::text(
"Veuillez analyser les données suivantes : {$target}"
),
];
}
}
asAssistant() fait considérer le message comme une intervention de l’assistant IA. En combinant système et message utilisateur, vous pouvez contrôler finement le comportement de l’IA.
Authentification et autorisation
Authentification par token via Sanctum
C’est la méthode la plus simple. Le client MCP fournit un en-tête Authorization: Bearer <token>.
// routes/ai.php
Mcp::web('/mcp/database', DatabaseServer::class)
->middleware('auth:sanctum');
Authentification OAuth 2.1
Pour une authentification plus robuste, utilisez Laravel Passport.
// routes/ai.php
use Laravel\Mcp\Facades\Mcp;
Mcp::oauthRoutes();
Mcp::web('/mcp/database', DatabaseServer::class)
->middleware('auth:api');
Avec l’authentification OAuth, publiez les vues d’autorisation fournies par MCP et configurez-les dans AppServiceProvider.
php artisan vendor:publish --tag=mcp-views
// app/Providers/AppServiceProvider.php
use Laravel\Passport\Passport;
public function boot(): void
{
Passport::authorizationView(function ($parameters) {
return view('mcp.authorize', $parameters);
});
}
Authentification via un middleware personnalisé
Si vous utilisez des tokens API maison, validez l’en-tête Authorization dans un middleware personnalisé.
// app/Http/Middleware/McpTokenMiddleware.php
public function handle($request, Closure $next)
{
$token = $request->bearerToken();
if (! $token || ! \App\Models\ApiToken::where('token', hash('sha256', $token))->exists()) {
return response()->json(['error' => 'Unauthorized'], 401);
}
return $next($request);
}
Autorisation à l’intérieur de l’outil
Dans la méthode handle d’un outil ou d’une ressource, utilisez $request->user() pour effectuer des contrôles d’autorisation fins.
public function handle(Request $request): Response
{
$user = $request->user();
if (! $user?->can('manage-users')) {
return Response::error("Vous n'avez pas la permission d'utiliser cet outil. Contactez un administrateur.");
}
// Suite du traitement autorisé...
}
shouldRegister ne fait que masquer l’outil de la liste. Effectuez systématiquement le contrôle d’autorisation au moment de l’appel, dans la méthode handle.
Exemple pratique : outils d’accès à la base de données
Voici une implémentation complète d’outils recherchant et créant des données via Eloquent.
Classe serveur
<?php
namespace App\Mcp\Servers;
use App\Mcp\Tools\CreatePostTool;
use App\Mcp\Tools\SearchPostsTool;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
use Laravel\Mcp\Server;
#[Name('Blog Management Server')]
#[Version('1.0.0')]
#[Instructions('Serveur MCP qui permet de rechercher et de créer des articles de blog.')]
class BlogServer extends Server
{
protected array $tools = [
SearchPostsTool::class,
CreatePostTool::class,
];
}
Outil de recherche (lecture seule)
<?php
namespace App\Mcp\Tools;
use App\Models\Post;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tool;
#[Description('Recherche des articles de blog par mot-clé et statut.')]
#[IsReadOnly]
#[IsIdempotent]
class SearchPostsTool extends Tool
{
public function handle(Request $request): Response
{
$validated = $request->validate([
'keyword' => 'nullable|string|max:100',
'status' => 'nullable|in:draft,published,archived',
'limit' => 'integer|min:1|max:20',
]);
$posts = Post::query()
->when($validated['keyword'] ?? null, fn ($q, $kw) =>
$q->where('title', 'like', "%{$kw}%")
->orWhere('body', 'like', "%{$kw}%")
)
->when($validated['status'] ?? null, fn ($q, $s) => $q->where('status', $s))
->latest()
->limit($validated['limit'] ?? 5)
->get(['id', 'title', 'status', 'published_at']);
if ($posts->isEmpty()) {
return Response::text('Aucun article ne correspond aux critères.');
}
return Response::structured([
'total' => $posts->count(),
'posts' => $posts->map(fn ($p) => [
'id' => $p->id,
'title' => $p->title,
'status' => $p->status,
'published_at' => $p->published_at?->toIso8601String(),
])->toArray(),
]);
}
public function schema(JsonSchema $schema): array
{
return [
'keyword' => $schema->string()
->description('Mot-clé contenu dans le titre ou le corps.'),
'status' => $schema->string()
->description("Statut de l'article.")
->enum(['draft', 'published', 'archived']),
'limit' => $schema->integer()
->description('Nombre maximum de résultats (défaut : 5, max : 20).')
->default(5),
];
}
}
Outil de création (écriture)
<?php
namespace App\Mcp\Tools;
use App\Models\Post;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Crée un nouvel article de blog en tant que brouillon.')]
class CreatePostTool extends Tool
{
public function handle(Request $request): Response
{
$user = $request->user();
if (! $user?->can('create-posts')) {
return Response::error("Vous n'avez pas la permission de créer un article.");
}
$validated = $request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string',
'tags' => 'nullable|array',
'tags.*' => 'string|max:50',
], [
'title.required' => "Veuillez indiquer le titre de l'article.",
'body.required' => "Veuillez indiquer le corps de l'article.",
]);
$post = Post::create([
'title' => $validated['title'],
'body' => $validated['body'],
'status' => 'draft',
'user_id' => $user->id,
]);
if (! empty($validated['tags'])) {
$post->syncTags($validated['tags']);
}
return Response::structured([
'id' => $post->id,
'title' => $post->title,
'status' => $post->status,
'created_at' => $post->created_at->toIso8601String(),
'message' => "Article créé en tant que brouillon.",
]);
}
public function schema(JsonSchema $schema): array
{
return [
'title' => $schema->string()
->description("Titre de l'article (255 caractères max).")
->required(),
'body' => $schema->string()
->description("Corps de l'article. Markdown accepté.")
->required(),
'tags' => $schema->array()
->description('Tableau de tags à attribuer à l\'article. Ex. : ["Laravel", "PHP"]'),
];
}
}
Exemple pratique : outil d’opérations sur le système de fichiers
Voici un outil manipulant des fichiers via la façade Storage.
<?php
namespace App\Mcp\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Storage;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tool;
#[Description('Récupère la liste des fichiers du stockage et lit le contenu des fichiers texte.')]
#[IsReadOnly]
class ReadFileTool extends Tool
{
public function handle(Request $request): Response
{
$validated = $request->validate([
'path' => 'required|string|max:500',
'disk' => 'nullable|string|in:local,public,s3',
]);
$disk = $validated['disk'] ?? 'local';
$path = $validated['path'];
// Empêche les attaques par traversée de répertoire
if (str_contains($path, '..')) {
return Response::error('Chemin invalide.');
}
if (! Storage::disk($disk)->exists($path)) {
return Response::error("Fichier introuvable : {$path}");
}
$mimeType = Storage::disk($disk)->mimeType($path);
// Retourne le contenu uniquement pour les fichiers texte
if (str_starts_with($mimeType, 'text/') || $mimeType === 'application/json') {
$content = Storage::disk($disk)->get($path);
return Response::text($content);
}
// Renvoie les images en binaire
if (str_starts_with($mimeType, 'image/')) {
return Response::fromStorage($path, disk: $disk);
}
return Response::error("Format de fichier non pris en charge : {$mimeType}");
}
public function schema(JsonSchema $schema): array
{
return [
'path' => $schema->string()
->description('Chemin du fichier à lire. Ex. : "reports/2025-01.csv"')
->required(),
'disk' => $schema->string()
->description('Disque de stockage à utiliser.')
->enum(['local', 'public', 's3'])
->default('local'),
];
}
}
Pour les outils de fichiers, assainissez systématiquement les chemins pour empêcher l’accès à des dossiers non autorisés. Il est essentiel de refuser les chemins contenant ...
Tests
Serveur MCP, outils, ressources et prompts se testent unitairement avec les fonctionnalités de test standard de Laravel.
Tester un outil
Appelez directement l’outil via Server::tool().
use App\Mcp\Servers\BlogServer;
use App\Mcp\Tools\SearchPostsTool;
use App\Models\Post;
use App\Models\User;
test('les articles peuvent être recherchés', function () {
Post::factory()->create(['title' => 'Les bases de Laravel', 'status' => 'published']);
Post::factory()->create(['title' => 'PHP avancé', 'status' => 'draft']);
$response = BlogServer::tool(SearchPostsTool::class, [
'keyword' => 'Laravel',
'status' => 'published',
]);
$response
->assertOk()
->assertSee('Les bases de Laravel');
});
test("un utilisateur sans permission ne peut pas créer d'article", function () {
$user = User::factory()->create();
$response = BlogServer::actingAs($user)
->tool(\App\Mcp\Tools\CreatePostTool::class, [
'title' => 'Article de test',
'body' => 'Corps',
]);
$response->assertSee("Vous n'avez pas la permission de créer un article");
});
use App\Mcp\Servers\BlogServer;
use App\Mcp\Tools\SearchPostsTool;
use App\Models\Post;
use App\Models\User;
public function test_les_articles_peuvent_etre_recherches(): void
{
Post::factory()->create(['title' => 'Les bases de Laravel', 'status' => 'published']);
Post::factory()->create(['title' => 'PHP avancé', 'status' => 'draft']);
$response = BlogServer::tool(SearchPostsTool::class, [
'keyword' => 'Laravel',
'status' => 'published',
]);
$response
->assertOk()
->assertSee('Les bases de Laravel');
}
public function test_un_utilisateur_sans_permission_ne_peut_pas_creer_darticle(): void
{
$user = User::factory()->create();
$response = BlogServer::actingAs($user)
->tool(\App\Mcp\Tools\CreatePostTool::class, [
'title' => 'Article de test',
'body' => 'Corps',
]);
$response->assertSee("Vous n'avez pas la permission de créer un article");
}
Tester ressources et prompts
// Test d'une ressource
$response = BlogServer::resource(\App\Mcp\Resources\AppDocumentationResource::class);
$response->assertOk()->assertSee('API');
// Test d'un prompt
$response = BlogServer::prompt(\App\Mcp\Prompts\DataAnalysisPrompt::class, [
'target' => 'ventes du mois dernier',
'format' => 'summary',
]);
$response->assertOk()->assertSee('analyste de données');
Principales méthodes d’assertion
| Méthode | Description |
|---|
assertOk() | Vérifie que la réponse ne contient pas d’erreur |
assertSee($text) | Vérifie que la réponse contient le texte donné |
assertHasErrors() | Vérifie que la réponse comporte des erreurs |
assertHasNoErrors() | Vérifie que la réponse ne comporte pas d’erreurs |
assertName($name) | Vérifie le nom de l’outil |
assertSentNotification($method, $data) | Vérifie qu’une notification a été envoyée |
assertNotificationCount($count) | Vérifie le nombre de notifications envoyées |
Débogage avec MCP Inspector
Pour un débogage interactif, utilisez MCP Inspector.
# Inspection d'un serveur web
php artisan mcp:inspector mcp/database
# Inspection d'un serveur local
php artisan mcp:inspector database
Points à considérer pour le déploiement
HTTP streaming et SSE
Si vous utilisez des réponses en streaming (Generator) sur un serveur web, vérifiez la configuration du serveur.
location /mcp {
proxy_pass http://127.0.0.1:8000;
proxy_buffering off; # Désactiver la mise en tampon pour SSE
proxy_cache off;
proxy_read_timeout 3600s; # Autoriser les connexions longues
proxy_set_header Connection '';
chunked_transfer_encoding on;
}
# Avec mod_proxy_http
ProxyPass /mcp http://127.0.0.1:8000/mcp
ProxyPassReverse /mcp http://127.0.0.1:8000/mcp
SetEnv proxy-sendchunks 1
Combinaison avec Laravel Octane
Pour les serveurs MCP à fort trafic, envisagez Laravel Octane (FrankenPHP ou Swoole). L’overhead par requête est fortement réduit.
Avec Octane, l’état est partagé entre les requêtes. Prenez garde à ne pas utiliser de propriétés statiques ni d’état global à l’intérieur de vos outils.
Limitation du débit
Limitez les requêtes vers le serveur MCP avec le middleware throttle.
// Définir un rate limiter dédié dans config/cache.php
// routes/ai.php
Mcp::web('/mcp/database', DatabaseServer::class)
->middleware(['auth:sanctum', 'throttle:60,1']);
Cache
Tirez parti du cache pour les outils en lecture seule fréquemment invoqués.
public function handle(Request $request): Response
{
$data = \Illuminate\Support\Facades\Cache::remember(
"mcp:search:{$request->get('keyword')}",
now()->addMinutes(5),
fn () => $this->fetchFromDatabase($request)
);
return Response::structured($data);
}
Journalisation et supervision
Journaliser les appels aux outils MCP permet de comprendre comment le client IA les utilise.
public function handle(Request $request): Response
{
\Illuminate\Support\Facades\Log::info('MCP tool called', [
'tool' => static::class,
'user' => $request->user()?->id,
'params' => $request->all(),
]);
// ...
}
En production, surveillez les performances et les exceptions du serveur MCP avec Laravel Telescope ou Sentry.