Creare un server MCP in PHP senza alcuna dipendenza

Un server MCP conforme alla specifica 2026-07-28, in PHP 8 puro, senza Composer né framework. Scritto, eseguito e chiamato da Codex, con le risposte reali.

Creare un server MCP in PHP senza alcuna dipendenza
Risposta rapida

Un server MCP è un servizio che espone strumenti a un agente di IA in JSON-RPC 2.0, descritti da uno schema che il client legge a runtime con tools/list. In PHP, sta in un file di 380 righe: una rotta POST /mcp, un token bearer, e una funzione per strumento. Attenzione, la revisione 2026-07-28 elimina l'handshake initialize, ma Codex e MCP Inspector 2.5.0 lo usano ancora: il tuo server deve gestire entrambi.

Hai un’API interna, un catalogo prodotti o una base di log, e vorresti che Claude Code o Codex la usassero direttamente invece di chiederti copia e incolla. È il lavoro di un server MCP, e sta in un solo file PHP: quello di questo tutorial è di 380 righe, senza Composer, senza framework e senza altra dipendenza che PHP 8.

Che cos’è un server MCP?

Il Model Context Protocol è un protocollo aperto che standardizza il modo in cui un’applicazione di IA si collega a dati e strumenti esterni. La specifica distingue tre ruoli: gli host, applicazioni che avviano le connessioni. I client, connettori all’interno dell’host. I server, servizi che forniscono il contesto e le funzionalità. I messaggi sono in JSON-RPC 2.0.

Un server può offrire tre cose: risorse (contesto e dati), prompt (modelli di messaggio) e strumenti (funzioni che il modello esegue). Qui implementiamo solo gli strumenti, perché è quello che serve nella maggior parte dei casi e perché è la parte che si testa con curl.

MCP o API REST: cosa cambia davvero?

MCP non sostituisce REST, aggiunge uno strato di descrizione sopra. Ecco cosa cambia in concreto.

Aspetto API REST classica Server MCP
Scoperta Una documentazione da leggere, un OpenAPI da caricare tools/list restituisce gli schemi JSON di ogni strumento, a runtime
Chi chiama Codice che scrivi tu Il modello, che sceglie lo strumento in base alla sua descrizione
Formato Quello che vuoi JSON-RPC 2.0, imposto
Errori Codici HTTP Due famiglie: errori di protocollo ed errori di esecuzione dello strumento
Integrazione Un adattatore per client Un server, tutti gli agenti compatibili

L’ultimo punto è l’unico che conta davvero. Un server MCP scritto una volta si collega a Claude Code, a Codex, all’Inspector, e agli altri client dell’ecosistema senza una riga di adattamento. È la stessa promessa degli skill, di cui descriviamo il formato nella guida al file SKILL.md.

Perché il tuo server deve parlare due lingue

È la trappola di questo tutorial, ed è meglio metterla in chiaro subito. La revisione 2026-07-28 della specifica, pubblicata il 28 luglio 2026 da David Soria Parra e Den Delimarsky, ha eliminato l’handshake. Niente più initialize, niente più notifica notifications/initialized, niente più intestazione Mcp-Session-Id. Ogni richiesta trasporta ora la sua versione di protocollo e l’identità del client in un campo _meta, e il server può essere replicato senza stato condiviso.

Gli altri cambiamenti della stessa revisione toccano direttamente il trasporto HTTP:

  • il punto di ingresso accetta ormai solo POST, il flusso GET e il DELETE di fine sessione sono scomparsi,
  • un metodo server/discover sostituisce la scoperta, e i server devono implementarlo.
  • le intestazioni Mcp-Method e Mcp-Name ricopiano campi del corpo, perché un bilanciatore di carico instradi senza leggere il JSON.
  • le risposte di elenco trasportano ttlMs e cacheScope per essere messe in cache.

Sulla carta, basterebbe quindi scrivere un server 2026-07-28. Solo che ho registrato nel log il metodo di apertura e l’intestazione User-Agent di ogni client che si è connesso al mio server il 7 settembre 2026, ed ecco cosa ho visto.

Client User-Agent Apertura Versione annunciata
Codex codex-mcp-client/0.153.4 initialize 2025-06-18
MCP Inspector 2.5.0 node initialize 2025-11-25

Nessuno dei due ha inviato server/discover, e nessuno ha messo _meta nelle proprie richieste. Un server che parlasse solo la revisione di luglio sarebbe oggi inutilizzabile con questi client. La specifica ha previsto il caso: chiama dual-era un’implementazione che gestisce entrambe, e autorizza esplicitamente un server a servire le due epoche sullo stesso endpoint. È quello che faremo, e costa una decina di righe.

