📚 Tutorial15 minuti di lettura

Costruire un MCP Server: Guida Pratica Step-by-Step

Come costruire un MCP server in TypeScript e Python, testarlo con l'Inspector e collegarlo a Claude Desktop, Claude Code e Cursor. Step by step.

AS

Alessandro Saiani

Human in the Loop

Costruire un MCP Server: Guida Pratica Step-by-Step

Nell'articolo su MCP abbiamo visto cos'è il Model Context Protocol, perché risolve il problema M × N delle integrazioni, e come l'industria l'ha adottato in 12 mesi. Sappiamo che ci sono 16.000+ server pubblici e 97 milioni di download mensili degli SDK.

Ora ne costruiamo uno.

In questa guida creiamo un MCP server funzionante da zero — un "Dev Notes" che salva, cerca e organizza appunti di sviluppo. Non un hello world: un server che mostra tools, resources e prompts in un caso d'uso reale. Prima in TypeScript, poi la versione Python in 25 righe.

Alla fine avrai un server testato con l'Inspector e collegato a Claude Desktop, Claude Code o Cursor.


Cosa Costruiamo

Un server Dev Notes — una knowledge base personale per i tuoi appunti di sviluppo. Perché è il tipo di server che:

  • È immediatamente utile (chi non ha appunti sparsi ovunque?)
  • Mostra tutte e tre le primitive MCP in azione
  • È autocontenuto — niente API esterne, niente database, solo un file JSON

Il server espone:

PrimitivaNomeCosa fa
Tooladd_noteSalva un nuovo appunto con titolo, contenuto e tag
Toolsearch_notesCerca negli appunti per parola chiave o tag
Resourcenotes://allLista tutti gli appunti (dati di contesto)
Promptsummarize_projectTemplate per riassumere gli appunti di un progetto

Quando lo colleghi a Claude, puoi dire cose tipo "Salva un appunto: il bug #342 è causato da un race condition nel modulo pagamenti" e Claude usa il tool add_note. Oppure "Cosa avevo scritto sul refactoring del database?" e usa search_notes.


Setup del Progetto (TypeScript)

Usiamo l'SDK ufficiale TypeScript — @modelcontextprotocol/sdk.

mkdir dev-notes-mcp
cd dev-notes-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D @types/node typescript

Configura tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src/**/*"]
}

E aggiorna package.json:

{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node build/index.js"
  }
}

Il Primo Tool: Salvare un Appunto

Partiamo dal minimo funzionante. Crea src/index.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fs from "fs/promises";
import path from "path";

// Dove salviamo gli appunti
const NOTES_FILE = path.join(process.cwd(), "notes.json");

interface Note {
  id: string;
  title: string;
  content: string;
  tags: string[];
  createdAt: string;
}

async function readNotes(): Promise<Note[]> {
  try {
    const data = await fs.readFile(NOTES_FILE, "utf-8");
    return JSON.parse(data);
  } catch {
    return []; // File non esiste ancora? Array vuoto.
  }
}

async function writeNotes(notes: Note[]): Promise<void> {
  await fs.writeFile(NOTES_FILE, JSON.stringify(notes, null, 2));
}

// --- Creiamo il server ---

const server = new McpServer({
  name: "dev-notes",
  version: "1.0.0"
});

// Tool: salva un appunto
server.tool(
  "add_note",                              // nome del tool
  "Save a development note with tags",     // descrizione per l'LLM
  {                                         // schema input (Zod)
    title: z.string().describe("Note title"),
    content: z.string().describe("Note content"),
    tags: z.array(z.string()).optional().describe("Tags for categorization")
  },
  async ({ title, content, tags }) => {     // handler
    const notes = await readNotes();
    const note: Note = {
      id: crypto.randomUUID(),
      title,
      content,
      tags: tags || [],
      createdAt: new Date().toISOString()
    };
    notes.push(note);
    await writeNotes(notes);
    return {
      content: [{
        type: "text",
        text: `Note saved: "${title}" (id: ${note.id})`
      }]
    };
  }
);

// Connessione via stdio
const transport = new StdioServerTransport();
await server.connect(transport);

