Vai al contenuto principale

Setup di un Server MCP: configurazione dell'ambiente

Come configurare un server MCP production-ready: TypeScript strict mode, validazione variabili d'ambiente con il principio fail fast, e perché un singolo console.log può rompere il protocollo JSON-RPC senza generare errori. Guida pratica con codice completo.

Questo è il secondo articolo della serie su come costruire un server MCP (Model Context Protocol) da zero. Nel primo articolo abbiamo esplorato l'architettura, i quattro pilastri (Resources, Tools, Prompts, Transport) e il flusso completo di comunicazione basato su JSON-RPC 2.0.

Oggi si scrive codice. Ma prima di implementare tool e risorse, serve una base solida e "solida" non significa "funzionante a malapena su localhost". Significa: TypeScript con strict mode al massimo, variabili d'ambiente validate con il principio del fail fast, e un sistema di logging che non distrugga il protocollo di comunicazione.

Inizializzazione del progetto

Creazione e struttura

Partiamo dal terminale:

mkdir mcp-knowledge-base-server && cd $_
npm init -y

Il flag -y accetta tutti i default. Non importa: il package.json generato va riscritto quasi interamente.

{
  "name": "mcp-knowledge-base-server",
  "version": "1.0.0",
  "description": "Production-ready MCP server for intelligent knowledge base search",
  "type": "module",
  "main": "dist/index.js",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "engines": {
    "node": ">=22.0.0"
  }
}

Quattro punti da notare.

"type": "module" dice a Node di usare ES Modules — import/export — invece dei vecchi require. Su un progetto nuovo non c'è motivo di partire con CommonJS.

"main": "dist/index.js" — il codice sorgente TypeScript vivrà in src/, ma l'output compilato andrà in dist/. Quando Claude (o qualsiasi client MCP) lancerà il server, eseguirà il JavaScript compilato.

I tre script coprono l'intero ciclo di sviluppo: dev usa tsx watch per hot-reload durante lo sviluppo, build compila TypeScript in JavaScript, start è il comando di produzione che lancia il JavaScript compilato.

"engines" non è obbligatorio e npm lo ignora di default, ma serve come documentazione: chiunque apra il package.json sa subito quale versione minima di Node serve. Se volete che npm lo rispetti davvero, aggiungete engine-strict=true nel .npmrc.

A proposito di versione Node, è buona pratica creare anche un file .nvmrc con il contenuto 22. Chi usa nvm o fnm potrà eseguire nvm use nella directory del progetto e ottenere automaticamente la versione corretta.

TypeScript: strict al massimo

Installiamo il tooling di sviluppo:

npm i -D typescript @types/node tsx

typescript è il compilatore, @types/node fornisce i tipi per le API di Node.js (fs, path, process, eccetera), tsx è il runner che useremo in sviluppo.

Ora il tsconfig.json. Qui si fa una scelta precisa: strict mode senza compromessi.

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "sourceMap": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Analizziamo le scelte più rilevanti.

target: ES2022 — usiamo feature moderne come top-level await e le nuove API di Array. Node 18+ le supporta tutte.

module: Node16 e moduleResolution: Node16 — è il duo corretto per ESM in Node. NodeNext funziona allo stesso modo, ma Node16 è più esplicito sulla versione target.

La sezione strict è dove non si transige. strict: true da solo abilita già una batteria di check: noImplicitAny, strictNullChecks, strictFunctionTypes e altri. Ma aggiungiamo anche:

  • noUnusedLocals e noUnusedParameters — eliminano codice morto prima che si accumuli.
  • noImplicitReturns — ogni branch di una funzione deve ritornare un valore esplicito.
  • noFallthroughCasesInSwitch — impedisce i fallthrough accidentali nei switch.

E soprattutto noUncheckedIndexedAccess. Questa opzione è oro puro. Normalmente, quando accedete a un array con array[0], TypeScript vi dice che il tipo è string. Con questo flag attivo, il tipo diventa string | undefined. Vi obbliga a gestire il caso in cui l'indice non esista. Niente più Cannot read properties of undefined a runtime.

La regola è semplice: se TypeScript non si lamenta con strict mode attivo, il codice è probabilmente corretto. Disattivare i check per fare prima significa solo spostare i bug da compile-time a runtime — e lì costano molto di più.

