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.
Alessandro Saiani
Human in the Loop

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:
| Primitiva | Nome | Cosa fa |
|---|---|---|
| Tool | add_note | Salva un nuovo appunto con titolo, contenuto e tag |
| Tool | search_notes | Cerca negli appunti per parola chiave o tag |
| Resource | notes://all | Lista tutti gli appunti (dati di contesto) |
| Prompt | summarize_project | Template 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:
- 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.
- 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". - 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 effects | I dati sono read-only |
| Il modello decide quando invocarli | Il 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.0senza 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
argsdeve 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.
| Transport | Quando usarlo |
|---|---|
| stdio | Server locale, tool personali, sviluppo |
| Streamable HTTP | Server 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_noteeupdate_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:
- Specifiche complete MCP — la versione più recente del protocollo
- Security best practices — autenticazione OAuth 2.1, PKCE, gestione credenziali
- SDK TypeScript e SDK Python — documentazione completa
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:
- Documentazione ufficiale MCP — Specifiche, tutorial, esempi
- SDK TypeScript — Il pacchetto
@modelcontextprotocol/sdk - SDK Python — FastMCP e il pacchetto
mcp - MCP Inspector — Lo strumento di debug per MCP
- Server Repository — Server ufficiali di riferimento
- PulseMCP — Directory di 8.600+ server community
- Invariant Labs - Tool Poisoning — Analisi sicurezza tool poisoning
- Transports Specification — stdio vs Streamable HTTP
- MCP: Cos'è e Come Ha Cambiato l'AI in 12 Mesi — Il nostro articolo teorico su MCP