Vai al contenuto principale

Core Development di un Server MCP: i Tool della Knowledge Base

Come scrivere i primi tool MCP funzionanti: ricerca fuzzy con Fuse.js, lettura file, registrazione con registerTool e validazione automatica con Zod. Dall'implementazione al testing con l'MCP Inspector.

Come implementare i primi tool MCP funzionanti: ricerca fuzzy con Fuse.js, lettura file, registrazione con registerTool e validazione automatica con Zod. Dal codice all'Inspector, con un buco di sicurezza lasciato apposta.

Questo è il terzo articolo della serie su come costruire un server MCP da zero. Nel primo articolo abbiamo esplorato l'architettura e il protocollo JSON-RPC. Nel secondo abbiamo costruito le fondamenta: TypeScript strict, variabili d'ambiente con fail-fast, e il logger su stderr. Alla fine avevamo un server che si avvia, fa l'handshake con l'Inspector, ma non ha ancora nessun tool da offrire.

Oggi il server prende vita. Scriviamo due tool — ricerca fuzzy e lettura file — li registriamo con l'SDK, e li testiamo dall'Inspector vedendo i JSON-RPC grezzi che viaggiano tra client e server.

Preparare la knowledge base

Prima di scrivere i tool, ci serve qualcosa su cui lavorare. La knowledge base è una cartella di file — markdown, JSON, testo — che il server indicizza e rende cercabile. Nella lezione precedente avevamo creato la cartella kb vuota. Adesso la riempiamo.

Creiamo una struttura con qualche sottocartella:

mkdir -p kb/{docs,config}

Il primo file è una guida di setup — kb/docs/setup-guide.md:

# Setup Guide

## Prerequisites

Before you begin, ensure you have:

- Node.js 18+ installed
- npm or yarn package manager
- Git for version control
- A code editor (VS Code recommended)

## Installation

### 1. Clone the Repository

git clone https://github.com/your-org/project.git
cd project

### 2. Install Dependencies

npm install

This will install all required packages defined in package.json.

### 3. Environment Configuration

Copy the example environment file and edit it:

cp .env.example .env

### 4. Start Development Server

npm run dev

The server should start on http://localhost:3000

Il secondo è una configurazione del server — kb/config/server.json:

{
  "server": {
    "name": "knowledge-base-server",
    "version": "1.0.0",
    "port": 3000,
    "environment": "production"
  },
  "database": {
    "host": "localhost",
    "port": 5432,
    "name": "kb_production",
    "pool": {
      "min": 2,
      "max": 10
    }
  },
  "logging": {
    "level": "info",
    "format": "json"
  }
}

Il terzo è una guida sull'autenticazione API — kb/docs/api-authentication.md:

# API Authentication Guide

## Overview

This guide covers the authentication mechanisms supported by our API.

## Supported Methods

### 1. API Key Authentication

The simplest method. Include your API key in the request header.

Pros:
- Simple to implement
- Works everywhere

Cons:
- Key can be leaked if logged

### 2. JWT (JSON Web Tokens)

For stateless authentication with token expiration.

## Best Practices

1. Always use HTTPS
2. Rotate keys regularly — at least every 90 days
3. Use environment variables — never hardcode secrets
4. Implement rate limiting

Tre file bastano per testare. La cosa importante è avere varietà: un markdown lungo, un JSON strutturato, e un altro markdown con un argomento diverso. Così quando faremo la ricerca fuzzy, vedremo come il motore distingue i file per rilevanza.

Nella vostra knowledge base reale avrete decine o centinaia di file. La struttura è la stessa — il tool li scansiona tutti ricorsivamente.

Installare le dipendenze

Nella lezione precedente avevamo già installato l'SDK MCP. Ora servono due pacchetti in più:

npm install zod fuse.js

Zod è una libreria di validazione. Definite uno schema — "query deve essere una stringa, maxResults un numero tra 1 e 20" — e Zod controlla a runtime che i dati ricevuti lo rispettino. Se non lo rispettano, errore immediato con un messaggio chiaro.

Ma la cosa fondamentale per noi è un'altra: l'SDK MCP integra Zod nativamente. Quando registrate un tool, passate lo schema Zod e l'SDK lo converte automaticamente nel JSON Schema che Claude legge. Scrivete la validazione una volta sola e ottenete due cose — la validazione runtime E la documentazione per l'AI.

Fuse.js è il motore di ricerca fuzzy. "Fuzzy" significa tollerante: se cercate "cnfig" con un typo, Fuse capisce che intendete "config" e trova il file giusto. Per una knowledge base è essenziale — gli utenti non scrivono sempre perfettamente, e neanche Claude.

Tool 1: Ricerca fuzzy

Avremmo potuto usare un semplice indexOf — cerco la stringa esatta dentro il nome del file o il contenuto. Funziona? Tecnicamente sì. Ma è fragile.