Versionamento esatto dei pacchetti

Un dettaglio che fa la differenza tra un progetto che "funziona sulla mia macchina" e uno riproducibile: il file .npmrc.

save-exact=true
prefer-offline=true

save-exact=true è cruciale. Normalmente quando eseguite npm install fuse.js, nel package.json trovate "fuse.js": "^7.0.0" — con il caret. Quel simbolo significa: "accetta qualsiasi versione 7.x.y". Tra oggi e domani potreste installare versioni diverse dello stesso pacchetto.

Con save-exact=true, diventa "fuse.js": "7.0.0" — punto. Versione esatta. Combinato con il package-lock.json, questo garantisce che il progetto si comporti allo stesso modo sulla vostra macchina, su quella del collega e in CI.

prefer-offline=true — se un pacchetto è già nella cache locale, npm lo usa senza scaricarlo di nuovo. Install più veloci, meno dipendenza dalla rete.

Struttura delle cartelle

mkdir -p src/{lib,tools}

Avremo src/ per tutto il codice sorgente, src/lib/ per i moduli di utilità (configurazione, logging, sicurezza) e src/tools/ per i tool MCP veri e propri. Struttura piatta, niente over-engineering. Se il progetto cresce, si rifattorizza.

Configurazione e variabili d'ambiente: Il principio del fail fast

Il nostro server ha bisogno di sapere una cosa fondamentale: dove si trovano i file della knowledge base. Questo path non può essere hardcodato — cambia da macchina a macchina, da ambiente a ambiente.

Il file .env.example come documentazione

npm install dotenv

Prima di tutto creiamo un .env.example. Questo file va in git: serve come documentazione per chi clona il progetto.

# Knowledge Base Configuration
# Absolute path to your knowledge base directory
KB_ROOT_PATH=/path/to/your/knowledge-base

# Optional: Maximum file size in MB (default: 10)
MAX_FILE_SIZE_MB=10

# Optional: Log level (error, warn, info, debug)
LOG_LEVEL=info

# Optional: Enable security features
ENABLE_PATH_VALIDATION=true

# Must have to avoid tips during execution
DOTENV_CONFIG_QUIET=true

L'unica variabile obbligatoria è KB_ROOT_PATH. Le altre hanno valori di default sensati. Il .env vero (con il path reale della vostra knowledge base) non va in git — contiene configurazione locale. Aggiungetelo al .gitignore insieme a node_modules/ e dist/.

N.B. DOTENV_CONFIG_QUIET è necessaria dal momomento che il package dotenv dalla versione 17 aggiunge un console log durante l'esecuzione dello script che romperà le scatole a MCP. La variabile serve a disabilitarlo.

Validazione: morire subito è eeglio che morire male

Il principio guida è fail fast is better than fail weird. Se la variabile d'ambiente manca, il server non deve partire in silenzio e poi crashare mezz'ora dopo con un errore incomprensibile. Deve morire subito, con un messaggio che dice esattamente cosa manca.

Creiamo src/lib/config.ts:

/**
 * Environment configuration and validation
 *
 * Fail-fast approach: If critical environment variables are missing,
 * we terminate immediately with a clear error message.
 */
import { existsSync } from 'fs';
import { resolve } from 'path';

export interface Config {
  kbRootPath: string;
  maxFileSizeMB: number;
  logLevel: string;
  enablePathValidation: boolean;
}

L'interfaccia Config tipizza ogni variabile d'ambiente. kbRootPath è una stringa, maxFileSizeMB è un numero — non una stringa, un numero. La conversione la facciamo noi.

Ora la funzione di validazione:

/**
 * Validate required environment variables
 * Exits process if validation fails
 */
export function validateEnvironment(): void {
  const kbRootPath = process.env.KB_ROOT_PATH;

  if (!kbRootPath) {
    console.error('❌ FATAL ERROR: KB_ROOT_PATH environment variable is required');
    console.error('');
    console.error('Please set it in your .env file:');
    console.error('  KB_ROOT_PATH=/path/to/your/knowledge-base');
    console.error('');
    console.error('Or export it:');
    console.error('  export KB_ROOT_PATH=/path/to/your/knowledge-base');
    process.exit(1);
  }

  const resolvedPath = resolve(kbRootPath);

  if (!existsSync(resolvedPath)) {
    console.error(`❌ FATAL ERROR: Knowledge base directory does not exist: ${resolvedPath}`);
    console.error('');
    console.error('Please create the directory or update KB_ROOT_PATH');
    process.exit(1);
  }

  // Update env with resolved path for consistency
  process.env.KB_ROOT_PATH = resolvedPath;
}

