Testing & QA di un Server MCP: Unit, Integration e Regression Test
Come costruire una suite di test completa per un server MCP: unit test con Vitest per la validazione dei path, integration test con il Client SDK ufficiale che parla al server via stdio, e regression test per inchiodare bug specifici. Dalla configurazione alla CI/CD con un solo comando.
Questo è il quinto articolo della serie su come costruire un server MCP da zero. Nel quarto abbiamo messo in sicurezza il server: path traversal bloccato con validatePath, prompt injection mitigata con i delimitatori, collegamento a Claude Desktop funzionante. Alla fine avevamo anche scritto qualche test per la funzione di validazione. Ma quei test avevano un problema che non avevamo notato.
Oggi costruiamo una suite di test completa — unit, integration, regression — che gira con un solo npm test e che potete mettere in CI/CD.
Le tre categorie di test
Prima di scrivere codice, vale la pena chiarire cosa stiamo costruendo e perché. "Test" è una parola generica che copre almeno tre approcci diversi, ognuno con un obiettivo specifico.
Unit test — testa una singola funzione in isolamento, senza dipendenze esterne. È veloce, deterministico, e localizza il problema con precisione. Se validatePath ha un bug, lo unit test lo dice in millisecondi e indica esattamente quale caso fallisce. Il tradeoff è che vede solo la funzione: non sa se quella funzione viene effettivamente chiamata nel contesto del server.
Integration test — testa più componenti che lavorano insieme. Nel nostro caso: il server MCP completo, con il trasporto stdio, il routing dei tool, la validazione Zod, e le funzioni sottostanti. È più lento — deve lanciare un processo Node — ma verifica che i pezzi si incastrino. Se qualcuno rimuove la chiamata a validatePath dentro readResource, lo unit test di validatePath resta verde. L'integration test no.
Regression test — non è una tecnica diversa, è uno scopo diverso. Usi un unit o integration test per inchiodare un bug specifico che hai fixato, così che non possa tornare. Il test documenta il bug e lo rende impossibile da reintrodurre. Fra sei mesi, quando qualcuno tocca quel codice, il test gli spiega cosa non deve rompere.
La differenza chiave: lo unit test risponde a "questa funzione funziona?". L'integration test risponde a "il sistema funziona?". Il regression test risponde a "questo bug specifico è morto per sempre?".
Configurare Vitest: il problema dei test doppi
Se avete seguito l'articolo precedente, avete già Vitest installato e uno script "test": "vitest" nel package.json. Quello che probabilmente non avete è un file di configurazione, e questo crea un problema.
Lanciate npm test e guardate l'output:
✓ src/test/security.test.ts (8 tests) 5ms
✓ dist/test/security.test.js (8 tests) 4msOgni test gira due volte: una dal sorgente TypeScript in src/, una dal compilato JavaScript in dist/. Succede perché Vitest, senza configurazione, cerca file *.test.* in tutte le directory del progetto — inclusa dist/ dove tsc compila i sorgenti .test.ts in .test.js.
Non è solo rumore nell'output. In CI/CD, 16 test che diventano 32 confondono i report. E se un test fallisce, lo vedete due volte — lo stesso errore in due file diversi, che sembra un problema più grande di quello che è.
La soluzione è un vitest.config.ts nella root del progetto:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
});include restringe la ricerca ai soli sorgenti TypeScript in src/. I file compilati in dist/ vengono ignorati completamente. Ogni test gira una volta sola.
Unit test: il falso positivo nascosto
Il bug delle parentesi
Nella lezione precedente avevamo scritto una suite di test per validatePath. Sembrava funzionare. Tutti verdi. Ma c'era un bug — non nel codice testato, nei test stessi.
Guardate il primo test:
it('should allow valid relative paths', () => {
expect(() => validatePath('docs/readme.md')).not.toThrow;
expect(() => validatePath('config/server.json')).not.toThrow;
expect(() => validatePath('subdir/file.txt')).not.toThrow;
});.not.toThrow — senza le parentesi ().
In JavaScript, accedere a una proprietà di un oggetto senza chiamarla non è un errore di sintassi. .toThrow senza parentesi restituisce il riferimento alla funzione — ma non la esegue. L'asserzione non viene mai valutata. È come scrivere Math.random invece di Math.random(): ottieni il riferimento, non il risultato.
La conseguenza è grave: il test passa sempre, qualunque cosa faccia validatePath. Potreste cancellare l'intera funzione — rimuovere il file security.ts dal progetto — e questo test resterebbe verde. Nessun errore, nessun warning, falsa sicurezza totale.
Lo stesso bug era presente nel test should allow paths with ../ that stay within KB.
I test negativi — quelli con .toThrow('Access denied') — funzionavano correttamente perché lì le parentesi c'erano. Ironia: i test che verificano che qualcosa si rompa erano giusti, quelli che verificano che qualcosa funzioni no.
Un linter come ESLint con il plugin eslint-plugin-vitest avrebbe segnalato un expect senza asserzione finale. Ma senza linter, l'unico modo per accorgersene è rileggere il codice con attenzione — o avere un collega che lo faccia.
La suite corretta
Riscriviamo src/test/security.test.ts correggendo i bug e aggiungendo i test mancanti:
import { describe, it, expect, beforeAll } from 'vitest';
import { validatePath } from '../lib/security.js';
beforeAll(() => {
process.env.KB_ROOT_PATH = '/tmp/test-kb';
});Il beforeAll imposta KB_ROOT_PATH a un path fittizio. Non ci serve che esista: validatePath controlla solo che il path risolto inizi con questa root, non che il file esista su disco. Questo rende i test veloci e indipendenti dal filesystem.
describe('validatePath', () => {
it('should allow valid relative paths', () => {
expect(() => validatePath('docs/readme.md')).not.toThrow();
expect(() => validatePath('config/server.json')).not.toThrow();
expect(() => validatePath('subdir/file.txt')).not.toThrow();
});Primo fix: .not.toThrow() con le parentesi. Ora l'asserzione viene effettivamente eseguita. Se validatePath lanciasse un'eccezione su un path legittimo, il test fallirebbe.
Ma c'è un problema più sottile: anche con le parentesi, not.toThrow() verifica solo l'assenza di errori. Non dice nulla sul valore restituito. Se validatePath restituisse una stringa vuota o un path sbagliato, il test passerebbe comunque. Per questo aggiungiamo:
it('should return the resolved absolute path', () => {
const result = validatePath('docs/readme.md');
expect(result).toBe('/tmp/test-kb/docs/readme.md');
});Questo test è più forte: verifica che il valore restituito sia esattamente il path assoluto corretto. Se qualcuno cambia la logica di resolve() dentro validatePath, questo test se ne accorge.
it('should allow paths with ../ that stay within KB', () => {
const result = validatePath('docs/../config/server.json');
expect(result).toBe('/tmp/test-kb/config/server.json');
});Secondo fix: invece di .not.toThrow (che era senza parentesi e quindi non testava nulla), ora verifichiamo il path risolto. docs/../config/server.json deve normalizzarsi a config/server.json — che resta dentro la KB, quindi è legittimo. Questo caso limite è importante: un check troppo aggressivo che blocca qualsiasi ../ romperebbe path perfettamente validi.
I test negativi restano invariati — funzionavano già:
it('should block Unix path traversal attacks', () => {
const attacks = [
'../../../../etc/passwd',
'../../../etc/shadow',
'../../.ssh/id_rsa',
'../../../../../root/.bashrc',
];
attacks.forEach(attack => {
expect(() => validatePath(attack)).toThrow('Access denied');
});
});
it('should block Windows path traversal attacks', () => {
const attacks = [
'..\\..\\..\\Windows\\System32\\config\\sam',
'..\\..\\..\\..\\boot.ini',
'..\\..\\Users\\Administrator\\Desktop\\passwords.txt',
];
attacks.forEach(attack => {
expect(() => validatePath(attack)).toThrow('Access denied');
});
});
it('should block absolute paths outside KB', () => {
expect(() => validatePath('/etc/passwd')).toThrow('Access denied');
expect(() => validatePath('/var/log/syslog')).toThrow('Access denied');
expect(() => validatePath('/root/.ssh/id_rsa')).toThrow('Access denied');
});
it('should block mixed . and .. attacks', () => {
expect(() => validatePath('./docs/../../../etc/passwd'))
.toThrow('Access denied');
});Quattro blocchi di test negativi, ognuno per una categoria di attacco diversa. Gli attacchi Unix con sequenze ../ verso file sensibili del sistema. Gli attacchi Windows con backslash — che il nostro sanitizer converte in forward slash prima di normalize(). I path assoluti che bypassano completamente la KB root. E le combinazioni miste di . e ...
Il pattern forEach con un array di stringhe è intenzionale: se domani scoprite un nuovo vettore d'attacco, aggiungete una riga all'array. Zero boilerplate.
Infine, un test che prima non avevamo:
it('should handle edge cases gracefully', () => {
expect(() => validatePath('my docs/file name.txt')).not.toThrow();
expect(() => validatePath('files/my-file_v2.txt')).not.toThrow();
expect(() => validatePath('./docs/readme.md')).not.toThrow();
});
});Edge case realistici: path con spazi, trattini, underscore, ./ iniziale. Sono situazioni normali in una knowledge base aziendale. Se un fix di sicurezza blocca "my docs/file name.txt", è un bug nel fix.
Integration test: il server completo
Il problema che gli unit test non vedono
Gli unit test verificano validatePath in isolamento. La funzione prende una stringa e restituisce un path o lancia un errore. Funziona.
Ma nella catena reale, quando Claude Desktop chiede di leggere un file, succedono molte più cose:
Claude Desktop → JSON-RPC via stdio → Server MCP → readResource() → validatePath()Ci sono almeno quattro punti dove qualcosa può andare storto senza che gli unit test se ne accorgano:
- Il server potrebbe non registrare il tool correttamente — un typo nel nome, uno schema Zod sbagliato
- La validazione Zod potrebbe rifiutare input validi — un vincolo troppo restrittivo
readResourcepotrebbe smettere di chiamarevalidatePath— un refactoring che rimuove la riga per errore- Il trasporto stdio potrebbe corrompere i messaggi — un
console.logdimenticato in un handler
Lo unit test non vede nessuno di questi problemi. Per coprirli serve un test che lanci il server vero e gli parli con il protocollo vero.
Client e StdioClientTransport
L'SDK MCP non è solo per i server. Esporta anche i componenti client — gli stessi usati da applicazioni come Claude Desktop e Cursor per comunicare con i server MCP.
Due classi ci servono:
Client è il client MCP. Si occupa dell'handshake completo con il server: manda il messaggio initialize, riceve le capabilities, conferma con initialized. Una volta connesso, espone metodi come listTools() per scoprire i tool disponibili e callTool() per invocarli. Gestisce la serializzazione JSON-RPC, il matching delle risposte alle richieste, i timeout. Tutto quello che nel vecchio integration.ts facevamo a mano con buffer, split('\n'), e JSON.parse, qui è un metodo con una await.
StdioClientTransport è il livello di trasporto. Spawna il server come child process e gestisce la comunicazione via stdin/stdout. Prende un oggetto di configurazione con command (il comando da eseguire), args (gli argomenti), e env (le variabili d'ambiente). È lo stesso meccanismo di Claude Desktop: lancia node dist/index.js, scrive JSON-RPC su stdin del processo, legge le risposte da stdout.
Client (test) ──stdin──→ Server MCP (child process)
←stdout──Client + StdioClientTransport insieme sono un "Claude Desktop headless" — niente interfaccia grafica, stesso protocollo. Se un test passa con questa combinazione, funziona con qualsiasi client MCP.
Scrivere il test
Creiamo src/test/integration.test.ts. L'estensione .test.ts fa sì che Vitest lo raccolga automaticamente — niente script separati, niente configurazione aggiuntiva. Gira con lo stesso npm test degli unit test.
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { resolve } from 'path';
describe('MCP Server (integration)', () => {
let client: Client;
let transport: StdioClientTransport;
beforeAll(async () => {
transport = new StdioClientTransport({
command: 'node',
args: [resolve(import.meta.dirname, '../../dist/index.js')],
env: {
...process.env,
KB_ROOT_PATH: resolve(import.meta.dirname, '../../kb'),
LOG_LEVEL: 'error',
DOTENV_CONFIG_QUIET: 'true',
},
});
client = new Client(
{ name: 'test-client', version: '1.0.0' },
{ capabilities: {} },
);
await client.connect(transport);
});
afterAll(async () => {
await transport.close();
});Il beforeAll lancia il server una volta per tutta la suite. Spawnare un processo Node costa circa 500ms: se lo facessimo per ogni it(), nove test significherebbero 4.5 secondi solo di startup. Con beforeAll, sono 500ms condivisi.
Nel costruttore di StdioClientTransport, args punta al compilato in dist/, non al sorgente. È il codice che gira in produzione — se funziona qui, funziona in produzione. Le variabili d'ambiente vengono passate esplicitamente in env perché il child process non eredita il file .env del progetto. LOG_LEVEL: 'error' riduce il rumore nell'output dei test.
client.connect(transport) esegue l'handshake MCP completo: initialize, ricezione capabilities, initialized. Se l'handshake fallisce — server che non parte, path sbagliato, errore di protocollo — connect lancia un'eccezione e tutti i test di questo describe saltano. Un singolo punto di fallimento che protegge l'intera suite.
L'afterAll chiude il trasporto, che manda SIGTERM al child process. Senza questa riga, il processo Node resta zombie e Vitest non termina.
it('should list exactly 2 tools', async () => {
const { tools } = await client.listTools();
expect(tools).toHaveLength(2);
});
it('should expose search_kb with required query param', async () => {
const { tools } = await client.listTools();
const searchTool = tools.find(t => t.name === 'search_kb');
expect(searchTool).toBeDefined();
expect(searchTool!.inputSchema.required).toContain('query');
});
it('should expose read_resource with required path param', async () => {
const { tools } = await client.listTools();
const readTool = tools.find(t => t.name === 'read_resource');
expect(readTool).toBeDefined();
expect(readTool!.inputSchema.required).toContain('path');
});Il primo gruppo di test verifica la tool discovery — il primo step del protocollo MCP. listTools() manda una richiesta JSON-RPC tools/list e riceve la lista dei tool disponibili. Verifichiamo non solo che i tool esistano, ma che abbiano i parametri obbligatori nello schema. Se qualcuno rende query opzionale per errore, il modello potrebbe chiamare search_kb senza query e causare un crash a runtime.
it('should return results for a valid search', async () => {
const result = await client.callTool({
name: 'search_kb',
arguments: { query: 'configurazione', maxResults: 3 },
});
expect(result.isError).toBeFalsy();
const text = (result.content as any)[0].text;
const parsed = JSON.parse(text);
expect(parsed.length).toBeGreaterThan(0);
});
it('should handle fuzzy queries (typo tolerance)', async () => {
const result = await client.callTool({
name: 'search_kb',
arguments: { query: 'cnfigurazione' },
});
expect(result.isError).toBeFalsy();
});Il secondo gruppo testa il tool di ricerca end-to-end. callTool() manda un tools/call con nome e argomenti. La risposta ha un campo isError che è true quando l'handler del server lancia un'eccezione. Verifichiamo che sia falsy — la ricerca deve funzionare senza errori.
Il test con 'cnfigurazione' — errore di battitura intenzionale — verifica che il fuzzy matching di Fuse.js funzioni attraverso l'intera catena: JSON-RPC → validazione Zod → searchKnowledgeBase → Fuse.js → risposta. Se il threshold di Fuse cambia e il typo non viene più tollerato, questo test fallisce.
it('should read an existing file', async () => {
const result = await client.callTool({
name: 'read_resource',
arguments: { path: 'docs/setup-guide.md' },
});
expect(result.isError).toBeFalsy();
const text = (result.content as any)[0].text;
expect(text).toContain('BEGIN FILE CONTENT');
});Il test di lettura verifica due cose: che il file venga letto senza errori, e che il contenuto sia avvolto dal wrapper anti-prompt-injection che abbiamo aggiunto nell'articolo sulla sicurezza (--- BEGIN FILE CONTENT ---). Se qualcuno rimuove il wrapper da readResource, questo test fallisce. È un integration test che protegge una feature di sicurezza.
it('should block path traversal attacks end-to-end', async () => {
const result = await client.callTool({
name: 'read_resource',
arguments: { path: '../../../../etc/passwd' },
});
const text = (result.content as any)[0].text;
expect(text).toContain('Access denied');
});
it('should block Windows-style traversal end-to-end', async () => {
const result = await client.callTool({
name: 'read_resource',
arguments: { path: '..\\..\\..\\etc\\passwd' },
});
const text = (result.content as any)[0].text;
expect(text).toContain('Access denied');
});Questi sono i test più importanti dell'intera suite. Verificano che il path traversal sia bloccato attraverso tutta la catena — dal JSON-RPC in ingresso fino al messaggio di errore in uscita.
La differenza rispetto agli unit test è fondamentale: lo unit test di validatePath verifica che la funzione blocchi i path malevoli. Questo integration test verifica che il server li blocchi. Se qualcuno rimuove la riga const absolutePath = validatePath(relativePath) da readResource — per errore durante un refactoring, perché non capisce a cosa serve — lo unit test di validatePath resta verde. La funzione esiste ancora ed è corretta. Ma il server non la chiama più. Questo integration test è l'unico che se ne accorge.
it('should handle non-existent files gracefully', async () => {
const result = await client.callTool({
name: 'read_resource',
arguments: { path: 'questo-non-esiste.md' },
});
const text = (result.content as any)[0].text;
expect(text).toContain('not found');
});
});L'ultimo test verifica la gestione degli errori: un file che non esiste deve produrre un messaggio leggibile, non un crash del processo. Il server MCP deve restare vivo dopo un errore — la prossima richiesta potrebbe essere perfettamente valida.
Eseguire la suite
Gli integration test lanciano il server compilato in dist/. Prima di testarli, serve una build fresca:
npm run build && npm test ✓ src/test/security.test.ts (9 tests) 5ms
✓ src/test/integration.test.ts (9 tests) 133ms
Test Files 2 passed (2)
Tests 18 passed (18)18 test, un solo comando. Notate la differenza di tempo: gli unit test in 5 millisecondi, gli integration in 133 — il costo dello spawn del processo. È per questo che non testiamo tutto con integration test. Per verificare 20 varianti di path malevoli, lo unit test è più efficiente. Per verificare che il server usi effettivamente validatePath, serve l'integration test.
La regola pratica: unit test per la copertura ampia, integration test per i percorsi critici.
Regression test: inchiodare un bug per sempre
Il ciclo
Un regression test non è una tecnica nuova — usa gli stessi describe e it degli unit test. La differenza è nello scopo: non state testando una feature, state inchiodando un bug specifico perché non possa mai tornare.
Il ciclo è sempre lo stesso:
- Sospetto — "questo check è davvero sicuro?"
- Test — scrivete il test che dovrebbe catturare il bug
- Conferma — il test fallisce? Bug confermato. Passa? Nessun bug, o test sbagliato.
- Fix — correggete il codice
- Verifica — il test ora passa
- Permanente — il test resta nella suite per sempre
Il punto 2 è fondamentale: il test si scrive prima del fix. Se lo scrivete dopo, non saprete mai se il test avrebbe davvero catturato il bug. Potrebbe passare per ragioni sbagliate — come le parentesi mancanti di prima.
L'attacco del prefisso
Guardate il check critico in security.ts:
if (!absolutePath.startsWith(config.kbRootPath + sep)) {Quel + sep alla fine. Sembra un dettaglio — la KB root è /tmp/test-kb, il path risolto sarà qualcosa come /tmp/test-kb/docs/file.md, lo slash c'è sempre. Perché aggiungerlo esplicitamente?
Immaginate una directory sorella della KB con un nome che inizia con lo stesso prefisso:
KB root: /tmp/test-kb
Attacco: /tmp/test-kb-malicious/evil.txt
CON + sep: '/tmp/test-kb-malicious/evil.txt'
.startsWith('/tmp/test-kb/')
→ false ✗ (bloccato)
SENZA + sep: '/tmp/test-kb-malicious/evil.txt'
.startsWith('/tmp/test-kb')
→ true ✓ (passa!)Senza + sep, qualsiasi directory il cui nome inizi con lo stesso prefisso della KB diventa accessibile. /tmp/test-kb-backup, /tmp/test-kb2, /tmp/test-kbbb — tutti passerebbero il check.
Il regression test inchioda questo comportamento. Lo aggiungiamo in fondo a security.test.ts, come un describe separato:
describe('Regression: KB root prefix escape', () => {
it('should block paths to sibling directories with similar names', () => {
const siblingAttacks = [
'../test-kb-malicious/evil.txt',
'../test-kb-backup/secrets.env',
'../test-kb2/config.json',
];
siblingAttacks.forEach(attack => {
expect(
() => validatePath(attack),
`Regression: "${attack}" non dovrebbe essere accessibile!`,
).toThrow('Access denied');
});
});
});Il secondo argomento di expect() è un messaggio custom che appare solo se il test fallisce. In un output con decine di test rossi, quel "Regression:" rende immediatamente chiaro che non è un test qualsiasi — è un bug che era già stato fixato e che è tornato. Indica esattamente quale path non dovrebbe essere accessibile e perché.
Verificare che il test funziona
Il test passa perché il + sep è al suo posto. Ma un test che passa non dimostra nulla finché non lo vedete fallire. Provate a rimuovere temporaneamente il + sep da security.ts:
// Prima (con fix):
if (!absolutePath.startsWith(config.kbRootPath + sep)) {
// Simulazione regressione:
if (!absolutePath.startsWith(config.kbRootPath)) {Lanciate npm test:
FAIL src/test/security.test.ts
✗ Regression: KB root prefix escape > should block paths
to sibling directories with similar names
→ Regression: "../test-kb-malicious/evil.txt"
non dovrebbe essere accessibile!Il test ha catturato la regressione. Rimettete il + sep, rilanciate, tutto verde. Il cerchio si chiude: il test documenta il bug, verifica il fix, e protegge da future regressioni.
Riepilogo
Una suite di test completa per un server MCP, con tre livelli di verifica:
Unit test — validatePath in isolamento: path validi, valore di ritorno, attacchi Unix/Windows, path assoluti, edge case. Tutto in security.test.ts.
Integration test — il server completo via SDK Client: tool discovery, ricerca fuzzy, lettura file, sicurezza end-to-end, gestione errori. In integration.test.ts.
Regression test — il bug del prefisso: impedisce di rimuovere il + sep da startsWith. Aggiunto in fondo a security.test.ts.
Tutto gira con un solo comando:
npm run build && npm testQuesto è quello che mettete in CI/CD. Se un test fallisce, il deploy si ferma. Nessun bug in produzione, nessun path traversal reintrodotto per errore, nessun falso positivo da parentesi mancanti.
Nel prossimo articolo metteremo il server in un container Docker per il deploy in produzione.