Tre cose da notare:

  1. Zod valida automaticamente l'input prima di eseguire l'handler. Se il modello invia parametri sbagliati, il server risponde con un errore strutturato senza che tu debba gestirlo.
  2. La descrizione è per l'LLM, non per l'utente. Scrivi in inglese perché è il linguaggio che i modelli capiscono meglio per il tool use. Sii specifico: "Save a development note with tags" è meglio di "Save note".
  3. Lo schema Zod diventa il JSON Schema che l'LLM vede per decidere quali parametri passare. I .describe() sui singoli campi sono importanti — guidano il modello.

Compila e sei pronto:

npx tsc

Il Secondo Tool: Cercare negli Appunti

Aggiungi la ricerca — subito dopo add_note:

server.tool(
  "search_notes",
  "Search notes by keyword in title/content, optionally filter by tag",
  {
    query: z.string().describe("Search keyword"),
    tag: z.string().optional().describe("Filter by this tag")
  },
  async ({ query, tag }) => {
    const notes = await readNotes();
    const q = query.toLowerCase();

    let results = notes.filter(n =>
      n.title.toLowerCase().includes(q) ||
      n.content.toLowerCase().includes(q)
    );

    if (tag) {
      results = results.filter(n =>
        n.tags.some(t => t.toLowerCase() === tag.toLowerCase())
      );
    }

    if (results.length === 0) {
      return {
        content: [{ type: "text", text: "No notes found." }]
      };
    }

    const formatted = results.map(n =>
      `**${n.title}** [${n.tags.join(", ")}]\n${n.content}`
    ).join("\n\n---\n\n");

    return {
      content: [{ type: "text", text: formatted }]
    };
  }
);

Adesso il modello può sia scrivere che cercare. Ma finora abbiamo usato solo tools — le azioni che il modello invoca attivamente. MCP ha altre due primitive.


Resource: Esporre Dati di Contesto

Una resource è diversa da un tool. Non è il modello a decidere quando leggerla — è il client (l'applicazione host). Pensala come un endpoint GET in REST: espone dati che il client può includere nel contesto quando serve.

server.resource(
  "all-notes",            // nome identificativo
  "notes://all",          // URI della risorsa
  async (uri) => {
    const notes = await readNotes();
    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify(notes, null, 2)
      }]
    };
  }
);

Quando Claude Desktop o Cursor vedono questa resource, possono includerla nel contesto della conversazione. L'utente può anche forzarne il caricamento tramite l'interfaccia. È utile per dare al modello una visione d'insieme senza che debba esplicitamente "cercare".

Quando usare tool vs resource:

Usa un tool quando...Usa una resource quando...
Il modello deve agire (scrivere, calcolare, chiamare API)Vuoi esporre dati di contesto (config, liste, stati)
L'operazione ha side effectsI dati sono read-only
Il modello decide quando invocarliIl client decide quando caricarli

Prompt: Template Riutilizzabili

Un prompt è un template di istruzioni parametrizzabile. Viene esposto all'utente come comando (tipo slash command) — non è il modello a invocarlo, è l'utente a sceglierlo.

server.prompt(
  "summarize_project",
  { project_name: z.string() },
  ({ project_name }) => ({
    messages: [{
      role: "user",
      content: {
        type: "text",
        text: `Read all my development notes and create a summary for the project "${project_name}". Group notes by topic, highlight open issues and action items, and note any decisions that were made.`
      }
    }]
  })
);

In Claude Desktop, questo prompt appare nel menu comandi. L'utente seleziona "summarize_project", inserisce il nome del progetto, e il template viene iniettato nella conversazione. Standardizza workflow che altrimenti riscriveresti ogni volta.


Test: L'MCP Inspector

L'Inspector è il Postman di MCP — una UI web per testare il tuo server senza doverlo collegare a un client.

npx tsc && npx @modelcontextprotocol/inspector node build/index.js

Si apre su http://localhost:6274. Da lì puoi:

  • Vedere la lista dei tools registrati e invocarli con parametri custom
  • Navigare le resources e leggerne il contenuto
  • Testare i prompts con diversi valori
  • Vedere i messaggi JSON-RPC scambiati in tempo reale