Due check, due process.exit(1).

Il primo verifica che la variabile esista. Se manca, non si prova a indovinare un default. Si stampa un errore chiaro che dice esattamente cosa fare per risolvere, e si uccide il processo.

Il secondo verifica che il path punti a una directory reale. Se no, stessa cosa: errore chiaro, processo morto.

Perché process.exit(1) e non throw new Error()****? L'1 è l'exit code. Zero significa "tutto ok", qualsiasi altro numero significa "errore". È una convenzione Unix. Gli strumenti esterni — CI/CD, Docker, script di shell — leggono l'exit code per sapere se il processo è andato a buon fine. Se uscissimo con exit(0), il sistema penserebbe che va tutto bene. Con exit(1) diciamo esplicitamente: qualcosa è andato storto, fermati qui. Se una condizione è irrecuperabile, non lanciate un'eccezione sperando che qualcuno la catturi. Fermate tutto. Subito.

Notate che usiamo resolve() per convertire eventuali path relativi in assoluti, e poi aggiorniamo process.env.KB_ROOT_PATH con il path risolto. Così nel resto del codice abbiamo sempre il path canonico.

La funzione getConfig() è il punto di accesso per il resto dell'applicazione:

/**
 * Get validated configuration
 */
export function getConfig(): Config {
  const kbRootPath = process.env.KB_ROOT_PATH;

  if (!kbRootPath) {
    throw new Error('KB_ROOT_PATH not set - validateEnvironment() should be called first');
  }

  return {
    kbRootPath,
    maxFileSizeMB: parseInt(process.env.MAX_FILE_SIZE_MB || '10', 10),
    logLevel: process.env.LOG_LEVEL || 'info',
    enablePathValidation: process.env.ENABLE_PATH_VALIDATION !== 'false',
  };
}

Notate parseInt con base 10 esplicita — mai fidarsi del default di JavaScript.

E notate enablePathValidation: process.env.ENABLE_PATH_VALIDATION !== 'false'. Sembra strano confrontare con la stringa 'false', ma ricordatevi: le variabili d'ambiente sono sempre stringhe. Non esiste il booleano false in un file .env — c'è solo il testo f-a-l-s-e.

La logica è: se la variabile vale esattamente la stringa 'false', disabilita. In tutti gli altri casi — compreso quando la variabile non è impostata affatto — abilita. Perché undefined !== 'false' è true. Questo è il pattern secure by default: la feature di sicurezza è attiva a meno che non la si disabiliti esplicitamente. Se ci si dimentica di impostarla nel .env, si è comunque protetti.

Demo del Fail Fast

Per verificare che la validazione funzioni, creiamo un src/index.ts minimale:

import dotenv from 'dotenv';
import { validateEnvironment } from './lib/config.js';

dotenv.config();
validateEnvironment();

console.error('✅ Server ready to start!');

Attenzione: l'import punta a './lib/config.js' con l'estensione .js, non .ts. Sembra sbagliato, ma è corretto: TypeScript con ESM richiede che gli import puntino al file compilato. È una delle cose che confondono di più quando si inizia.

Se rinominiamo il .env e lanciamo il server:

mv .env .env.bak
npx tsx src/index.ts

Otteniamo:

❌ FATAL ERROR: KB_ROOT_PATH environment variable is required

Please set it in your .env file:
  KB_ROOT_PATH=/path/to/your/knowledge-base

Or export it:
  export KB_ROOT_PATH=/path/to/your/knowledge-base

Errore chiaro, immediato, con istruzioni per risolvere. Non un Cannot read properties of undefined sepolto in uno stack trace di 40 righe.

Il disastro dello STDOUT: perché console.log rompe MCP