Se l'utente chiede "trovami la documentazione sulla configurazione del server", Claude potrebbe estrarre la query "configurazione server". Con indexOf, il file si chiama server.json — non contiene la parola "configurazione" nel nome. E il contenuto è JSON, non prosa in italiano. Zero risultati.

Con la fuzzy search, Fuse.js calcola una distanza di similarità. "server" matcha "server.json" con score alto. "configurazione" matcha "config" nella path "config/server.json". Il file viene trovato.

La differenza tra una ricerca esatta e una fuzzy, per una knowledge base, è la differenza tra uno strumento frustrante e uno che funziona davvero.

Scrivere search.ts

Creiamo src/tools/search.ts. Partiamo dagli import:

// Modulo 'fs' di Node.js — funzioni sincrone per leggere file e cartelle
import { readdirSync, statSync, readFileSync } from 'fs';
// Modulo 'path' — utility per manipolare percorsi di file
// - join: concatena segmenti ("kb" + "docs" → "kb/docs")
// - relative: calcola il path relativo tra due assoluti
// - sep: il separatore del SO ("/" su Linux/Mac, "\" su Windows)
import { join, relative, sep } from 'path';
// Fuse.js — motore di ricerca fuzzy (tollerante ai typo)
import Fuse from 'fuse.js';
// Moduli nostri dalla lezione 2
import { getConfig } from '../lib/config.js';
import { logger } from '../lib/logger.js';
// NOTA: non importiamo Zod qui. La validazione dell'input la gestisce
// l'SDK in index.ts via registerTool. Questo file ha solo logica di business.

Filesystem per leggere i file, path per gestire i percorsi, Fuse per la ricerca, e i moduli config e logger dalla lezione precedente. Notate: non importiamo Zod. La validazione dell'input la gestirà l'SDK quando registriamo il tool.

Le interfacce per i dati:

// Rappresenta un file in memoria durante la scansione.
// Usato internamente dal motore di ricerca — NON esposto a Claude.
interface FileInfo {
  path: string;     // Path relativo dalla root della KB (es. "docs/setup-guide.md")
  name: string;     // Solo il nome del file (es. "setup-guide.md")
  content: string;  // Contenuto testuale completo — serve a Fuse per cercare nel testo
  size: number;     // Dimensione in byte — serve per il check sul limite
}

// Rappresenta un risultato di ricerca restituito a Claude.
// Esportato perché index.ts ne ha bisogno per il tipo di ritorno.
export interface SearchResult {
  path: string;     // Path relativo, sempre con "/" (anche su Windows)
  name: string;     // Nome del file
  score: number;    // Rilevanza: 0.0 = nessun match, 1.0 = perfetto (invertito rispetto a Fuse)
  snippet: string;  // Anteprima: i primi 200 caratteri del contenuto, su una riga
}

FileInfo rappresenta un file della knowledge base come lo tiene in memoria il motore di ricerca — path relativo, nome, contenuto testuale, dimensione in byte. SearchResult è quello che restituiamo a Claude: path, nome, uno score di rilevanza — dove più alto è meglio — e uno snippet di anteprima.

La scansione ricorsiva

La funzione che scansiona la knowledge base legge ricorsivamente tutti i file dalla cartella root:

/**
 * Scansiona ricorsivamente una cartella e restituisce tutti i file leggibili.
 *
 * PERCHÉ DUE PARAMETRI?
 * - `dir` cambia ad ogni livello di ricorsione (è la cartella corrente)
 * - `rootPath` resta SEMPRE la root originale della KB
 *
 * Esempio con kb/docs/guides/:
 *   Chiamata 1: scanDirectory("kb",              "kb")  ← coincidono
 *   Chiamata 2: scanDirectory("kb/docs",          "kb")  ← dir scende, rootPath resta
 *   Chiamata 3: scanDirectory("kb/docs/guides",   "kb")  ← dir scende ancora
 *
 * rootPath serve a relative() per calcolare il path relativo:
 * da "kb/docs/setup-guide.md" otteniamo "docs/setup-guide.md"
 */
