Model Context Protocol (MCP): Il Livello di Standardizzazione Mancante per l'Integrazione AI-Strumenti
Architettura del Protocollo MCP
Il Model Context Protocol è un protocollo basato su JSON-RPC 2.0 che definisce come le applicazioni AI (client) comunicano con capacità esterne (server). Questa è la versione in una frase. La realtà è più sfumata.
Il protocollo definisce tre tipi di primitive che un server può esporre:
- Resources — Dati in sola lettura che il client può portare nel contesto (pensa a file, record di database, risposte API)
- Tools — Funzioni eseguibili che il modello può invocare con parametri
- Prompts — Template di prompt riutilizzabili con argomenti
La maggior parte delle persone si interessa solo ai tool. È un errore iniziale comune. I resource sono probabilmente più importanti perché ti permettono di alimentare contesto al modello senza bruciare un round trip di tool-call.
Ecco come appare la negoziazione delle capability a livello di protocollo:
// Client -> Server (initialize request)
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {
"roots": { "listChanged": true }
},
"clientInfo": {
"name": "il-mio-agente",
"version": "1.0.0"
}
}
}
// Server -> Client (initialize response)
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-03-26",
"capabilities": {
"tools": { "listChanged": true },
"resources": { "subscribe": true },
"prompts": { "listChanged": true }
},
"serverInfo": {
"name": "postgres-mcp",
"version": "0.3.1"
}
}
}
Nota il flag di capability listChanged. Questo è uno di quei dettagli di cui nessuno parla — significa che il server può notificare il client quando la sua lista di tool cambia a runtime. Critico per ambienti dinamici dove gli strumenti vanno e vengono.
Il Livello di Trasporto — Dove le Cose Si Fanno Interessanti
MCP definisce due trasporti ufficiali, e capire quando usare ciascuno è critico per evitare incidenti in produzione.
Trasporto stdio
L'opzione più semplice. Il client avvia il server come processo figlio e comunica tramite stdin/stdout. Ogni messaggio JSON-RPC è una singola riga terminata da \n.
import { spawn } from 'child_process';
const server = spawn('node', ['./il-mio-mcp-server.js'], {
stdio: ['pipe', 'pipe', 'pipe'] // stdin, stdout, stderr
});
// CRITICO: stderr NON fa parte del protocollo
// Se il tuo server fa console.log(), rompe il flusso JSON-RPC
server.stdout.on('data', (chunk) => {
const lines = chunk.toString().split('\n').filter(Boolean);
for (const line of lines) {
const message = JSON.parse(line); // Qui esplode
handleMessage(message); // se loggi su stdout
}
});
Un errore frequente: un console.log('Server started') nell'inizializzazione del server che si attiva solo in produzione per una differenza nel percorso di caricamento della versione di Node.js. Il parser JSON si strozza su Server started\n{"jsonrpc":"2.0"... e il messaggio di errore è completamente inutile: "Unexpected token S in JSON at position 0". Questo può consumare ore di debugging.
Trasporto Streamable HTTP
Questo è quello che vuoi per deployment in produzione. Il client comunica via HTTP POST a un singolo endpoint, e il server può opzionalmente fare upgrade a Server-Sent Events per risposte in streaming.
import express from 'express';
const app = express();
app.use(express.json());
app.post('/mcp', async (req, res) => {
const message = req.body;
const acceptsSSE = req.headers.accept?.includes('text/event-stream');
if (acceptsSSE && isLongRunningTool(message)) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Mcp-Session-Id': sessionId
});
await streamToolExecution(message, res);
res.end();
} else {
const result = await handleMessage(message);
res.json(result);
}
});
L'header Mcp-Session-Id è come il server traccia le sessioni client. Senza di esso, non puoi implementare tool stateful o sottoscrizioni a resource attraverso multiple richieste HTTP.
Costruire un Server MCP Reale
Di seguito un server MCP di produzione — uno strumento di query PostgreSQL che espone le tabelle del database come resource e query di lettura sicure come tool.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import pg from 'pg';
import { z } from 'zod';
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 5,
idleTimeoutMillis: 30000
});
const server = new McpServer({
name: 'postgres-readonly',
version: '1.0.0'
});
server.resource(
'table-schema',
'schema://tables/{tableName}',
async (uri, { tableName }) => {
const result = await pool.query(`
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = $1
ORDER BY ordinal_position
`, [tableName]);
return {
contents: [{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(result.rows, null, 2)
}]
};
}
);
server.tool(
'query',
'Eseguire una query SQL in sola lettura sul database',
{
sql: z.string().describe('Query SQL SELECT da eseguire'),
params: z.array(z.unknown()).optional()
.describe('Valori parametrizzati della query')
},
async ({ sql, params }) => {
const normalized = sql.trim().toLowerCase();
const forbidden = ['insert', 'update', 'delete', 'drop', 'alter',
'create', 'truncate', 'grant', 'revoke'];
for (const keyword of forbidden) {
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
if (regex.test(normalized)) {
return {
content: [{
type: 'text',
text: `Rifiutato: la query contiene keyword vietato "${keyword}"`
}],
isError: true
};
}
}
const client = await pool.connect();
try {
await client.query('SET TRANSACTION READ ONLY');
await client.query('BEGIN');
const result = await client.query(sql, params || []);
await client.query('COMMIT');
return {
content: [{
type: 'text',
text: JSON.stringify({
rowCount: result.rowCount,
rows: result.rows.slice(0, 100)
}, null, 2)
}]
};
} catch (err) {
await client.query('ROLLBACK');
return {
content: [{ type: 'text', text: `Errore query: ${err.message}` }],
isError: true
};
} finally {
client.release();
}
}
);
server.prompt(
'analyze-table',
'Generare query di analisi per una tabella specifica',
{ tableName: z.string() },
({ tableName }) => ({
messages: [{
role: 'user',
content: {
type: 'text',
text: `Analizza la tabella "${tableName}". Prima leggi il suo schema usando il resource table-schema, poi esegui query per capire la distribuzione dei dati, i tassi di null, e identificare anomalie. Concentrati su insight azionabili.`
}
}]
})
);
const transport = new StdioServerTransport();
await server.connect(transport);
Le Primitive in Pratica
Resources vs Tools — Il Framework Decisionale
Un errore comune è esporre tutto come tool. Ecco un modello mentale utile:
- Resource: Dati che il modello ha bisogno di leggere per prendere decisioni. Pull-based. Il modello decide quando fare fetch.
- Tool: Un'azione con side effect o computazione. Chiamato esplicitamente dal modello con parametri specifici.
Se esponi una tabella di configurazione da 200 righe come tool call che restituisce JSON, il modello deve invocarlo, aspettare la risposta, e poi continuare a ragionare. Se lo esponi come resource, il client può fare prefetch nel contesto prima che il modello inizi a generare.
Template di Prompt — La Primitiva Sottoutilizzata
I prompt in MCP non sono solo testo statico. Supportano argomenti e possono referenziare resource:
server.prompt(
'incident-response',
'Guidare attraverso investigazione incidenti',
{
severity: z.enum(['p1', 'p2', 'p3']),
service: z.string()
},
({ severity, service }) => ({
messages: [{
role: 'user',
content: {
type: 'text',
text: `Risposta incidente per ${severity.toUpperCase()} su ${service}.\n` +
`1. Interroga i log di errore recenti usando il tool dei log\n` +
`2. Controlla lo storico dei deploy via il resource dei deploy\n` +
`3. Valuta il blast radius e suggerisci mitigazione`
}
}]
})
);
Questo è enormemente utile per standardizzare il comportamento degli agenti nel tuo team. Invece di avere tutti che scrivono system prompt leggermente diversi, definisci template di prompt lato server e sono scopribili attraverso il protocollo.
Gestione Errori Robusta
La spec definisce codici di errore JSON-RPC standard, ma aggiunge anche codici specifici MCP. Ecco un pattern raccomandato:
server.tool('deploy', 'Deployare un servizio', { /* ... */ }, async (args) => {
try {
const result = await deployService(args);
return {
content: [{ type: 'text', text: JSON.stringify(result) }]
};
} catch (err) {
if (err.code === 'RATE_LIMITED') {
return {
content: [{
type: 'text',
text: `Rate limited. Riprova dopo ${err.retryAfter}s`
}],
isError: true
};
}
throw err;
}
});
La distinzione tra isError: true in un risultato tool vs lanciare un errore è sottile ma importante. Gli errori tool sono "fallimenti attesi" — il modello può ragionarci sopra. Gli errori lanciati sono fallimenti a livello di protocollo che tipicamente abortiscono l'interazione corrente.
Gestione Sessioni e Statefulness
Un dettaglio importante: le sessioni MCP hanno stato. L'header Mcp-Session-Id non è solo per il logging — è come il server correla le sottoscrizioni ai resource, traccia quali tool sono stati listati, e gestisce il cleanup.
const sessions = new Map();
function getOrCreateSession(sessionId) {
if (!sessions.has(sessionId)) {
sessions.set(sessionId, {
id: sessionId,
subscriptions: new Set(),
createdAt: Date.now(),
lastActivity: Date.now()
});
}
const session = sessions.get(sessionId);
session.lastActivity = Date.now();
return session;
}
// Pulire sessioni stantie ogni 5 minuti
setInterval(() => {
const cutoff = Date.now() - 30 * 60 * 1000;
for (const [id, session] of sessions) {
if (session.lastActivity < cutoff) {
session.subscriptions.forEach(sub => sub.unsubscribe());
sessions.delete(id);
}
}
}, 5 * 60 * 1000);
Problemi del Mondo Reale
La qualità della descrizione del tool conta più di quanto pensi. Il modello usa le descrizioni dei tool per decidere quando chiamare un tool. Un tool descritto come "Cerca nel database" viene chiamato per ogni domanda, anche quando una semplice lettura di resource basta. Cambiandolo in "Esegui query SQL SELECT per filtraggio complesso, aggregazione o join che non possono essere risolti leggendo direttamente i resource delle tabelle" le chiamate inutili calano del 60%.
Lo JSON Schema per i parametri dei tool deve essere preciso. Non usare z.any() o z.record(z.unknown()). Più ristretto è il tuo schema, meglio il modello compila i parametri.
Metti timeout alle esecuzioni dei tuoi tool. MCP non definisce un meccanismo di timeout a livello di protocollo — è responsabilità del server:
function withTimeout(fn, ms = 30000) {
return async (...args) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fn(...args, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
};
}
Dove Va Tutto Questo
MCP è ancora giovane, ma la traiettoria è chiara. Il protocollo ha già il buy-in dei grandi vendor di IDE, framework di agenti e provider di modelli. La spec si sta muovendo verso OAuth 2.1 per l'autenticazione, il che renderà viabili i server MCP multi-tenant per prodotti SaaS.
L'aspetto più promettente è il pattern di notifica listChanged. Immagina un server MCP che espone tool dinamicamente basandosi sui permessi dell'utente, il contesto del progetto corrente, o anche la competenza dimostrata dal modello. Il protocollo supporta questo oggi — la maggior parte delle implementazioni semplicemente non ha ancora raggiunto questo livello.
Per qualsiasi tipo di sistema di agenti AI, costruire server MCP è preferibile a scrivere integrazioni custom. L'investimento si ripaga quando il cliente decide di cambiare modello per la terza volta.