Cos’è un server MCP (guida avanzata)
Il Model Context Protocol (MCP) è una specifica che consente a client AI (Claude, Cursor, GitHub Copilot, ecc.) e applicazioni di comunicare tramite un protocollo standardizzato. MCP ha tre primitive principali.
| Primitiva | Ruolo | Casi d’uso principali |
|---|
| Tool | Funzioni che l’AI può invocare | Operazioni sui dati, integrazioni API esterne, calcoli |
| Resource | Contesto che l’AI può leggere | Documentazione, informazioni di configurazione, dati dinamici |
| Prompt | Template riutilizzabili | Query ricorrenti, guida a un workflow |
Il vantaggio di costruire un server MCP in Laravel è poter riutilizzare tutto l’ecosistema Laravel: Eloquent, cache, autenticazione, validazione.
Questa guida avanzata entra nell’implementazione pratica. Per i concetti di base di MCP consulta Intermedio: Laravel MCP.
Installazione e setup iniziale
Installare il pacchetto
Installa con Composer.composer require laravel/mcp
Pubblicare il file delle rotte
Con vendor:publish generi routes/ai.php, dove registrerai il server MCP.php artisan vendor:publish --tag=ai-routes
Generare la classe server
Crea la classe server con un comando Artisan.php artisan make:mcp-server DatabaseServer
Nel file generato app/Mcp/Servers/DatabaseServer.php registri tool, resource e prompt. Registrare il server
In routes/ai.php colleghi il server a una rotta.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);
Un server web viene raggiunto via HTTP POST. Un server locale gira come comando Artisan e serve per integrare client AI basati su CLI.
I tool sono funzioni invocabili dai client AI. Puoi usare direttamente service container, validazione ed Eloquent di Laravel.
php artisan make:mcp-tool SearchUsersTool
Nella classe generata implementi i metodi handle e schema.
Definizione dei parametri (schema)
Nel metodo schema usi il builder Illuminate\Contracts\JsonSchema\JsonSchema per definire i parametri accettati.
<?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('Cerca utenti per nome o email.')]
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' => 'Indica una parola chiave di ricerca. Esempio: "Rossi" o "[email protected]"',
'limit.max' => 'Il numero massimo di risultati è 50.',
'role.in' => 'Il ruolo deve essere uno tra admin, editor, 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('Nessun utente trovato con i criteri indicati.');
}
$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('Parola chiave di ricerca. Cerca per nome o email.')
->required(),
'limit' => $schema->integer()
->description('Numero massimo di risultati (default: 10, max: 50).')
->minimum(1)
->maximum(50)
->default(10),
'role' => $schema->string()
->description('Ruolo per il filtro. Uno tra admin, editor, viewer.')
->enum(['admin', 'editor', 'viewer']),
];
}
}
Le annotazioni del protocollo MCP aiutano il client AI a valutare la sicurezza del tool.
use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly] // tool di sola lettura, non modifica l'ambiente
#[IsIdempotent] // chiamate ripetute con gli stessi argomenti danno lo stesso risultato
class SearchUsersTool extends Tool
{
// ...
}
| Annotazione | Descrizione |
|---|
#[IsReadOnly] | Non modifica dati |
#[IsDestructive] | Esegue operazioni distruttive (es. cancellazione) |
#[IsIdempotent] | Riesecuzione con gli stessi argomenti sicura |
#[IsOpenWorld] | Comunica con entità esterne |
Risposte strutturate
Per restituire una risposta JSON facile da parsare per il client AI usa Response::structured.
return Response::structured([
'total' => $users->count(),
'users' => $users->map(fn ($u) => [
'id' => $u->id,
'name' => $u->name,
'email' => $u->email,
])->toArray(),
]);
Risposte in streaming
Per elaborazioni lunghe puoi restituire un Generator per streammare l’avanzamento.
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,
]);
// elaborazione pesante...
yield Response::text("Elaborazione completata: {$item->name}");
}
}
Nel server web le risposte in streaming vengono inviate automaticamente come stream SSE (Server-Sent Events).
Registrazione condizionale
Puoi esporre un tool solo a utenti che soddisfano certe condizioni.
public function shouldRegister(Request $request): bool
{
return $request?->user()?->hasRole('admin') ?? false;
}
Implementazione delle risorse
Le risorse sono i dati che il client AI legge come contesto: documentazione, configurazioni, dati dinamici.
Risorsa statica
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('Panoramica della documentazione API e delle regole di business dell\'applicazione.')]
class AppDocumentationResource extends Resource
{
public function handle(Request $request): Response
{
$content = \Illuminate\Support\Facades\Storage::get('docs/api-overview.md')
?? 'Documentazione non trovata.';
return Response::text($content);
}
}
Risorsa dinamica (URI template)
Con gli URI template puoi fornire risorse dinamiche in base ai parametri dell’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('Recupera le informazioni utente dato l\'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("Utente con ID {$userId} non trovato.");
}
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));
}
}
Il client AI richiede la risorsa con URI del tipo app://users/42/profile; il valore di {userId} è recuperabile con $request->get('userId').
Annotazioni delle risorse
Puoi indicare priorità e audience della risorsa.
use Laravel\Mcp\Enums\Role;
use Laravel\Mcp\Server\Annotations\Audience;
use Laravel\Mcp\Server\Annotations\Priority;
#[Audience(Role::Assistant)] // destinata all'assistente AI
#[Priority(0.8)] // importanza (0.0–1.0)
class AppDocumentationResource extends Resource
{
// ...
}
Implementazione dei prompt
I prompt sono template riutilizzabili usati dal client AI. Standardizzano query ricorrenti e workflow complessi.
Creare 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('Template di prompt per generare un report di analisi dati.')]
class DataAnalysisPrompt extends Prompt
{
public function arguments(): array
{
return [
new Argument(
name: 'target',
description: 'Oggetto dell\'analisi. Esempio: "vendite del mese scorso", "andamento delle iscrizioni"',
required: true,
),
new Argument(
name: 'format',
description: 'Formato di output. "summary" (riassunto) o "detail" (dettagliato)',
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'
? 'Crea un report dettagliato che includa numeri, trend, valori anomali e azioni consigliate.'
: 'Crea un riassunto conciso con le metriche principali e tre insight chiave.';
return [
Response::text(
"Sei un data analyst. {$instruction}"
)->asAssistant(),
Response::text(
"Analizza i seguenti dati: {$target}"
),
];
}
}
Con asAssistant() il messaggio è trattato come output dell’assistente AI. Combinando system prompt e messaggi utente controlli in modo fine il comportamento dell’AI.
Autenticazione e autorizzazione
Autenticazione token con Sanctum
Il metodo più semplice. Il client MCP invia l’header Authorization: Bearer <token>.
// routes/ai.php
Mcp::web('/mcp/database', DatabaseServer::class)
->middleware('auth:sanctum');
Autenticazione con OAuth 2.1
Per un’autenticazione più solida, usa Laravel Passport.
// routes/ai.php
use Laravel\Mcp\Facades\Mcp;
Mcp::oauthRoutes();
Mcp::web('/mcp/database', DatabaseServer::class)
->middleware('auth:api');
Per OAuth pubblichi le view di autorizzazione fornite da MCP e le configuri in 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);
});
}
Autenticazione con middleware custom
Se usi API token proprietari, validali con un middleware custom sull’header Authorization.
// 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);
}
Nel metodo handle di un tool o risorsa puoi usare $request->user() per controlli d’autorizzazione fine.
public function handle(Request $request): Response
{
$user = $request->user();
if (! $user?->can('manage-users')) {
return Response::error('Non hai i permessi per usare questo tool. Contatta un amministratore.');
}
// logica autorizzata...
}
shouldRegister si limita a nascondere il tool dalla lista. Il controllo di autorizzazione all’invocazione va sempre eseguito nel metodo handle.
Esempio completo di tool che cercano e creano dati usando Eloquent.
Classe server
<?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('Server MCP per cercare e creare articoli del blog.')]
class BlogServer extends Server
{
protected array $tools = [
SearchPostsTool::class,
CreatePostTool::class,
];
}
<?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('Cerca articoli del blog per parola chiave o stato.')]
#[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('Nessun articolo corrisponde ai criteri indicati.');
}
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('Parola chiave presente nel titolo o nel corpo.'),
'status' => $schema->string()
->description('Stato dell\'articolo.')
->enum(['draft', 'published', 'archived']),
'limit' => $schema->integer()
->description('Numero massimo di risultati (default: 5, max: 20).')
->default(5),
];
}
}
<?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('Crea un nuovo articolo del blog come bozza.')]
class CreatePostTool extends Tool
{
public function handle(Request $request): Response
{
$user = $request->user();
if (! $user?->can('create-posts')) {
return Response::error('Non hai il permesso di creare articoli.');
}
$validated = $request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string',
'tags' => 'nullable|array',
'tags.*' => 'string|max:50',
], [
'title.required' => 'Indica il titolo dell\'articolo.',
'body.required' => 'Indica il corpo dell\'articolo.',
]);
$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' => 'Articolo creato come bozza.',
]);
}
public function schema(JsonSchema $schema): array
{
return [
'title' => $schema->string()
->description('Titolo dell\'articolo (max 255 caratteri).')
->required(),
'body' => $schema->string()
->description('Corpo dell\'articolo. È possibile usare Markdown.')
->required(),
'tags' => $schema->array()
->description('Array di tag da assegnare. Esempio: ["Laravel", "PHP"]'),
];
}
}
Esempio di tool che opera sui file tramite il facade 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('Elenca i file nello storage e legge il contenuto dei file di testo.')]
#[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'];
// previene la directory traversal
if (str_contains($path, '..')) {
return Response::error('È stato indicato un path non valido.');
}
if (! Storage::disk($disk)->exists($path)) {
return Response::error("File non trovato: {$path}");
}
$mimeType = Storage::disk($disk)->mimeType($path);
// restituisci il contenuto solo per i file di testo
if (str_starts_with($mimeType, 'text/') || $mimeType === 'application/json') {
$content = Storage::disk($disk)->get($path);
return Response::text($content);
}
// restituisci i file immagine come binari
if (str_starts_with($mimeType, 'image/')) {
return Response::fromStorage($path, disk: $disk);
}
return Response::error("Formato file non supportato: {$mimeType}");
}
public function schema(JsonSchema $schema): array
{
return [
'path' => $schema->string()
->description('Path del file da leggere. Esempio: "reports/2025-01.csv"')
->required(),
'disk' => $schema->string()
->description('Disco storage da usare.')
->enum(['local', 'public', 's3'])
->default('local'),
];
}
}
Nei tool che operano sui file esegui sempre la sanitizzazione del path e impedisci l’accesso al di fuori delle directory autorizzate. Rifiuta i path che contengono ...
Test
Server MCP, tool, risorse e prompt sono testabili con gli strumenti di test standard di Laravel.
Chiami il tool direttamente con il metodo Server::tool().
use App\Mcp\Servers\BlogServer;
use App\Mcp\Tools\SearchPostsTool;
use App\Models\Post;
use App\Models\User;
test('è possibile cercare articoli', function () {
Post::factory()->create(['title' => 'Basi di Laravel', 'status' => 'published']);
Post::factory()->create(['title' => 'PHP avanzato', 'status' => 'draft']);
$response = BlogServer::tool(SearchPostsTool::class, [
'keyword' => 'Laravel',
'status' => 'published',
]);
$response
->assertOk()
->assertSee('Basi di Laravel');
});
test('gli utenti senza permessi non possono creare articoli', function () {
$user = User::factory()->create();
$response = BlogServer::actingAs($user)
->tool(\App\Mcp\Tools\CreatePostTool::class, [
'title' => 'Articolo di test',
'body' => 'Corpo',
]);
$response->assertSee('Non hai il permesso di creare articoli');
});
use App\Mcp\Servers\BlogServer;
use App\Mcp\Tools\SearchPostsTool;
use App\Models\Post;
use App\Models\User;
public function test_can_search_posts(): void
{
Post::factory()->create(['title' => 'Basi di Laravel', 'status' => 'published']);
Post::factory()->create(['title' => 'PHP avanzato', 'status' => 'draft']);
$response = BlogServer::tool(SearchPostsTool::class, [
'keyword' => 'Laravel',
'status' => 'published',
]);
$response
->assertOk()
->assertSee('Basi di Laravel');
}
public function test_users_without_permission_cannot_create_posts(): void
{
$user = User::factory()->create();
$response = BlogServer::actingAs($user)
->tool(\App\Mcp\Tools\CreatePostTool::class, [
'title' => 'Articolo di test',
'body' => 'Corpo',
]);
$response->assertSee('Non hai il permesso di creare articoli');
}
Testare risorse e prompt
// test di una risorsa
$response = BlogServer::resource(\App\Mcp\Resources\AppDocumentationResource::class);
$response->assertOk()->assertSee('API');
// test di un prompt
$response = BlogServer::prompt(\App\Mcp\Prompts\DataAnalysisPrompt::class, [
'target' => 'vendite del mese scorso',
'format' => 'summary',
]);
$response->assertOk()->assertSee('data analyst');
Metodi di assertion principali
| Metodo | Descrizione |
|---|
assertOk() | La response non contiene errori |
assertSee($text) | La response contiene il testo indicato |
assertHasErrors() | La response contiene errori |
assertHasNoErrors() | La response non contiene errori |
assertName($name) | Verifica il nome del tool |
assertSentNotification($method, $data) | Verifica l’invio di una notifica |
assertNotificationCount($count) | Verifica il numero di notifiche inviate |
Debug con MCP Inspector
Per debug interattivo usa MCP Inspector.
# ispezione del server web
php artisan mcp:inspector mcp/database
# ispezione del server locale
php artisan mcp:inspector database
Considerazioni per il deploy
HTTP streaming e SSE
Se usi risposte in streaming (Generator) sul server web, verifica la configurazione del server.
location /mcp {
proxy_pass http://127.0.0.1:8000;
proxy_buffering off; # disabilita il buffering per SSE
proxy_cache off;
proxy_read_timeout 3600s; # supporto per connessioni lunghe
proxy_set_header Connection '';
chunked_transfer_encoding on;
}
# usando 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
Combinazione con Laravel Octane
Per server MCP ad alto traffico valuta Laravel Octane (FrankenPHP o Swoole). L’overhead per richiesta si riduce significativamente.
Con Octane lo stato viene condiviso tra le richieste. Fai attenzione a non usare proprietà statiche o stato globale nei tool.
Rate limit
Limita le richieste al server MCP con il middleware throttle.
// definisci un rate limiter dedicato in config/cache.php
// routes/ai.php
Mcp::web('/mcp/database', DatabaseServer::class)
->middleware(['auth:sanctum', 'throttle:60,1']);
Cache
Per tool di sola lettura invocati di frequente sfrutta la cache.
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);
}
Log e monitoraggio
Loggando le chiamate ai tool MCP tieni sotto controllo l’utilizzo dei client AI.
public function handle(Request $request): Response
{
\Illuminate\Support\Facades\Log::info('MCP tool called', [
'tool' => static::class,
'user' => $request->user()?->id,
'params' => $request->all(),
]);
// ...
}
In produzione ti consigliamo di monitorare performance ed eccezioni del server MCP con Laravel Telescope o Sentry.