function scanDirectory(dir: string, rootPath: string): FileInfo[] {
  const files: FileInfo[] = [];

  try {
    // withFileTypes: true → restituisce oggetti Dirent (non semplici stringhe),
    // così distinguiamo file da cartelle senza una stat() extra
    const entries = readdirSync(dir, { withFileTypes: true });

    for (const entry of entries) {
      const fullPath = join(dir, entry.name);

      // Ignoriamo file/cartelle nascosti (iniziano con '.')
      // Es: .gitignore, .env, .DS_Store — sono file di sistema, non documentazione
      if (entry.name.startsWith('.')) {
        continue;
      }

      if (entry.isDirectory()) {
        // RICORSIONE: scendiamo nella sottocartella.
        // 'dir' diventa fullPath (la sottocartella corrente),
        // ma rootPath resta invariato — serve per calcolare i path relativi.
        files.push(...scanDirectory(fullPath, rootPath));
      } else if (entry.isFile()) {
        try {
          const stats = statSync(fullPath);

          // Saltiamo file > 1MB per non sovraccaricare l'indice Fuse in memoria.
          if (stats.size > 1024 * 1024) {
            logger.debug(`Skipping large file: ${entry.name}`);
            continue;
          }

          const content = readFileSync(fullPath, 'utf-8');
          const relativePath = relative(rootPath, fullPath);

          files.push({
            path: relativePath,
            name: entry.name,
            content,
            size: stats.size,
          });
        } catch {
          // readFileSync('utf-8') fallisce su file binari (immagini, PDF...).
          // Invece di crashare tutta la scansione, saltiamo e andiamo avanti.
          logger.debug(`Skipping unreadable file: ${entry.name}`);
        }
      }
    }
  } catch (error) {
    // Se la cartella stessa non è leggibile (permessi, non esiste),
    // logghiamo e restituiamo array vuoto — non crashiamo.
    logger.error(`Error scanning directory ${dir}:`, error);
  }

  return files;
}

Tre decisioni importanti in questa funzione.

Saltiamo i file nascosti — quelli che iniziano con punto. .gitignore, .env, .DS_Store... sono file di sistema, non vogliamo indicizzarli.

Saltiamo i file più grandi di un megabyte. Stiamo caricando l'intero contenuto in memoria per la ricerca. Se nella knowledge base avete un PDF da 50 megabyte, non volete caricarlo nell'indice di Fuse ogni volta. Se l'utente vuole leggere quel file, userà il tool read_resource direttamente — quello non ha limiti di indicizzazione.

Il try-catch interno. readFileSync con 'utf-8' fallisce sui file binari — immagini, PDF, eseguibili. Invece di crashare l'intera scansione per un file che non possiamo leggere, lo saltiamo silenziosamente e andiamo avanti.

Una nota sulle performance: questa funzione è sincrona e ri-legge tutti i file ad ogni ricerca. Per una knowledge base di documentazione — decine di file, pochi megabyte — va benissimo. Se aveste migliaia di file, costruireste un indice persistente e lo aggiornereste solo quando i file cambiano. Ma per il nostro caso, rileggere è più semplice e ci evita tutta la complessità di invalidare una cache. Non ottimizzate prima di averne bisogno.

La funzione di ricerca

/**
 * Funzione principale di ricerca. Riceve parametri GIÀ VALIDATI dall'SDK
 * (la validazione Zod avviene in index.ts, prima che questa funzione venga chiamata).
 * Noi scriviamo solo logica di business — zero validazione qui.
 */
export async function searchKnowledgeBase(
  query: string,
  maxResults: number
): Promise<SearchResult[]> {
  logger.info(`Searching KB for: "${query}"`);

  const config = getConfig();
  const files = scanDirectory(config.kbRootPath, config.kbRootPath);

  logger.debug(`Indexed ${files.length} files`);

  if (files.length === 0) {
    logger.warn('No files found in knowledge base');
    return [];
  }

  // --- Configurazione Fuse.js ---
  const fuse = new Fuse(files, {
    // keys: campi di FileInfo su cui Fuse cerca, con pesi diversi.
    // Peso più alto = quel campo conta di più nel punteggio.
    keys: [
      { name: 'name', weight: 2 },      // Nome file = segnale più forte
      { name: 'path', weight: 1.5 },    // Path aiuta (es. "config/" matcha "configurazione")
      { name: 'content', weight: 1 },   // Contenuto = peso minore, evita falsi positivi
    ],
    // threshold: tolleranza ai typo. 0 = match esatto, 1 = accetta tutto.
    // 0.4 è un buon compromesso: tollera 1-2 errori senza rumore.
    threshold: 0.4,
    // includeScore: ci serve per calcolare la rilevanza da restituire a Claude
    includeScore: true,
    // ignoreLocation: non ci interessa DOVE nel testo appare il match.
    // Senza questo, Fuse favorirebbe match vicini all'inizio della stringa.
    ignoreLocation: true,
    // minMatchCharLength: ignora match di 1 solo carattere (troppo rumorosi)
    minMatchCharLength: 2,
  });

  const results = fuse.search(query, { limit: maxResults });

  logger.info(`Found ${results.length} results`);

  // Trasformiamo i risultati di Fuse nel nostro formato SearchResult
  return results.map((result) => {
    const file = result.item;

    const snippet =
      file.content
        .substring(0, 200)
        .replace(/\n/g, ' ')
        .trim() + (file.content.length > 200 ? '...' : '');

    return {
      path: file.path.split(sep).join('/'),
      name: file.name,
      // INVERSIONE DELLO SCORE: Fuse usa 0 = perfetto, 1 = pessimo.
      // Noi invertiamo (1 - score) così più alto = più rilevante.
      score: Math.round((1 - (result.score ?? 0)) * 100) / 100,
      snippet,
    };
  });
}

Approfondiamo la configurazione di Fuse perché è il cuore di tutto.

keys è l'array dei campi su cui Fuse cerca, ognuno con un peso diverso. name con peso 2 — il nome del file è il segnale più forte. path con peso 1.5 — il percorso del file. Un file in config/server.json ha il segmento "config" nel path, che matcha bene la query "configurazione". content con peso 1 — il contenuto ha il peso più basso. Serve per trovare file che parlano di un argomento anche se né il nome né il path lo menzionano.

threshold: 0.4 è il livello di tolleranza ai typo. Zero significa match perfetto, uno accetta qualsiasi cosa. Con 0.4, Fuse tollera un paio di errori di battitura senza restituire risultati irrilevanti.

ignoreLocation: true — non ci interessa dove appare il match nella stringa. Senza questo flag, Fuse favorirebbe match vicini all'inizio.

Nei risultati, tre cose da notare. Lo snippet sono i primi 200 caratteri del file con i newline sostituiti da spazi. Claude lo usa per capire al volo di cosa parla il file senza leggerlo tutto. Il path — facciamo split(sep).join('/') per normalizzare i separatori. Su Windows sep è la barra rovesciata, ma noi vogliamo sempre le forward slash nei risultati. Lo score — Fuse restituisce un punteggio dove zero è match perfetto e uno è nessun match. Lo invertiamo: 1 - score. Così per l'utente e per Claude, uno score alto significa risultato migliore.

Notate la firma della funzione: searchKnowledgeBase prende query: string e maxResults: number. Tipi semplici, già validati. Non prende unknown, non fa validazione interna. Sarà l'SDK a validare gli input prima di chiamare questa funzione. Noi scriviamo solo la logica di business — la validazione la deleghiamo al framework. Questo è un principio importante: separate la logica di business dalla validazione dell'input. Il codice diventa più semplice da testare e da ragionare.

Registrare il tool con registerTool

La logica di ricerca è pronta. Adesso dobbiamo dire al server MCP che questo tool esiste — nome, descrizione, schema dei parametri, e cosa fare quando Claude lo chiama.

Apriamo src/index.ts. Dalla lezione precedente abbiamo il server con l'handshake funzionante ma senza tool. Aggiungiamo gli import:

// Zod: validazione runtime. L'SDK lo converte automaticamente in JSON Schema per Claude.
import { z } from 'zod';
// La logica di ricerca che abbiamo appena scritto
import { searchKnowledgeBase } from './tools/search.js';

Tra la creazione del server e la connessione al trasporto, registriamo il tool di ricerca:

// registerTool prende 3 argomenti:
//   1. Nome del tool (identificatore univoco che Claude usa per chiamarlo)
//   2. Configurazione: description (prompt per l'AI) + inputSchema (validazione Zod)
//   3. Handler: funzione eseguita quando Claude chiama il tool
server.registerTool(
  'search_kb',
  {
    description: `Cerca file e contenuti nella knowledge base usando fuzzy matching avanzato.

Questo tool è utile quando:
- L'utente chiede informazioni su un argomento specifico
- Vuoi trovare documentazione relativa a un topic
- Hai bisogno di file che contengono determinate keywords
- Vuoi esplorare cosa c'è nella knowledge base

Il sistema usa fuzzy matching, quindi tollera typo e trova match parziali.
Ritorna i file più rilevanti con score di similarità (più alto = più rilevante).

Esempi di query efficaci:
- "configurazione server" trova config/server.json, docs/setup-server.md
- "autenticazione API" trova api-auth.md, authentication-guide.md
- "deploy produzione" trova deployment.md, production-setup.md`,

    inputSchema: {
      query: z
        .string()
        .min(1)
        .describe(
          'La parola chiave o frase da cercare. Supporta typo e match parziali. Esempi: "configurazione", "API authentication", "setup database"'
        ),
      maxResults: z
        .number()
        .int()
        .min(1)
        .max(20)
        .default(5)
        .describe(
          'Numero massimo di risultati da restituire (default: 5, max: 20)'
        ),
    },
  },
  async ({ query, maxResults }) => {
    try {
      const results = await searchKnowledgeBase(query, maxResults);
      return {
        content: [
          {
            type: 'text',
            text: JSON.stringify(results, null, 2),
          },
        ],
      };
    } catch (error) {
      const message =
        error instanceof Error ? error.message : 'Unknown error';
      logger.error(`search_kb failed: ${message}`);
      return {
        content: [{ type: 'text', text: `Error: ${message}` }],
        isError: true,
      };
    }
  }
);

C'è tantissimo qui. Andiamo con ordine.

La description è un prompt per l'AI

registerTool prende tre argomenti. Il primo è il nome del tool — 'search_kb'. Questo è l'identificatore univoco che Claude usa per chiamarlo.

Il secondo è un oggetto di configurazione con due campi: description e inputSchema.

La description è il punto più critico. Non è un commento per sviluppatori — è un prompt per Claude. Claude legge questa description per decidere quando usare il tool. "Questo tool è utile quando..." dice a Claude i casi d'uso. "Tollera typo e trova match parziali" dice a Claude che può mandare query imperfette. Gli esempi concreti — "configurazione server trova config/server.json" — danno a Claude un modello mentale di cosa aspettarsi.

Se scrivete "Searches the knowledge base" come description, Claude non saprà quando usare il tool, non capirà che la fuzzy search esiste, e i risultati saranno mediocri. Description verbose batte description brevi. Sempre.

Zod e la conversione automatica in JSON Schema

L'inputSchema è dove Zod mostra il suo valore. Guardate cosa succede:

Codice ZodJSON Schema generatoz.string()"type": "string".min(1)"minLength": 1.describe('...')"description": "..."z.number().int()"type": "integer".min(1).max(20)"minimum": 1, "maximum": 20.default(5)"default": 5 + campo diventa opzionale

Non scrivete mai il JSON Schema a mano. Scrivete Zod, e l'SDK fa la conversione. Questo vi dà due vantaggi in uno: la validazione runtime — se Claude manda un numero dove ci vuole una stringa, Zod blocca — E la documentazione per l'AI, tutto dallo stesso codice.

Il JSON Schema completo che Claude riceve, generato automaticamente:

{
  "type": "object",
  "properties": {
    "query": {
      "type": "string",
      "minLength": 1,
      "description": "La parola chiave o frase da cercare..."
    },
    "maxResults": {
      "type": "integer",
      "minimum": 1,
      "maximum": 20,
      "default": 5,
      "description": "Numero massimo di risultati..."
    }
  },
  "required": ["query"]
}

Notate che "required" contiene solo "query", non "maxResults". Questo perché .default(5) in Zod rende il campo opzionale con valore di fallback.

L'handler e la gestione errori

Il terzo argomento di registerTool è l'handler — la funzione asincrona che si esegue quando Claude chiama il tool.

Notate la destrutturazione: ({ query, maxResults }). Questi parametri sono già validati dallo schema Zod — quando il vostro handler li riceve, sono del tipo giusto, rispettano i vincoli, e maxResults ha il default di 5 se non era stato specificato. Non dovete validare niente.

Il return ha una struttura fissa: un oggetto con content — un array di blocchi. Ogni blocco ha un type e un text. Per i risultati della ricerca, serializziamo l'array in JSON.

Il try-catch è fondamentale: se qualcosa va storto — file system non accessibile, errore di Fuse, qualsiasi cosa — catturiamo l'eccezione e la restituiamo come errore con isError: true. Senza il try-catch, un errore nell'handler potrebbe far cadere la connessione. Con il try-catch, la connessione resta viva e Claude riceve un messaggio d'errore pulito. Può decidere cosa fare — riprovare, chiedere all'utente, o comunicare il problema.

Tool 2: Lettura file

Il secondo tool è più semplice: legge il contenuto completo di un file dalla knowledge base. L'utente cerca con search_kb, trova il file giusto, e poi lo legge con read_resource. Due tool che lavorano in coppia.

Scrivere read.ts

Creiamo src/tools/read.ts:

import { readFileSync, statSync } from 'fs';
import { resolve } from 'path';
import { getConfig } from '../lib/config.js';
import { logger } from '../lib/logger.js';

/**
 * Legge il contenuto completo di un file dalla knowledge base.
 * Riceve un path relativo già validato dall'SDK (validazione Zod in index.ts).
 *
 * ⚠️  ATTENZIONE: questo codice è VOLUTAMENTE insicuro.
 * Non valida che il path resti dentro la KB → vulnerabile a path traversal.
 * Esempio: "../../../../etc/passwd" legge file di sistema.
 * Il fix lo scriviamo nella lezione sulla sicurezza.
 */
export async function readResource(relativePath: string): Promise<string> {
  logger.info(`Reading resource: ${relativePath}`);

  const config = getConfig();
  const maxSizeBytes = config.maxFileSizeMB * 1024 * 1024;

  // resolve() unisce la root della KB con il path relativo ricevuto.
  // Es: resolve("/home/user/kb", "docs/setup-guide.md")
  //     → "/home/user/kb/docs/setup-guide.md"
  //
  // ⚠️  VULNERABILITÀ: resolve() segue i ".." senza controlli.
  // resolve("/home/user/kb", "../../etc/passwd") → "/etc/passwd"
  const absolutePath = resolve(config.kbRootPath, relativePath);

  // --- Validazione 1: il file esiste? ---
  let stats;
  try {
    stats = statSync(absolutePath);
  } catch {
    throw new Error(`File not found: ${relativePath}`);
  }

  // --- Validazione 2: è un file, non una directory? ---
  if (!stats.isFile()) {
    throw new Error(`Path is not a file: ${relativePath}`);
  }

  // --- Validazione 3: non supera il limite di dimensione? ---
  if (stats.size > maxSizeBytes) {
    throw new Error(
      `File too large (${Math.round(stats.size / 1024 / 1024)}MB). Maximum: ${config.maxFileSizeMB}MB`
    );
  }

  // --- Lettura del contenuto ---
  try {
    const content = readFileSync(absolutePath, 'utf-8');
    logger.debug(`Read ${content.length} characters from ${relativePath}`);
    return content;
  } catch {
    throw new Error(`Cannot read file: ${relativePath}`);
  }
}

Struttura lineare: prendi il path relativo, risolvilo in assoluto, controlla che il file esista, controlla che sia un file e non una directory, controlla la dimensione, leggi.

La vulnerabilità intenzionale

Guardate la riga resolve(config.kbRootPath, relativePath). resolve prende la root della knowledge base e concatena il path che arriva dall'utente. Semplice.

Ma cosa succede se Claude — o qualcuno — manda come path ../../../../etc/passwd? Funziona. resolve va su di quattro cartelle e punta al file di sistema. Lo legge e lo restituisce. È una vulnerabilità grave: si chiama path traversal attack.

L'ho lasciata apposta. Nella lezione sulla sicurezza faremo vedere l'attacco live dall'Inspector, e poi scriveremo il fix insieme. Così capite il perché prima del come. Per ora, sappiate che questo codice è insicuro. Non mettetelo in produzione così com'è.

Una nota sulle performance: stiamo usando readFileSync — lettura sincrona che carica tutto in memoria. Per file di documentazione da qualche megabyte è perfetto. Se dovessimo gestire file da un gigabyte, passeremmo agli Stream di Node.js. Ma per una knowledge base di testo? Questo va benissimo.

Registrare il tool

Torniamo in src/index.ts e registriamo il secondo tool. Aggiungiamo l'import:

import { readResource } from './tools/read.js';

E la registrazione, subito dopo search_kb:

server.registerTool(
  'read_resource',
  {
    description: `Legge il contenuto completo di un file specifico dalla knowledge base.

Usa questo tool quando:
- Hai già identificato il file esatto che ti serve (tramite search_kb)
- L'utente chiede di leggere un file specifico
- Hai bisogno del contenuto completo per rispondere

Il path deve essere relativo alla root della knowledge base.
File troppo grandi (>10MB) vengono rifiutati con un errore.

Esempi di utilizzo:
- Dopo search_kb trova "config/server.json", usa read_resource con quel path
- L'utente chiede "leggimi il README", cerca prima il file, poi leggilo`,
    inputSchema: {
      path: z
        .string()
        .min(1)
        .describe(
          'Path relativo del file dalla root della KB. Deve essere un path restituito da search_kb o un path conosciuto. Esempio: "docs/setup-guide.md"'
        ),
    },
  },
  async ({ path: filePath }) => {
    try {
      const content = await readResource(filePath);
      return {
        content: [{ type: 'text', text: content }],
      };
    } catch (error) {
      const message =
        error instanceof Error ? error.message : 'Unknown error';
      logger.error(`read_resource failed: ${message}`);
      return {
        content: [{ type: 'text', text: `Error: ${message}` }],
        isError: true,
      };
    }
  }
);

Stesso pattern: nome, configurazione con description e schema Zod, handler con try-catch. L'unica differenza è che il risultato non è JSON — è il testo grezzo del file. Claude riceve il contenuto così com'è e può analizzarlo, riassumerlo, estrarre informazioni.

Un dettaglio: nella destrutturazione rinominiamo path in filePath. Perché? Perché path è anche il nome del modulo Node.js che importiamo in cima. Rinominarlo evita il conflitto. Piccola cosa, ma vi salva da bug sottili.

Guardate la description: "Dopo search_kb trova config/server.json, usa read_resource con quel path". Stiamo dicendo a Claude l'ordine: prima cerca, poi leggi. Claude segue queste istruzioni. Se non glielo dite, potrebbe provare a leggere un file a caso senza prima cercarlo.

Il file index.ts completo

Vediamo tutto insieme:

// --- Dipendenze esterne ---
import dotenv from 'dotenv';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

// --- Moduli interni (dalla lezione 2) ---
import { validateEnvironment } from './lib/config.js';
import { logger } from './lib/logger.js';

// --- Logica dei tool (questa lezione) ---
import { searchKnowledgeBase } from './tools/search.js';
import { readResource } from './tools/read.js';

dotenv.config();
validateEnvironment();

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

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

// ============================================================================
// TOOL 1: search_kb — Ricerca fuzzy nella knowledge base
// ============================================================================
server.registerTool(
  'search_kb',
  {
    description: `Cerca file e contenuti nella knowledge base usando fuzzy matching avanzato.

Questo tool è utile quando:
- L'utente chiede informazioni su un argomento specifico
- Vuoi trovare documentazione relativa a un topic
- Hai bisogno di file che contengono determinate keywords
- Vuoi esplorare cosa c'è nella knowledge base

Il sistema usa fuzzy matching, quindi tollera typo e trova match parziali.
Ritorna i file più rilevanti con score di similarità (più alto = più rilevante).

Esempi di query efficaci:
- "configurazione server" trova config/server.json, docs/setup-server.md
- "autenticazione API" trova api-auth.md, authentication-guide.md
- "deploy produzione" trova deployment.md, production-setup.md`,

    inputSchema: {
      query: z
        .string()
        .min(1)
        .describe(
          'La parola chiave o frase da cercare. Supporta typo e match parziali. Esempi: "configurazione", "API authentication", "setup database"'
        ),
      maxResults: z
        .number()
        .int()
        .min(1)
        .max(20)
        .default(5)
        .describe(
          'Numero massimo di risultati da restituire (default: 5, max: 20)'
        ),
    },
  },
  async ({ query, maxResults }) => {
    try {
      const results = await searchKnowledgeBase(query, maxResults);
      return {
        content: [
          { type: 'text', text: JSON.stringify(results, null, 2) },
        ],
      };
    } catch (error) {
      const message =
        error instanceof Error ? error.message : 'Unknown error';
      logger.error(`search_kb failed: ${message}`);
      return {
        content: [{ type: 'text', text: `Error: ${message}` }],
        isError: true,
      };
    }
  }
);

// ============================================================================
// TOOL 2: read_resource — Lettura di un file dalla knowledge base
// ============================================================================
server.registerTool(
  'read_resource',
  {
    description: `Legge il contenuto completo di un file specifico dalla knowledge base.

Usa questo tool quando:
- Hai già identificato il file esatto che ti serve (tramite search_kb)
- L'utente chiede di leggere un file specifico
- Hai bisogno del contenuto completo per rispondere

Il path deve essere relativo alla root della knowledge base.
File troppo grandi (>10MB) vengono rifiutati con un errore.

Esempi di utilizzo:
- Dopo search_kb trova "config/server.json", usa read_resource con quel path
- L'utente chiede "leggimi il README", cerca prima il file, poi leggilo`,
    inputSchema: {
      path: z
        .string()
        .min(1)
        .describe(
          'Path relativo del file dalla root della KB. Deve essere un path restituito da search_kb o un path conosciuto. Esempio: "docs/setup-guide.md"'
        ),
    },
  },
  async ({ path: filePath }) => {
    try {
      const content = await readResource(filePath);
      return {
        content: [{ type: 'text', text: content }],
      };
    } catch (error) {
      const message =
        error instanceof Error ? error.message : 'Unknown error';
      logger.error(`read_resource failed: ${message}`);
      return {
        content: [{ type: 'text', text: `Error: ${message}` }],
        isError: true,
      };
    }
  }
);

// ============================================================================
// AVVIO DEL SERVER
// ============================================================================
try {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  logger.info('MCP Knowledge Base Server running on stdio');
  logger.info(`Knowledge base path: ${process.env.KB_ROOT_PATH}`);
} catch (error) {
  logger.error('Fatal error starting server:', error);
  process.exit(1);
}

Nessun array di tool da tenere sincronizzato. Nessuno switch/case per smistare le chiamate. Nessun handler generico con type assertion. Ogni tool è un blocco autocontenuto: nome, descrizione, schema, handler. Se domani volete aggiungere un terzo tool — per esempio create_file per creare nuovi documenti — aggiungete un altro registerTool e avete finito. Non dovete toccare nient'altro.

Il try-catch finale segue lo stesso pattern fail-fast della lezione precedente: se qualcosa va storto durante l'avvio — trasporto che non si connette, errore imprevisto — logghiamo e usciamo con codice 1. Se non puoi partire, non provare a restare in vita. Muori subito e dillo chiaramente.

MCP Inspector: demo live

Il server è completo. Due tool, logica di ricerca, logica di lettura. Non lo colleghiamo subito a Claude Desktop — prima lo testiamo con l'MCP Inspector.

L'Inspector è uno strumento ufficiale che vi permette di interagire col server manualmente e vedere i messaggi JSON-RPC grezzi. Pensatelo come i DevTools del browser, ma per MCP. Quando qualcosa non funziona — e succederà — questo è il primo posto dove andare.

Compilare e lanciare

npm run build
npx @modelcontextprotocol/inspector node dist/index.js