Lo scheletro: un file, una rotta

Il server dimostrativo si chiama regexlab ed espone due strumenti: regex_test, che testa un’espressione regolare su esempi, e blog_search, che interroga l’API REST pubblica di gekkode.com in sola lettura. Lo si avvia con il server web integrato di PHP.

bash
cd docker/articles/2026-09-07/lab/creer-serveur-mcp-php
MCP_TOKEN=demo-token-local php -S 127.0.0.1:8765 mcp.php

Iniziamo con due funzioni involucro. Tutte le uscite passano da loro, il che garantisce che nessuna risposta parta senza un codice HTTP esplicito.

php
<?php
declare(strict_types=1);

const SERVER_NAME = 'regexlab';
const SERVER_VERSION = '0.1.0';
const SUPPORTED_VERSIONS = ['2026-07-28', '2025-11-25', '2025-06-18'];
const ALLOWED_ORIGINS = ['http://127.0.0.1:8765', 'http://localhost:8765'];

function send(int $status, ?array $payload = null): never
{
    http_response_code($status);
    if ($payload === null) {
        exit;
    }
    header('Content-Type: application/json');
    echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n";
    exit;
}

function fail(int $status, int $code, string $message, mixed $id = null, ?array $data = null): never
{
    $error = ['code' => $code, 'message' => $message];
    if ($data !== null) {
        $error['data'] = $data;
    }
    send($status, ['jsonrpc' => '2.0', 'id' => $id, 'error' => $error]);
}

Viene poi la porta d’ingresso: metodo HTTP, percorso, origine, token. Quattro controlli, e i codici di ritorno imposti dalla specifica.

php
function header_value(string $name): ?string
{
    $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
    return isset($_SERVER[$key]) ? trim((string) $_SERVER[$key]) : null;
}

// GET e DELETE non fanno più parte della revisione 2026-07-28.
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    header('Allow: POST');
    send(405, ['jsonrpc' => '2.0', 'error' => ['code' => -32600, 'message' => 'Use POST on /mcp']]);
}

if (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) !== '/mcp') {
    fail(404, -32601, 'Unknown endpoint');
}

// Origin: difesa contro il DNS rebinding, 403 richiesto dalla specifica.
$origin = header_value('Origin');
if ($origin !== null && !in_array($origin, ALLOWED_ORIGINS, true)) {
    fail(403, -32600, 'Origin not allowed');
}

// Token bearer.
$expected = getenv('MCP_TOKEN') ?: '';
if ($expected !== '') {
    $sent = (string) preg_replace('/^Bearer\s+/i', '', header_value('Authorization') ?? '');
    if (!hash_equals($expected, $sent)) {
        header('WWW-Authenticate: Bearer realm="regexlab"');
        fail(401, -32001, 'Unauthorized');
    }
}

La validazione dell’intestazione Origin non è decorativa. La specifica la classifica come MUST e richiede un 403, proprio perché senza di essa un sito web aperto nel tuo browser può, tramite DNS rebinding, dialogare con il server MCP in esecuzione sulla tua macchina. Verifichiamo le tre porte:

bash
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8765/mcp
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8765/mcp \
  -H 'Origin: https://evil.example' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
curl -s -X POST http://127.0.0.1:8765/mcp -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: server/discover' \
  -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}'