Prova ad aggiungere un appunto, poi cercalo. Se funziona nell'Inspector, funziona ovunque.

Nota sulla sicurezza dell'Inspector: la versione precedente (CVE-2025-49596) ascoltava su 0.0.0.0 senza autenticazione. Ora è fixato: binding solo su localhost con token di sessione. Aggiorna sempre alla versione più recente.


Collegare il Server ai Client

Il server funziona. Ora lo colleghiamo ai tool che usi ogni giorno.

Claude Desktop

Modifica il file di configurazione:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json
{
  "mcpServers": {
    "dev-notes": {
      "command": "node",
      "args": ["/path/assoluto/al/build/index.js"]
    }
  }
}

Riavvia Claude Desktop. Il server apparirà nell'icona MCP (il martello) in basso a sinistra. Se non lo vedi, controlla i log in ~/Library/Logs/Claude/.

Path assoluto obbligatorio. Il path negli args deve essere assoluto. Path relativi non funzionano — il working directory del processo non è quello che ti aspetti.

Claude Code

# Aggiungi al progetto corrente
claude mcp add dev-notes node /path/assoluto/al/build/index.js

# Oppure a livello utente (disponibile in tutti i progetti)
claude mcp add dev-notes --scope user node /path/assoluto/al/build/index.js

# Verifica
claude mcp list

Dentro Claude Code, usa /mcp per controllare lo stato dei server connessi.

VS Code (Copilot)

Crea .vscode/mcp.json nella root del progetto:

{
  "mcpServers": {
    "dev-notes": {
      "command": "node",
      "args": ["./build/index.js"]
    }
  }
}

Cursor

Crea .cursor/mcp.json nella root del progetto (o ~/.cursor/mcp.json per configurazione globale):

{
  "mcpServers": {
    "dev-notes": {
      "command": "node",
      "args": ["/path/assoluto/al/build/index.js"],
      "env": {}
    }
  }
}

La Versione Python (25 Righe)

Lo stesso server in Python con FastMCP — il framework ufficiale che alimenta il ~70% dei server MCP:

uv init dev-notes-mcp-py
cd dev-notes-mcp-py
uv add "mcp[cli]"
import json
from pathlib import Path
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("Dev Notes")
NOTES_FILE = Path("notes.json")

def _read():
    return json.loads(NOTES_FILE.read_text()) if NOTES_FILE.exists() else []

def _write(notes):
    NOTES_FILE.write_text(json.dumps(notes, indent=2))

@mcp.tool()
def add_note(title: str, content: str, tags: list[str] | None = None) -> str:
    """Save a development note with optional tags"""
    notes = _read()
    notes.append({"title": title, "content": content, "tags": tags or []})
    _write(notes)
    return f"Saved: {title}"

@mcp.tool()
def search_notes(query: str, tag: str | None = None) -> str:
    """Search notes by keyword, optionally filter by tag"""
    notes = _read()
    q = query.lower()
    results = [n for n in notes
               if q in n["title"].lower() or q in n["content"].lower()]
    if tag:
        results = [n for n in results if tag.lower() in
                   [t.lower() for t in n["tags"]]]
    if not results:
        return "No notes found."
    return "\n---\n".join(
        f"**{n['title']}** [{', '.join(n['tags'])}]\n{n['content']}"
        for n in results
    )

@mcp.resource("notes://all")
def list_all_notes() -> str:
    """List all saved development notes"""
    return json.dumps(_read(), indent=2)

@mcp.prompt()
def summarize_project(project_name: str) -> str:
    """Summarize all notes for a project"""
    return f'Read all notes and summarize project "{project_name}". Group by topic, highlight open issues.'

if __name__ == "__main__":
    mcp.run()

La magia di FastMCP: il nome della funzione diventa il nome del tool, il docstring diventa la descrizione per l'LLM, e i type hint generano automaticamente il JSON Schema. Zero boilerplate.

Per testarlo:

# Con l'Inspector
mcp dev server.py

# Installa in Claude Desktop con un comando
mcp install server.py