L'Inspector si avvia, lancia il server come processo figlio, e apre un'interfaccia web nel browser. Cliccando su "Connect", l'handshake va a buon fine — vedete il nome del server, la versione, e le capabilities. Nella lezione precedente vedevamo solo tools: {} senza dettagli. Adesso il server ha dei tool veri da offrire.

Lista tools e JSON Schema

Cliccando su "List Tools" vediamo i due tool: search_kb e read_resource. E lì c'è il JSON Schema che l'SDK ha generato automaticamente dal codice Zod.

Per search_kb: type: string, minLength: 1, description con il testo che abbiamo scritto nel .describe(). E maxResults è un integer con minimum: 1, maximum: 20, default: 5. Non abbiamo scritto niente di tutto questo a mano — è uscito dal codice Zod. Se cambiamo il codice Zod, il JSON Schema si aggiorna automaticamente. Zero rischio di disallineamento.

Fuzzy search con typo

Facciamo una ricerca con un typo. Chiamiamo search_kb con "cnfig" — manca la "o":

{
  "query": "cnfig"
}

Nonostante il typo, il tool trova i file giusti. config/server.json con uno score alto, e probabilmente anche la guida di setup che menziona "configuration" nel contenuto. Se avessimo usato un semplice indexOf — "il file contiene esattamente la stringa cnfig?" — zero risultati. Con Fuse.js, il motore capisce che "cnfig" è molto vicino a "config".

Un'altra ricerca, più specifica:

{
  "query": "autenticazione",
  "maxResults": 2
}

Troviamo api-authentication.md. La query è in italiano — "autenticazione" — ma il file è in inglese — "authentication". La fuzzy search trova il match perché le due parole condividono la radice.

Lettura file

Leggiamo uno dei file trovati:

{
  "path": "config/server.json"
}

Il contenuto completo arriva nel campo text. Questo è quello che Claude riceve: il JSON grezzo del documento. Claude lo analizza e genera una risposta in linguaggio naturale per l'utente.

Input sbagliato: la validazione Zod in azione

Cosa succede con un input sbagliato? Chiamiamo search_kb senza la query:

{}

Errore. L'SDK ha validato l'input con Zod prima ancora di chiamare il nostro handler. La funzione searchKnowledgeBase non è mai stata eseguita. L'errore è stato catturato al livello del protocollo.

Query vuota:

{
  "query": ""
}

Errore: "String must contain at least 1 character(s)". Viene dal .min(1) nello schema Zod.

maxResults fuori range:

{
  "query": "test",
  "maxResults": 100
}

Errore: "Number must be less than or equal to 20". Il .max(20) ha bloccato il valore.

Senza Zod e senza registerTool, queste validazioni dovreste scriverle a mano dentro ogni handler. Con un if-else per ogni campo, per ogni vincolo. E se vi dimenticate un check? Crash a runtime. Con Zod definite le regole una volta, e la validazione è automatica e completa.

I messaggi JSON-RPC

Nel pannello dei log dell'Inspector si vedono i messaggi JSON-RPC grezzi che sono passati tra il client e il server.

L'handshake iniziale — initialize con le capabilities. Poi tools/list — la lista dei tool. Poi le chiamate tools/call con search_kb e read_resource. E le risposte con i risultati o gli errori.

Questo è il protocollo MCP. Nessuna magia. Solo messaggi JSON che vanno e vengono su stdin e stdout, nel formato JSON-RPC 2.0 che abbiamo visto nella lezione uno. Quando qualcosa non funziona — Claude non trova il vostro tool, i risultati sono vuoti, avete un errore strano — tornate qui. L'Inspector vi mostra esattamente cosa il client ha chiesto e cosa il server ha risposto.

Un server MCP con due tool funzionanti.

search_kb — ricerca fuzzy con Fuse.js. Tollera typo, cerca nel nome del file, nel path e nel contenuto, restituisce risultati ordinati per rilevanza. "cnfig" trova "config". Questo è quello che rende una knowledge base utilizzabile.

read_resource — lettura di file dalla knowledge base, con controllo di dimensione. Semplice, diretto, efficace.

Abbiamo visto come registerTool di McpServer integra Zod: scrivete lo schema una volta e ottenete validazione runtime e documentazione per l'AI, automaticamente.

Abbiamo visto che le description dei tool sono prompt per l'AI — non commenti per umani. Dettagliate, con esempi, con casi d'uso. Questo fa la differenza tra un tool che Claude usa bene e uno che usa a caso.

E abbiamo usato l'MCP Inspector per testare tutto e vedere i JSON-RPC grezzi.

Ma c'è un problema. read_resource legge qualsiasi file gli chiediate. Se mandate ../../../../etc/passwd, lo legge. Se mandate ../../.env, legge le vostre credenziali. Nel prossimo articolo faremo vedere l'attacco live, e poi costruiremo le difese — validazione del path, sanitizzazione dell'input, test di sicurezza automatizzati.

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.