json
405
403
{"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"Unauthorized"}}

Leggere la richiesta e individuare l’epoca

Il corpo è JSON-RPC. Ne escono tre informazioni: il metodo, i parametri, l’identificatore. La presenza di io.modelcontextprotocol/protocolVersion in _meta basta a sapere se il client è moderno.

php
$raw = file_get_contents('php://input') ?: '';
try {
    $req = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException) {
    fail(400, -32700, 'Parse error');
}
if (!is_array($req) || !isset($req['method'])) {
    fail(400, -32600, 'Invalid Request');
}

$method = (string) $req['method'];
$params = is_array($req['params'] ?? null) ? $req['params'] : [];
$id = $req['id'] ?? null;

$meta = is_array($params['_meta'] ?? null) ? $params['_meta'] : [];
$bodyVersion = $meta['io.modelcontextprotocol/protocolVersion'] ?? null;
$modern = is_string($bodyVersion);

error_log(sprintf(
    '[mcp] %s | version=%s | agent=%s',
    $method,
    is_string($bodyVersion) ? $bodyVersion : ($params['protocolVersion'] ?? '-'),
    $_SERVER['HTTP_USER_AGENT'] ?? '-'
));

// Una notifica non ha identificatore: si accusa ricezione e ci si ferma.
if (!array_key_exists('id', $req)) {
    send(202, null);
}

La chiamata a error_log qui sopra è ciò che mi ha permesso di stilare la tabella dei client più sopra. Il server integrato di PHP scrive sull’output di errore, quindi il log appare nel terminale che lo ha lanciato. Tienile per tutta la fase di sviluppo.

Il trattamento delle notifiche merita una parola. Una notifica JSON-RPC è un messaggio senza id: il client non aspetta risposta. La specifica è categorica, il server deve rispondere 202 Accepted senza corpo. È da lì che passa il notifications/initialized dei client legacy.

bash
curl -s -o /dev/null -w "status=%{http_code} octets=%{size_download}\n" \
  -X POST http://127.0.0.1:8765/mcp -H 'Authorization: Bearer demo-token-local' \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
bash
status=202 octets=0

Perché questo server restituisce solo JSON

Una parola sul formato di risposta, perché è una scelta e non un obbligo. Di fronte a una richiesta, il server può rispondere con un oggetto unico in Content-Type: application/json, oppure con un flusso SSE in text/event-stream, proprio di quella richiesta, che trasporta notifiche prima della risposta finale. Il client, dal canto suo, deve saper leggere entrambi: è per questo che invia sempre l’intestazione Accept: application/json, text/event-stream.

Questo server si limita al JSON. È la soluzione più semplice, e basta finché nessuno strumento ha bisogno di rendere conto del proprio avanzamento. Il flusso diventa necessario in due casi: uno strumento lungo che vuole emettere notifications/progress durante il suo lavoro, e la richiesta subscriptions/listen, la cui risposta resta aperta per portare i cambiamenti di elenco. Quando sarà il momento, la revisione 2026-07-28 fa della chiusura del flusso da parte del client il segnale di annullamento della richiesta, e raccomanda l’intestazione X-Accel-Buffering: no perché nginx non metta gli eventi in buffer.

Validare le intestazioni speculari

È la parte più specifica della revisione 2026-07-28, e quella che si dimentica. Quando un client moderno invia una richiesta, deve ricopiare tre valori del corpo in intestazioni: MCP-Protocol-Version, Mcp-Method, e Mcp-Name per un tools/call. Il server deve verificare che l’intestazione e il corpo coincidano, e rifiutare con 400 e il codice di errore -32020 in caso contrario.

La ragione è una falla classica: se un bilanciatore di carico decide l’instradamento in base all’intestazione mentre il server esegue in base al corpo, un client malintenzionato può far passare una chiamata per un’altra. La specifica chiama questo errore HeaderMismatch.

php
if ($modern) {
    $headerVersion = header_value('MCP-Protocol-Version');
    if ($headerVersion !== $bodyVersion) {
        fail(400, -32020, sprintf(
            'Header mismatch: MCP-Protocol-Version %s does not match body value %s',
            var_export($headerVersion, true),
            var_export($bodyVersion, true)
        ), $id);
    }
    if (header_value('Mcp-Method') !== $method) {
        fail(400, -32020, 'Header mismatch: Mcp-Method', $id);
    }
    if ($method === 'tools/call' && header_value('Mcp-Name') !== ($params['name'] ?? null)) {
        fail(400, -32020, 'Header mismatch: Mcp-Name', $id);
    }
    if (!in_array($bodyVersion, SUPPORTED_VERSIONS, true)) {
        fail(400, -32022, 'Unsupported protocol version', $id, [
            'supported' => SUPPORTED_VERSIONS,
            'requested' => $bodyVersion,
        ]);
    }
}

Un test che mente su Mcp-Name: l’intestazione annuncia blog_search, il corpo richiede regex_test.

bash
curl -s -X POST http://127.0.0.1:8765/mcp \
  -H 'Authorization: Bearer demo-token-local' -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/call' \
  -H 'Mcp-Name: blog_search' \
  -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"regex_test","arguments":{"pattern":"a","subjects":["a"]},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'
json
{"jsonrpc":"2.0","id":5,"error":{"code":-32020,"message":"Header mismatch: Mcp-Name"}}

E una versione che il server non conosce. La specifica impone di rispondere -32022 elencando le versioni accettate, perché il client possa riprovare senza intervento umano.

json
{
  "jsonrpc": "2.0",
  "id": 6,
  "error": {
    "code": -32022,
    "message": "Unsupported protocol version",
    "data": {
      "supported": ["2026-07-28", "2025-11-25", "2025-06-18"],
      "requested": "1900-01-01"
    }
  }
}

Rispondere alla scoperta, da entrambi i lati

Ecco cosa rende il server bi-epoca: un case per initialize, uno per server/discover, ed entrambi restituiscono le stesse funzionalità in due involucri diversi. Ecco la decina di righe che costa restare compatibili.

php
switch ($method) {
    // Handshake legacy (revisioni 2025-11-25 e precedenti).
    case 'initialize':
        $asked = (string) ($params['protocolVersion'] ?? '2025-11-25');
        ok($id, [
            'protocolVersion' => in_array($asked, SUPPORTED_VERSIONS, true) ? $asked : '2025-11-25',
            'capabilities' => ['tools' => ['listChanged' => false]],
            'serverInfo' => ['name' => SERVER_NAME, 'version' => SERVER_VERSION],
            'instructions' => 'regex_test teste une expression régulière ; blog_search interroge gekkode.com.',
        ]);

    // Scoperta senza stato (revisione 2026-07-28).
    case 'server/discover':
        ok($id, [
            'resultType' => 'complete',
            'supportedVersions' => SUPPORTED_VERSIONS,
            'capabilities' => ['tools' => ['listChanged' => false]],
            '_meta' => ['io.modelcontextprotocol/serverInfo' => [
                'name' => SERVER_NAME,
                'version' => SERVER_VERSION,
            ]],
            'instructions' => 'regex_test teste une expression régulière ; blog_search interroge gekkode.com.',
            'ttlMs' => 3600000,
            'cacheScope' => 'public',
        ]);

    default:
        fail($modern ? 404 : 200, -32601, 'Method not found: ' . $method, $id);
}

Due dettagli da non perdere. Il serverInfo si sposta: è alla radice del risultato in modalità legacy, e sotto _meta['io.modelcontextprotocol/serverInfo'] nella 2026-07-28. E il metodo sconosciuto non si tratta allo stesso modo: in modalità moderna, la specifica richiede un 404 Not Found accompagnato da un -32601, perché il corpo JSON-RPC è ciò che distingue questo caso dal 404 di un vecchio server che non ospita l’endpoint.

Ecco la risposta reale a server/discover:

bash
curl -s -X POST http://127.0.0.1:8765/mcp \
  -H 'Authorization: Bearer demo-token-local' -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: server/discover' \
  -d '{"jsonrpc":"2.0","id":"d1","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"8.7.1"},"io.modelcontextprotocol/clientCapabilities":{}}}}'
json
{"jsonrpc":"2.0","id":"d1","result":{"resultType":"complete","supportedVersions":["2026-07-28","2025-11-25","2025-06-18"],"capabilities":{"tools":{"listChanged":false}},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"regexlab","version":"0.1.0"}},"instructions":"regex_test teste une expression régulière ; blog_search interroge gekkode.com.","ttlMs":3600000,"cacheScope":"public"}}

E quella a initialize, così come la riceve Codex:

json
{
  "jsonrpc": "2.0",
  "id": 0,
  "result": {
    "protocolVersion": "2025-11-25",
    "capabilities": {"tools": {"listChanged": false}},
    "serverInfo": {"name": "regexlab", "version": "0.1.0"},
    "instructions": "regex_test teste une expression régulière ; blog_search interroge gekkode.com."
  }
}

Descrivere gli strumenti: tools/list

Uno strumento è un nome, una descrizione, e uno schema JSON di ingresso. La descrizione è ciò che il modello legge per decidere se chiamare lo strumento: scrivila per lui, non per uno sviluppatore. L’outputSchema è facoltativo ma raccomandato, permette al client di validare ciò che restituisci.

php
[
    'name' => 'regex_test',
    'title' => 'Testeur d\'expression régulière',
    'description' => 'Teste une expression régulière PCRE sur une liste de chaînes et retourne, '
        . 'pour chacune, si elle correspond et les groupes capturés.',
    'inputSchema' => [
        'type' => 'object',
        'properties' => [
            'pattern' => ['type' => 'string', 'description' => 'Motif PCRE, sans délimiteurs.'],
            'flags' => ['type' => 'string', 'description' => 'Modificateurs parmi i, m, s, x, u.'],
            'subjects' => [
                'type' => 'array',
                'items' => ['type' => 'string'],
                'description' => 'Chaînes à tester (20 au maximum).',
            ],
        ],
        'required' => ['pattern', 'subjects'],
        'additionalProperties' => false,
    ],
]

La risposta a tools/list aggiunge resultType, e i due campi di cache introdotti dalla revisione di luglio.

php
case 'tools/list':
    ok($id, [
        'resultType' => 'complete',
        'tools' => tool_definitions(),
        'ttlMs' => 300000,
        'cacheScope' => 'public',
    ]);