Questo è il bug più insidioso che incontrerete con MCP. Un bug che non genera errori, non lancia eccezioni, non vi dà warning. Semplicemente, il server smette di funzionare e non capite perché.

Come funziona il trasporto stdio

Per capire il problema, bisogna sapere come funziona il trasporto stdio in MCP.

Quando Claude (o qualsiasi client MCP) lancia il vostro server, crea un processo figlio e comunica con lui attraverso due canali:

  • stdin — il client scrive qui i messaggi JSON-RPC per il server
  • stdout — il server risponde qui, sempre in JSON-RPC

È una pipe bidirezionale. Il client manda una richiesta come {"jsonrpc": "2.0", "method": "tools/list"} sullo stdin del server, e si aspetta di ricevere una risposta JSON valida sullo stdout.

Ma console.log dove scrive? Su stdout.

Quindi se il vostro codice contiene console.log('Starting server...'), quello che arriva al client sullo stdout è:

Starting server...
{"jsonrpc":"2.0","result":{"tools":[...]}}

Testo in chiaro mischiato a JSON-RPC. Il protocollo è corrotto.

La dimostrazione

Introduciamo il bug nel nostro index.ts:

import dotenv from 'dotenv';
import { validateEnvironment } from './lib/config.js';

dotenv.config();
validateEnvironment();

console.log('✅ Server ready to start!');

Compiliamo e lanciamo l'MCP Inspector:

npx tsc
npx @modelcontextprotocol/inspector node dist/index.js

Cliccando su "Connect"... niente. L'Inspector non riesce a completare l'handshake, ma questo è normale dal momento che non c'è un server MCP che risponda alla richiesta initialize. Nel terminale però appare un syntax error: il client sta cercando di fare il parse JSON di ✅ Server ready to start! e ovviamente fallisce.

Perché è Insidioso

La cosa subdola è che se aggiungiamo un server MCP minimale (con l'SDK @modelcontextprotocol/sdk), l'SDK è abbastanza resiliente da recuperare in fase di inizializzazione. Il garbage data arriva prima che la comunicazione vera inizi, e il server parte lo stesso.

import dotenv from 'dotenv';
import { validateEnvironment } from './lib/config.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

dotenv.config();
validateEnvironment();

console.log('✅ Server ready to start!'); // ← Sembra funzionare...

const server = new McpServer(
  { name: 'knowledge-base-server', version: '1.0.0' },
  { capabilities: { tools: {} } }
);

const transport = new StdioServerTransport();
await server.connect(transport);

Tutto sembra funzionare. Ma quel syntax error c'è ancora, ed è un chiaro segnale di corruzione del protocollo. Il problema diventa critico per tre ragioni:

  1. Client diversi hanno tolleranze diverse. L'Inspector perdona, ma Claude Desktop, Cursor, o un agente autonomo? Ogni client ha il suo parser. Un server MCP corretto non deve contare sulla clemenza del client.
  2. Un console.log dentro un tool handler è distruttivo. In fase di avvio vi va bene perché il testo arriva prima della comunicazione vera. Ma se quel console.log scatta nel mezzo dell'esecuzione di un tool — si inserisce letteralmente tra i byte di un messaggio JSON-RPC. E lì non c'è SDK che tenga: il framing si rompe.
  3. La serializzazione multi-riga rompe il framing. Se fate console.log di un oggetto, Node lo serializza su più righe. E quello distrugge la struttura del messaggio.

La Soluzione: Un Logger su stderr

Tutto il logging deve andare su stderr. Il modo migliore per garantirlo è creare un modulo logger dedicato.

src/lib/logger.ts:

/**
 * Logger utility for MCP server
 *
 * CRITICAL: MCP uses stdout for JSON-RPC protocol.
 * ALL logging MUST go to stderr to avoid corrupting the protocol.
 */
type LogLevel = 'error' | 'warn' | 'info' | 'debug';

const LOG_LEVELS: Record<LogLevel, number> = {
  error: 0,
  warn: 1,
  info: 2,
  debug: 3,
};

class Logger {
  private level: LogLevel;

  constructor() {
    this.level = (process.env.LOG_LEVEL as LogLevel) || 'info';
  }

  private shouldLog(level: LogLevel): boolean {
    return LOG_LEVELS[level] <= LOG_LEVELS[this.level];
  }

  private log(level: LogLevel, message: string, ...args: unknown[]): void {
    if (!this.shouldLog(level)) {
      return;
    }

    const timestamp = new Date().toISOString();
    const prefix = `[${timestamp}] [${level.toUpperCase()}]`;

    // ALWAYS use console.error to write to stderr, never stdout
    console.error(prefix, message, ...args);
  }

  error(message: string, ...args: unknown[]): void {
    this.log('error', message, ...args);
  }

  warn(message: string, ...args: unknown[]): void {
    this.log('warn', message, ...args);
  }

  info(message: string, ...args: unknown[]): void {
    this.log('info', message, ...args);
  }

  debug(message: string, ...args: unknown[]): void {
    this.log('debug', message, ...args);
  }
}

export const logger = new Logger();

La riga chiave è console.error(prefix, message, ...args). Sembra controintuitivo usare console.error anche per log di livello info, ma in Node.js il nome è fuorviante: console.log scrive su stdout, console.error scrive su stderr. Non c'entra con la gravità del messaggio — è solo il canale di output.

La gerarchia numerica dei livelli (error: 0, debug: 3) permette di filtrare: se il livello configurato è info (2), si stampano error, warn e info — ma non debug.

Esportiamo un singleton: tutti i moduli importano la stessa istanza. Un unico punto dove controllare dove e come finisce il logging.

Il fix finale

Sistemiamo src/index.ts:

import dotenv from 'dotenv';
import { validateEnvironment } from './lib/config.js';
import { logger } from './lib/logger.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

dotenv.config();
validateEnvironment();

logger.info('MCP Knowledge Base Server starting...');

const server = new McpServer(
  { name: 'knowledge-base-server', version: '1.0.0' },
  { capabilities: { tools: {} } }
);

const transport = new StdioServerTransport();
await server.connect(transport);

logger.info('Server running on stdio');

L'unica differenza sostanziale: console.log diventa logger.info. Tutto finisce su stderr, stdout resta pulito per il protocollo.

Una nota: in validateEnvironment usiamo console.error diretto invece del logger. È una scelta voluta. Quegli errori sono fatali — il processo sta per morire. Per un messaggio così critico vogliamo la certezza assoluta che arrivi: niente dipendenze, niente livelli di log. console.error e basta. Il logger lo usiamo per tutto il resto.

Ricompilando e rilanciando l'Inspector, l'handshake va a buon fine. Il nome del server, la versione, le capabilities — tutto appare correttamente. Le notifications con i nostri log continuano a scorrere (vengono da stderr e non danno fastidio), ma lo stdout è pulito, riservato al JSON-RPC.

Non abbiamo ancora nessun tool, quelli arrivano nel prossimo articolo. Ma la connessione è solida. Un singolo console.log era l'unica differenza tra un server che funziona e uno che sembra morto senza spiegarti perché.

Tre regole da questo capitolo.

TypeScript strict al massimo. Se disabilitate i check, state solo nascondendo potenziali bug. noUncheckedIndexedAccess da solo vi salva da un'intera categoria di errori a runtime.

Fail fast. Se una variabile d'ambiente critica manca, il server si arresta immediatamente con un messaggio chiaro. Non cinque minuti dopo con un errore incomprensibile. Usate process.exit(1) per condizioni irrecuperabili, e date sempre istruzioni su come risolvere il problema.

Mai console.log in un server MCP. Lo stdout è sacro — è il canale del protocollo. Tutto il logging va su stderr. Createvi un logger, usatelo ovunque, e dimenticate che console.log esiste.

Nel prossimo articolo iniziamo a scrivere i tool veri e propri: la ricerca fuzzy nella knowledge base e la lettura dei file.

Unisciti a WebTea

Niente spam. Solo contenuti formativi su Software Engineering.

Ci sono due cose che non ci piacciono: lo spam e il mancato rispetto della privacy. Seleziona come vuoi restare in contatto:

Preferenze di contatto

Usiamo Mailchimp come piattaforma di marketing. Cliccando su iscriviti, accetti la nostra privacy policy e che le tue informazioni vengano trasferite a Mailchimp per l'elaborazione. Termini e Privacy. Puoi disiscriverti in qualsiasi momento.