Tech
AI

🛠️ Costruire un Server MCP da Zero

02/08/2025

6 min read

Nel panorama in rapida evoluzione dei protocolli di comunicazione per sistemi AI, il Model Context Protocol (MCP) emerge come un paradigma interessante per l'integrazione tra modelli linguistici e servizi esterni. Ma cosa significa realmente costruire un server MCP funzionale? Un'analisi tecnica attraverso la creazione di un prototipo rivela prospettive illuminanti.

Il Contesto: Oltre le API Tradizionali

L'MCP rappresenta una risposta architettonica a una sfida specifica: come permettere ai modelli AI di accedere a dati e funzionalità esterne in modo strutturato e sicuro? A differenza delle classiche REST API, l'MCP introduce concetti di tools e resources che si adattano meglio al paradigma conversazionale dell'AI moderna.

La nostra implementazione esplora questo concetto attraverso un caso d'uso apparentemente semplice: un menu di bevande digitale. Sotto la superficie, però, emergono pattern architetturali che riflettono sfide più ampie dello sviluppo moderno.

Architettura del Sistema

typescript
1// src/server.ts - Il cuore del sistema 2import { stdin as input, stdout as output } from 'node:process'; 3import * as readline from 'node:readline'; 4import { resources } from './handlers/resources.js'; 5import { tools } from './handlers/tools.js'; 6import { sendResponse } from './utils/index.js';

Passo 1: Configurazione Base e Event Loop

typescript
1const serverInfo = { 2 name: 'My Custom MCP Server', 3 version: '1.0.0', 4}; 5 6const rl = readline.createInterface({ 7 input: input, 8 output: output, 9}); 10 11(async function main() { 12 for await (const line of rl) { 13 try { 14 const json = JSON.parse(line); 15 // Gestione delle richieste JSON-RPC 16 } catch (error) { 17 console.error(error); 18 } 19 } 20})();

L'event loop rappresenta il nucleo operativo del server. La scelta di un'architettura single-threaded con async/await riflette le migliori pratiche Node.js, offrendo performance ottimali per operazioni I/O-intensive tipiche di questo dominio.

Passo 2: Il Protocollo di Inizializzazione

typescript
1if (json.jsonrpc === '2.0' && json.method === 'initialize') { 2 sendResponse(json.id, { 3 protocolVersion: '2025-03-26', 4 capabilities: { 5 tools: { listChanged: true }, 6 resources: { listChanged: true }, 7 }, 8 serverInfo, 9 }); 10}

Il handshake iniziale stabilisce le capabilities del server. Questo pattern di negoziazione delle funzionalità è cruciale per la compatibilità forward/backward e rappresenta un'evoluzione rispetto ai protocolli più rigidi del passato.

Passo 3: Gestione dei Tools - Il Paradigma Funzionale

typescript
1// src/handlers/tools.ts 2import { drinks } from '../__mocks__/drinks.js'; 3 4export const tools = [ 5 { 6 name: 'getDrinkNames', 7 description: 'Get the names of the drinks in the shop', 8 inputSchema: { type: 'object', properties: {} }, 9 execute: async (args: any) => { 10 return { 11 content: [ 12 { 13 type: 'text', 14 text: JSON.stringify({ names: drinks.map((drink) => drink.name) }), 15 }, 16 ], 17 }; 18 }, 19 }, 20 { 21 name: 'getDrinkInfo', 22 description: 'Get more info about the drink', 23 inputSchema: { 24 type: 'object', 25 properties: { 26 name: { type: 'string' }, 27 }, 28 required: ['name'], 29 }, 30 execute: async (args: any) => { 31 const drink = drinks.find((drink) => drink.name === args.name); 32 return { 33 content: [ 34 { 35 type: 'text', 36 text: JSON.stringify(drink || { error: 'Drink not found' }), 37 }, 38 ], 39 }; 40 }, 41 }, 42];

I tools rappresentano funzioni pure che l'AI può invocare. L'approccio dichiarativo con schema di validazione (inputSchema) anticipa errori e facilita l'introspezione del sistema. È interessante notare come questo pattern rispecchi trend più ampi verso architetture function-as-a-service.

Passo 4: Resources - Il Paradigma dei Dati

typescript
1// src/handlers/resources.ts 2export const resources = [ 3 { 4 uri: 'menu://app', 5 name: 'menu', 6 get: async () => { 7 return { 8 contents: [ 9 { 10 uri: 'menu://app', 11 text: JSON.stringify(drinks), 12 }, 13 ], 14 }; 15 }, 16 }, 17];

Le resources introducono un concetto di URI-based data access che ricorda i principi REST ma con semantica specifica per il contesto AI. Questo approccio permette versioning e namespacing dei dati in modo elegante.

Passo 5: La Gestione delle Richieste

typescript
1if (json.method === 'tools/call') { 2 const tool = tools.find((t) => t.name === json.params.name); 3 if (tool) { 4 const result = await tool.execute(json.params.arguments); 5 sendResponse(json.id, result); 6 } else { 7 console.error(`Tool not found: ${json.params.name}`); 8 sendResponse(json.id, { error: `Tool not found: ${json.params.name}` }); 9 } 10} 11 12if (json.method === 'resources/read') { 13 const uri = json.params.uri; 14 const resource = resources.find((resource) => resource.uri === uri); 15 if (resource) { 16 sendResponse(json.id, await resource.get()); 17 } else { 18 sendResponse(json.id, { 19 error: { code: -32602, message: 'Resource not found' }, 20 }); 21 } 22}

Il pattern di routing su method matching offre chiarezza e performance. La gestione degli errori segue lo standard JSON-RPC, garantendo interoperabilità.

Passo 6: Utilities e Response Formatting

typescript
1// src/utils/index.ts 2export function sendResponse(id: string, result: object) { 3 const response = { 4 jsonrpc: '2.0', 5 id: id, 6 result, 7 }; 8 console.log(JSON.stringify(response)); 9}

La funzione di utility sendResponse incapsula la logica di formattazione JSON-RPC. Questo pattern di single responsibility facilita testing e manutenzione

Il Data Layer

typescript
1// src/__mocks__/drinks.ts 2export const drinks = [ 3 { 4 name: 'Latte', 5 price: 5, 6 description: 'A latte is a coffee drink made with espresso and steamed milk.', 7 }, 8 { 9 name: 'Mocha', 10 price: 6, 11 description: 'A mocha is a coffee drink made with espresso and chocolate.', 12 }, 13 { 14 name: 'Flat White', 15 price: 7, 16 description: 'A flat white is a coffee drink made with espresso and steamed milk.', 17 }, 18];