Tre regole di denominazione da rispettare: il nome di uno strumento deve stare tra 1 e 128 caratteri, limitarsi alle lettere ASCII, cifre, _, - e ., ed essere unico nel server. Gli spazi e le virgole sono vietati.

Eseguire uno strumento: tools/call e le sue due famiglie di errori

La qualità di un server MCP si gioca qui, e molti tutorial la mancano. La specifica distingue due meccanismi di propagazione degli errori, e confonderli costa andirivieni al modello:

  • un errore di protocollo (strumento sconosciuto, richiesta malformata) è un errore JSON-RPC classico, il modello ha poche probabilità di cavarsela da solo,
  • un errore di esecuzione (argomento fuori intervallo, API fuori servizio, data non valida) viene restituito in un risultato normale con isError: true, e il client deve darlo al modello perché si corregga.

Il tester di espressioni regolari è un caso da manuale: un pattern scritto male deve tornare al modello con il messaggio di PCRE, non con un «errore 500».

php
function run_regex_test(array $args): array
{
    $pattern = (string) ($args['pattern'] ?? '');
    $flags = (string) ($args['flags'] ?? '');
    $subjects = is_array($args['subjects'] ?? null) ? array_values($args['subjects']) : [];

    if ($pattern === '' || strlen($pattern) > 512) {
        return tool_error('Le motif doit faire entre 1 et 512 caractères.');
    }
    if ($flags !== '' && !preg_match('/^[imsxu]{1,5}$/', $flags)) {
        return tool_error('Modificateurs acceptés : i, m, s, x, u.');
    }
    if ($subjects === [] || count($subjects) > 20) {
        return tool_error('Fournissez entre 1 et 20 chaînes à tester.');
    }

    // Si scappano i delimitatori non protetti invece di accettare un pattern già delimitato.
    $delimited = '/' . preg_replace('~(?<!\\\\)/~', '\\/', $pattern) . '/' . $flags;
    $compileError = null;
    set_error_handler(static function (int $no, string $msg) use (&$compileError): bool {
        $compileError = $msg;
        return true;
    });
    $compiles = preg_match($delimited, '');
    restore_error_handler();
    if ($compiles === false) {
        // Il messaggio di PCRE dice dove si rompe il pattern: il modello può correggersi da solo.
        return tool_error('Motif invalide : ' . ($compileError ?? preg_last_error_msg()));
    }
    // …
}

Due precauzioni in queste poche righe. Non si accetta mai un pattern già delimitato, si aggiungono da soli i delimitatori scappando gli / non protetti. Accettare /pattern/flags così com’è equivarrebbe a lasciare che il chiamante scelga i modificatori, cosa che la validazione a monte vieta. E il gestore di errori temporaneo recupera il messaggio esatto di PCRE, laddove preg_last_error_msg() si accontenta di un laconico «Internal error».

La differenza si vede alla chiamata. Pattern con una parentesi mancante:

json
{
  "jsonrpc": "2.0",
  "id": 8,
  "result": {
    "resultType": "complete",
    "content": [
      {
        "type": "text",
        "text": "Motif invalide : preg_match(): Compilation failed: missing closing parenthesis at offset 7"
      }
    ],
    "isError": true
  }
}

Un modello che riceve questo testo richiude la parentesi e richiama lo strumento. Se avesse ricevuto un errore JSON-RPC, avrebbe avuto molte meno probabilità di cavarsela: la specifica nota che gli errori di protocollo portano raramente a una correzione.

Resta un rischio proprio delle espressioni regolari: l’esplosione combinatoria. Un pattern come (a+)+$ su una stringa di trentasei «a» seguite da una «b» occupa il processore per moltissimo tempo. Il rimedio sta in una riga in testa al file, ini_set('pcre.backtrack_limit', '200000');, e in un controllo del valore restituito da preg_match.

json
{
  "jsonrpc": "2.0",
  "id": 9,
  "result": {
    "resultType": "complete",
    "content": [{"type": "text", "text": "Échec du moteur PCRE : Backtrack limit exhausted"}],
    "isError": true
  }
}

Una chiamata che riesce, ora, con il suo structuredContent conforme all’outputSchema dichiarato:

bash
curl -s -X POST http://127.0.0.1:8765/mcp \
  -H 'Authorization: Bearer demo-token-local' -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/call' \
  -H 'Mcp-Name: regex_test' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"regex_test","arguments":{"pattern":"^([A-Z]{2})-(\\d{4})$","subjects":["FR-2026","fr-2026","XX-12"]},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'
json
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "resultType": "complete",
    "content": [
      {
        "type": "text",
        "text": "1 correspondance(s) sur 3 chaîne(s).\nOUI FR-2026 -> FR | 2026\nNON fr-2026\nNON XX-12"
      }
    ],
    "structuredContent": {
      "pattern": "^([A-Z]{2})-(\\d{4})$",
      "matches": 1,
      "results": [
        {"subject": "FR-2026", "matched": true, "groups": ["FR", "2026"]},
        {"subject": "fr-2026", "matched": false, "groups": []},
        {"subject": "XX-12", "matched": false, "groups": []}
      ]
    },
    "isError": false
  }
}

Nota che il testo leggibile è presente in aggiunta al contenuto strutturato. La specifica lo raccomanda esplicitamente: uno strumento che restituisce dati strutturati dovrebbe anche fornire la versione serializzata in un blocco di testo, per i client che leggono solo content.

Il secondo strumento: interrogare un’API in sola lettura

blog_search mostra il caso più comune in azienda: esporre un servizio esistente. L’host è hardcoded, il metodo è un GET, nessun parametro del chiamante costruisce un URL arbitrario.

php
function run_blog_search(array $args): array
{
    $query = trim((string) ($args['query'] ?? ''));
    $limit = max(1, min(5, (int) ($args['limit'] ?? 3)));
    if ($query === '' || mb_strlen($query) > 120) {
        return tool_error('La requête doit faire entre 1 et 120 caractères.');
    }

    $url = BLOG_API . '?' . http_build_query([
        'search' => $query,
        'per_page' => $limit,
        'orderby' => 'relevance',
        '_fields' => 'id,date,link,title',
    ]);
    $context = stream_context_create(['http' => [
        'method' => 'GET',
        'timeout' => 8,
        'header' => "Accept: application/json\r\nUser-Agent: regexlab-mcp/0.1\r\n",
        'ignore_errors' => true,
    ]]);
    $body = @file_get_contents($url, false, $context);
    if ($body === false) {
        return tool_error('API du blog injoignable.');
    }
    // …
}

Il _fields dell’API REST di WordPress fa molto lavoro: riduce la risposta ai quattro campi utili, quindi i token fatturati al passaggio nel contesto del modello. È lo stesso riflesso descritto nel nostro articolo sulla riduzione dei token, applicato al server invece che al client. Risultato della chiamata:

bash
2024-08-11 — Les 10 meilleurs packages Laravel pour créer votre site web
Les 10 meilleurs packages Laravel pour créer votre site web
2023-02-03 — Comment installer Redis sur Debian et Laravel https://www.gekkode.com/developpement/comment-installer-redis-sur-debian-et-laravel/

Collegare il server a Codex

Codex legge i suoi server MCP in ~/.codex/config.toml, ma l’opzione -c permette di dichiararli per una sola esecuzione, senza scrivere nulla su disco. Ideale per un test, ed è per questa via che il server è stato verificato.

bash
export REGEXLAB_TOKEN=demo-token-local

codex exec \
  -c 'mcp_servers.regexlab.url="http://127.0.0.1:8765/mcp"' \
  -c 'mcp_servers.regexlab.bearer_token_env_var="REGEXLAB_TOKEN"' \
  -c 'mcp_servers.regexlab.default_tools_approval_mode="approve"' \
  -m gpt-6-astra --skip-git-repo-check \
  "Utilise UNIQUEMENT l'outil MCP regexlab.regex_test. Teste le motif ^([A-Z]{2})-(\d{4})\$ sur les chaînes FR-2026, fr-2026 et XX-12."
bash
codex
Je vais tester ce motif sur les trois chaînes avec l'outil demandé.

mcp: regexlab/regex_test started
mcp: regexlab/regex_test (completed)
codex
Il y a 1 correspondance sur 3 : « FR-2026 », avec les groupes capturés « FR » et « 2026 ».
tokens used
9 342

Il terzo -c è quello che mi è costato due tentativi. Senza di esso, Codex si connette al server, elenca gli strumenti, e rifiuta la chiamata con un messaggio senza ambiguità:

bash
mcp: regexlab/regex_test started
mcp: regexlab/regex_test (failed)
MCP tool call requires approval, but approval policy is never

In modalità exec, la politica di approvazione vale never e nessun essere umano è lì per validare. La chiave default_tools_approval_mode accetta quattro valori, auto, prompt, writes e approve, solo l’ultima lascia passare la chiamata senza intervento. Da riservare ai server che hai scritto tu stesso, per le ragioni dettagliate nel nostro articolo sulle sandbox e i permessi.

Per un’installazione duratura, il comando ufficiale scrive la stessa cosa nella configurazione:

bash
codex mcp add regexlab --url http://127.0.0.1:8765/mcp \
  --bearer-token-env-var REGEXLAB_TOKEN
codex mcp list
codex mcp get regexlab
codex mcp remove regexlab

Collegare il server a Claude Code

Sul lato Claude Code, l’aggiunta di un server HTTP sta in un comando, e l’intestazione di autorizzazione si passa con --header.

bash
claude mcp add --transport http regexlab http://127.0.0.1:8765/mcp \
  --header "Authorization: Bearer demo-token-local"
claude mcp list
claude mcp get regexlab

Per condividere il server con il team, l’ambito project scrive un file .mcp.json alla radice del repository, versionabile. La documentazione accetta l’espansione di variabili d’ambiente, il che evita di committare il token:

json
{
  "mcpServers": {
    "regexlab": {
      "type": "http",
      "url": "http://127.0.0.1:8765/mcp",
      "headers": {
        "Authorization": "Bearer ${REGEXLAB_TOKEN}"
      }
    }
  }
}

La sintassi ${VAR} e ${VAR:-valore predefinito} è riconosciuta nei campi url, headers, command, args e env. Dalla sessione, /mcp mostra lo stato dei server e permette di connettersi a quelli che richiedono OAuth.

Verificare con MCP Inspector, senza installare nulla

Lo strumento di test ufficiale si usa in modalità grafica, ma la sua modalità --cli è molto più pratica per un test rapido e si avvia con npx, quindi senza installazione globale.

bash
npx -y @modelcontextprotocol/inspector@2.5.0 --cli http://127.0.0.1:8765/mcp \
  --transport http --header "Authorization: Bearer demo-token-local" \
  --method tools/call --tool-name regex_test \
  --tool-arg 'pattern=^\d{5}$' --tool-arg 'subjects=["62500","6250"]'
json
{
  "content": [
    {"type": "text", "text": "1 correspondance(s) sur 2 chaîne(s).\nOUI 62500\nNON 6250"}
  ],
  "structuredContent": {
    "pattern": "^\\d{5}$",
    "matches": 1,
    "results": [
      {"subject": "62500", "matched": true, "groups": []},
      {"subject": "6250", "matched": false, "groups": []}
    ]
  },
  "isError": false
}

L’Inspector 2.5.0, pubblicato il 2 settembre 2026, apre anch’esso con un initialize, annunciando la versione 2025-11-25. Resta lo strumento più rapido per vedere cosa restituisce davvero il tuo server, prima di collegarci un agente.

Cosa non sa fare il server integrato di PHP

php -S gestisce una richiesta alla volta. Finché i tuoi strumenti rispondono in pochi millisecondi, non si nota. Non appena uno strumento chiama la rete, l’intero server si blocca. Ho lanciato due chiamate in parallelo, blog_search che interroga l’API del blog e poi regex_test che fa solo calcolo locale:

Configurazione blog_search regex_test lanciato 50 ms dopo
php -S predefinito 0,601 s 0,548 s
PHP_CLI_SERVER_WORKERS=4 0,578 s 0,002 s

Senza processi supplementari, la chiamata rapida aspetta pazientemente la fine di quella lenta: 0,548 s invece dei 13 ms che impiega da sola. Con quattro worker, risponde immediatamente. È sufficiente per sviluppare, non sostituisce PHP-FPM dietro nginx in produzione.

Passare in produzione: l’SDK PHP ufficiale e Laravel MCP

Scrivere il proprio server a mano è il modo giusto per capire il protocollo. Per codice che vive in produzione, due pacchetti meritano una deviazione, ed entrambi si sono mossi quest’estate.

L’SDK PHP ufficiale si installa con composer require mcp/sdk. Si presenta come l’SDK ufficiale del protocollo per PHP, mantenuto in collaborazione con la PHP Foundation e riprendendo le pratiche del progetto Symfony. È agnostico rispetto al framework, richiede PHP 8.1 come minimo, e il suo repository annuncia il supporto delle due epoche di protocollo, l’handshake initialize e la revisione senza stato 2026-07-28. Ultima versione al 7 settembre 2026: la v0.8.1, pubblicata il 29 agosto.

Laravel MCP (composer require laravel/mcp) punta all’altro estremo dello spettro: dichiari i tuoi server in routes/ai.php, con Mcp::web('/mcp/weather', WeatherServer::class) per un server HTTP o Mcp::local('weather', …) per un comando Artisan, e ogni strumento diventa una classe che estende Tool con un metodo handle() e uno schema(). Il middleware Laravel si applica così com’è, incluso throttle. La prima versione stabile è in preparazione: la v1.0.0-beta.1 è del 14 agosto 2026 e richiede PHP 8.2 con Laravel 11.45.3, 12.41.1 o 13.