5 Trappole che Ti Morderanno

1. console.log() uccide il server

Il transport stdio usa stdout per i messaggi JSON-RPC. Se fai console.log("debug"), quel testo si mischia ai messaggi del protocollo e corrompe tutto. Il server muore senza errori chiari.

Soluzione: usa console.error() per il debug (va su stderr, che è separato). In Python, print(..., file=sys.stderr) o il modulo logging.

Questa è la trappola numero uno. Ogni developer ci casca almeno una volta.

2. SSE è deprecato

Se trovi tutorial che usano SSEServerTransport, sono outdated. Dal spec 2025-06-18, il transport remoto standard è Streamable HTTP — un singolo endpoint HTTP che supporta POST, GET e SSE opzionale per lo streaming.

SSE funziona ancora per backward compatibility, ma le nuove implementazioni devono usare Streamable HTTP.

3. Le descrizioni dei tool contano più di quanto pensi

L'LLM decide quale tool usare e con quali parametri basandosi quasi interamente sulla descrizione e sullo schema. Una descrizione vaga porta a invocazioni sbagliate.

Cattivo: "Search notes" — troppo generico.

Buono: "Search notes by keyword in title/content, optionally filter by tag" — l'LLM sa esattamente cosa può fare e quali parametri sono opzionali.

4. Mai fidarsi dell'input generato dall'LLM

Il tuo server riceve input dal modello AI, non dall'utente. Questo significa che è vulnerabile a prompt injection indiretta: un utente malintenzionato potrebbe inserire istruzioni nei dati che il modello elabora, manipolando i parametri che il modello invia al tuo tool.

Se il tuo tool esegue query SQL, usa sempre query parametrizzate. Mai string concatenation. Mai eval(). Mai exec(). Tratta l'input come untrusted — perché lo è.

5. Auto-approval è un rischio reale

Nei test di Invariant Labs, il tool poisoning — server malevoli che inseriscono istruzioni nascoste nei metadati dei tool — ha avuto un tasso di successo dell'84.2% quando l'auto-approval era attivo.

La regola: conferma manualmente le azioni dei server MCP che non hai scritto tu. Soprattutto quelli che toccano filesystem, database o API con credenziali.


Transport: Locale vs Remoto

Per il nostro server Dev Notes, il transport stdio è perfetto: gira sulla tua macchina, un solo client alla volta, zero configurazione di rete.

Ma se vuoi esporre un server a più utenti o deployarlo in cloud, serve Streamable HTTP:

import { StreamableHTTPServerTransport } from
  "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";

const app = express();
app.use(express.json());

app.post("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: () => crypto.randomUUID()
  });
  await server.connect(transport);
  await transport.handleRequest(req, res);
});

app.listen(3000, () => {
  console.error("MCP server running on http://localhost:3000/mcp");
});

Notare: console.error(), non console.log(). Ormai lo sai.

TransportQuando usarlo
stdioServer locale, tool personali, sviluppo
Streamable HTTPServer remoto, multi-client, cloud, team

Da Qui in Poi

Hai un MCP server funzionante. Ecco dove andare:

Estendi il server Dev Notes:

  • Aggiungi un tool delete_note e update_note
  • Usa SQLite invece di JSON per performance migliori
  • Aggiungi resource template con URI parametrici (notes://tag/{tagName})

Esplora l'ecosistema:

  • Server ufficiali — filesystem, GitHub, PostgreSQL, Slack, Puppeteer e altri
  • PulseMCP — directory di 8.600+ server community
  • MCP Registry — il registry ufficiale per discovery e installazione

Vai più a fondo:

L'ecosistema MCP è esploso da 100 a 16.000 server in poco più di un anno. La curva di apprendimento per costruire un server è bassa — come hai visto, bastano 25 righe in Python. La parte difficile non è il codice: è capire quale problema risolvere e scrivere descrizioni che guidino il modello nel modo giusto.

Il prossimo tool che ti trovi a usare manualmente — query al database, ricerca nei log, gestione di ticket — chiediti: "Potrebbe essere un MCP server?"

La risposta, sempre più spesso, è sì.


Fonti e approfondimenti: