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
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
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
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
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
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
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
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
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];