Se il tuo bisogno è WordPress o PrestaShop piuttosto che un server generico, due articoli di questa serie trattano il caso: MCP per WordPress e MCP per PrestaShop.

Cinque righe di sicurezza che non costano nulla

Un server MCP è una superficie di esecuzione di codice accessibile da un modello. La specifica dedica una pagina intera all’argomento, ricorda come minimo queste regole, tutte rispettate dal file di questo tutorial.

  • Ascolta su 127.0.0.1, mai su 0.0.0.0, per un server locale. La specifica lo classifica come SHOULD.
  • Valida l’intestazione Origin e rispondi 403 quando non compare nella tua lista. Senza questo, una pagina web aperta nel tuo browser può parlare con il tuo server.
  • Richiedi un token, confrontato con hash_equals() per non far trapelare informazioni tramite il tempo di confronto.
  • Resta in sola lettura finché non hai bisogno di scrivere, e limita tutto: lunghezza delle stringhe, numero di elementi, timeout di rete, limite di backtracking.
  • Fissa l’host nel codice negli strumenti che chiamano la rete. Un parametro URL libero trasforma il tuo server in un relè per l’esfiltrazione.

Quest’ultimo punto non è teorico. Gli incidenti pubblici dell’ecosistema MCP censiti finora riguardano quasi tutti server che facevano più di quanto dichiarato: un pacchetto pubblicato su npm che ricopiava di passaggio le e-mail che avrebbe dovuto inviare, un relè la cui falla apriva l’esecuzione di comandi. Uno strumento incapace di fare altro che quello che dice la sua descrizione è uno strumento che si può auditare. La questione del perimetro di un agente e dei suoi strumenti è trattata in dettaglio nell’articolo sulle sandbox e i permessi.

Cosa ricordare

  • Un server MCP utile sta in un file PHP di 380 righe: una rotta POST /mcp, JSON-RPC 2.0, e due funzioni per strumento.
  • La revisione 2026-07-28 elimina initialize, le sessioni e il flusso GET, ma Codex e l’Inspector aprono ancora con l’handshake legacy: scrivi un server che gestisce entrambe le epoche.
  • In modalità moderna, ricopia e verifica MCP-Protocol-Version, Mcp-Method e Mcp-Name, con -32020 in caso di discrepanza e -32022 per una versione sconosciuta.
  • Restituisci gli errori applicativi nel risultato con isError: true, non come errore JSON-RPC: è ciò che permette al modello di correggersi da solo.
  • Testa con curl, poi con npx @modelcontextprotocol/inspector --cli, prima di collegare un agente.
  • php -S serve solo allo sviluppo: una chiamata di rete lenta blocca tutto il server finché PHP_CLI_SERVER_WORKERS non è definito.
  • Per la produzione, parti dall’SDK ufficiale mcp/sdk o da laravel/mcp piuttosto che mantenere il tuo strato di trasporto.

Errori frequenti

Scrivere un server che parla solo la revisione 2026-07-28 I client testati il 7 settembre 2026 aprono ancora con initialize. Tieni il case initialize accanto a server/discover: la specifica autorizza un server a servire le due epoche sullo stesso endpoint.
Dimenticare di validare le intestazioni speculari Nella 2026-07-28, MCP-Protocol-Version, Mcp-Method e Mcp-Name devono corrispondere al corpo. Una discrepanza si rifiuta con 400 e il codice -32020, altrimenti un intermediario e il server possono leggere due cose diverse.
Rispondere a una notifica Un messaggio senza id non aspetta risposta. Restituisci 202 Accepted senza corpo, altrimenti i client legacy si bloccano su notifications/initialized.
Propagare un errore applicativo come errore JSON-RPC Un argomento non valido si restituisce in un risultato con isError: true e un messaggio utilizzabile. Un errore di protocollo fa abbandonare il modello, un errore di esecuzione lo fa correggere.
Chiamare lo strumento da codex exec senza impostare l'approvazione La politica vale never e Codex rifiuta con «MCP tool call requires approval». Aggiungi -c 'mcp_servers.NOM.default_tools_approval_mode="approve"', e solo per un server che hai scritto tu.

Claude CodeCodexLaravelMCPPHP

Damien Flandrin Sviluppatore web dal 2010, creatore di Gekkode e di Email Impact. Ogni articolo è testato su un progetto reale prima della pubblicazione. Contatti
Newsletter

I nuovi test, tutorial e progetti, via e-mail.

Test riproducibili, codice versionato, risultati datati. Mai spam.