Creare il proprio MCP Server: costruire una capacità su misura per Claude Code

Costruisci da zero un MCP Server per la gestione del contesto di progetto in TypeScript — registrazione decisioni, gestione task, ricerca tra sessioni, registrazione strumenti, Resource, debug e pubblicazione.


I tre articoli precedenti su MCP riguardavano l'integrazione di componenti già esistenti — MCP Server pronti all'uso, database, API interne. Questo articolo è diverso: costruiremo un MCP Server da zero, per fare qualcosa che gli strumenti attuali non sanno fare.

Non si tratta di wrappare l'API di qualcun altro, ma di implementare la propria logica.

Cosa costruiamo: un gestore di contesto di progetto

Claude Code ha un vero punto debole: non ha memoria tra una sessione e l'altra. Ogni volta che si apre una nuova sessione, bisogna rispiegare il contesto del progetto, le decisioni recenti, i task in corso. CLAUDE.md risolve parte del problema, ma è statico e non si aggiorna automaticamente con l'avanzamento del progetto.

Costruiremo un MCP Server che permetterà a Claude di:

  • Registrare decisioni e contesto del progetto («Abbiamo deciso di usare Redis per la cache, per il motivo X»)
  • Gestire i task pendenti e in corso
  • Recuperare nelle nuove sessioni il contesto registrato in precedenza

I dati vengono salvati in un file JSON locale, senza dipendenze da servizi esterni.

Inizializzazione del progetto

mkdir mcp-project-context && cd mcp-project-context
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

tsconfig.json:

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

Aggiungi "type": "module" nel package.json.

Livello dati

Per prima cosa ci occupiamo della lettura e scrittura dei dati. Tutti i dati vengono salvati in .claude/project-context.json nella root del progetto.

src/store.ts:

import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
import { dirname } from "path";

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

export interface Task {
  id: string;
  title: string;
  status: "todo" | "in-progress" | "done";
  notes: string;
  createdAt: string;
  updatedAt: string;
}

export interface ProjectContext {
  decisions: Decision[];
  tasks: Task[];
}

export class Store {
  private data: ProjectContext;

  constructor(private filePath: string) {
    this.data = this.load();
  }

  private load(): ProjectContext {
    if (!existsSync(this.filePath)) {
      return { decisions: [], tasks: [] };
    }
    return JSON.parse(readFileSync(this.filePath, "utf-8"));
  }

  private save(): void {
    const dir = dirname(this.filePath);
    if (!existsSync(dir)) {
      mkdirSync(dir, { recursive: true });
    }
    writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
  }

  // Decisions
  addDecision(title: string, content: string, tags: string[]): Decision {
    const decision: Decision = {
      id: crypto.randomUUID().slice(0, 8),
      title,
      content,
      tags,
      createdAt: new Date().toISOString(),
    };
    this.data.decisions.push(decision);
    this.save();
    return decision;
  }

  searchDecisions(query: string): Decision[] {
    const q = query.toLowerCase();
    return this.data.decisions.filter(
      (d) =>
        d.title.toLowerCase().includes(q) ||
        d.content.toLowerCase().includes(q) ||
        d.tags.some((t) => t.toLowerCase().includes(q))
    );
  }

  listDecisions(): Decision[] {
    return this.data.decisions;
  }

  // Tasks
  addTask(title: string): Task {
    const task: Task = {
      id: crypto.randomUUID().slice(0, 8),
      title,
      status: "todo",
      notes: "",
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };
    this.data.tasks.push(task);
    this.save();
    return task;
  }

  updateTask(
    id: string,
    updates: Partial<Pick<Task, "status" | "notes" | "title">>
  ): Task | null {
    const task = this.data.tasks.find((t) => t.id === id);
    if (!task) return null;
    Object.assign(task, updates, { updatedAt: new Date().toISOString() });
    this.save();
    return task;
  }

  listTasks(status?: Task["status"]): Task[] {
    if (status) {
      return this.data.tasks.filter((t) => t.status === status);
    }
    return this.data.tasks;
  }
}

Alcune scelte di design:

  • Gli ID usano i primi 8 caratteri di un UUID, abbastanza corti da poterli citare a voce («aggiorna il task a3f2b1c9»)
  • Ogni operazione di scrittura viene persistita immediatamente, senza cache in memoria
  • La struttura dati è volutamente semplice — il minimo indispensabile

Registrazione dei tool

src/index.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { Store } from "./store.js";

const CONTEXT_FILE =
  process.env.CONTEXT_FILE || ".claude/project-context.json";
const store = new Store(CONTEXT_FILE);

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

// ---- Gestione decisioni ----

server.tool(
  "record_decision",
  "记录一条项目决策。当团队做出技术选型、架构决定、或重要的设计取舍时使用",
  {
    title: z.string().describe("决策标题,如「缓存方案选型」"),
    content: z.string().describe("决策内容和原因"),
    tags: z
      .array(z.string())
      .optional()
      .default([])
      .describe("标签,便于后续检索,如 [\"cache\", \"architecture\"]"),
  },
  async ({ title, content, tags }) => {
    const decision = store.addDecision(title, content, tags);
    return {
      content: [
        {
          type: "text",
          text: `已记录决策 [${decision.id}]: ${decision.title}`,
        },
      ],
    };
  }
);

server.tool(
  "search_decisions",
  "搜索已记录的项目决策。当需要回顾之前为什么做某个选择时使用",
  {
    query: z.string().describe("搜索关键词,会匹配标题、内容和标签"),
  },
  async ({ query }) => {
    const results = store.searchDecisions(query);
    if (results.length === 0) {
      return {
        content: [{ type: "text", text: `没有找到与「${query}」相关的决策` }],
      };
    }
    return {
      content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
    };
  }
);

server.tool(
  "list_decisions",
  "列出所有已记录的项目决策",
  {},
  async () => {
    const decisions = store.listDecisions();
    return {
      content: [{ type: "text", text: JSON.stringify(decisions, null, 2) }],
    };
  }
);

// ---- Gestione task ----

server.tool(
  "add_task",
  "添加一个待办任务",
  {
    title: z.string().describe("任务标题"),
  },
  async ({ title }) => {
    const task = store.addTask(title);
    return {
      content: [
        { type: "text", text: `已添加任务 [${task.id}]: ${task.title}` },
      ],
    };
  }
);

server.tool(
  "update_task",
  "更新任务的状态或备注",
  {
    id: z.string().describe("任务 ID"),
    status: z
      .enum(["todo", "in-progress", "done"])
      .optional()
      .describe("新状态"),
    notes: z.string().optional().describe("备注信息"),
    title: z.string().optional().describe("更新标题"),
  },
  async ({ id, status, notes, title }) => {
    const task = store.updateTask(id, { status, notes, title });
    if (!task) {
      return {
        content: [{ type: "text", text: `找不到任务 ${id}` }],
        isError: true,
      };
    }
    return {
      content: [{ type: "text", text: JSON.stringify(task, null, 2) }],
    };
  }
);

server.tool(
  "list_tasks",
  "列出任务。可以按状态筛选",
  {
    status: z
      .enum(["todo", "in-progress", "done"])
      .optional()
      .describe("按状态筛选,不传则列出全部"),
  },
  async ({ status }) => {
    const tasks = store.listTasks(status);
    return {
      content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }],
    };
  }
);

// ---- Avvio ----

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

Registrazione di una Resource

Oltre ai tool, MCP supporta anche le Resource — dati che Claude può leggere in modo proattivo. Registriamo un «riepilogo del progetto» come Resource, così Claude può ottenere il contesto completo all'inizio di una sessione:

server.resource("project-summary", "project://summary", async (uri) => {
  const decisions = store.listDecisions();
  const tasks = store.listTasks();
  const activeTasks = tasks.filter((t) => t.status !== "done");

  const summary = {
    totalDecisions: decisions.length,
    recentDecisions: decisions.slice(-5),
    activeTasks,
    completedTasks: tasks.filter((t) => t.status === "done").length,
  };

  return {
    contents: [
      {
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify(summary, null, 2),
      },
    ],
  };
});

La differenza tra Resource e Tool: un Tool viene invocato da Claude su richiesta durante la conversazione; una Resource è contesto statico che Claude può leggere, simile a CLAUDE.md ma generato dinamicamente.

Compilazione e configurazione

npx tsc

Configurazione di Claude Code:

{
  "mcpServers": {
    "project-context": {
      "command": "node",
      "args": ["/path/to/mcp-project-context/dist/index.js"],
      "env": {
        "CONTEXT_FILE": "/path/to/your-project/.claude/project-context.json"
      }
    }
  }
}

Riavvia Claude Code e digita /mcp per verificare che project-context compaia nella lista.

Utilizzo pratico

Una volta configurato, le conversazioni con Claude si svolgono così:

Registrare una decisione

Abbiamo appena discusso e deciso di usare Bull + Redis per la coda dei task,
invece di RabbitMQ. Il motivo principale è che il team usa già Redis
e non vogliamo introdurre una nuova infrastruttura.
Annotalo, per favore.

→ Claude invoca record_decision
→ Decisione registrata [a3f2b1c9]: Scelta della coda task: Bull + Redis

Ricerca tra sessioni

In una nuova sessione:

Perché avevamo scelto Bull invece di RabbitMQ?

→ Claude invoca search_decisions("Bull")
→ Trova il record precedente e ripropone la motivazione completa

Tracking dei task

Ci sono tre cose da fare:
1. Implementare l'endpoint di registrazione utenti
2. Creare il template dell'e-mail di registrazione
3. Aggiungere il flusso di verifica e-mail

→ Claude invoca add_task tre volte
→ I tre task vengono creati con stato todo
L'endpoint di registrazione è pronto, aggiorna lo stato.

→ Claude invoca update_task(id, "done")
→ Contemporaneamente elenca i task pendenti rimanenti

Punti chiave nello sviluppo di un MCP Server

Dopo aver completato questo Server, ecco alcune lezioni pratiche.

Gli errori devono essere comprensibili

Quando un tool MCP fallisce, Claude mostra il messaggio di errore all'utente. Per questo i messaggi di errore devono essere scritti in modo chiaro:

// Male
throw new Error("ENOENT");

// Bene
return {
  content: [{ type: "text", text: `找不到任务 ${id},用 list_tasks 查看所有任务` }],
  isError: true,
};

isError: true indica a Claude che la chiamata è fallita, e adatterà il suo comportamento di conseguenza (ad esempio, provando un approccio diverso).

Non esporre troppi tool

Un MCP Server con 5-10 tool è ragionevole. Oltre i 15, la probabilità che Claude scelga il tool sbagliato aumenta sensibilmente. Se le capacità sono troppe, suddividile in più Server.

I valori di ritorno devono essere informativi

Un tool che restituisce «operazione riuscita» non basta. Claude ha bisogno di sapere qual è stato il risultato dell'operazione per poter continuare la conversazione:

// Male
return { content: [{ type: "text", text: "OK" }] };

// Bene
return { content: [{ type: "text", text: `已添加任务 [${task.id}]: ${task.title}` }] };

Usare zod per la validazione dei parametri

L'SDK MCP supporta zod nativamente. Usarlo per definire i parametri non solo permette la validazione dei tipi, ma fornisce anche istruzioni d'uso a Claude tramite .describe(). Senza descrizione, Claude può solo tirare a indovinare.

stdio è il meccanismo di trasporto più semplice

MCP supporta due meccanismi di trasporto: stdio (standard input/output) e HTTP+SSE. Per un MCP Server in sviluppo locale, stdio è sufficiente: Claude Code avvia il Server come processo figlio e la comunicazione avviene tramite stdin/stdout.

HTTP+SSE è più adatto a Server distribuiti in remoto, ad esempio servizi MCP centralizzati all'interno di un'azienda.

Metodi di debug

Usare MCP Inspector

Anthropic mette a disposizione uno strumento di debug chiamato MCP Inspector, che permette di testare il proprio MCP Server direttamente nel browser:

npx @modelcontextprotocol/inspector node dist/index.js

Avvia un'interfaccia web dove si può invocare manualmente ogni tool, visualizzare input e output e verificare gli errori. Molto più efficiente che testare ripetutamente dentro Claude Code.

Aggiungere log

Lo stdout del MCP Server è occupato (viene usato per la comunicazione JSON-RPC), quindi non si può usare console.log. Usa console.error per le informazioni di debug, che vengono scritte su stderr:

console.error("[debug] processing request:", JSON.stringify(params));

Oppure scrivi su file:

import { appendFileSync } from "fs";

function log(msg: string) {
  appendFileSync("/tmp/mcp-debug.log", `${new Date().toISOString()} ${msg}\n`);
}

Pubblicazione e distribuzione

Quando il tuo MCP Server è stabile, ci sono diversi modi per distribuirlo:

Pacchetto npm

Aggiungi il campo bin e uno shebang, poi pubblica su npm:

{
  "name": "@yourcompany/mcp-project-context",
  "bin": {
    "mcp-project-context": "./dist/index.js"
  }
}

Aggiungi in cima a dist/index.js:

#!/usr/bin/env node

Per utilizzarlo:

{
  "mcpServers": {
    "project-context": {
      "command": "npx",
      "args": ["-y", "@yourcompany/mcp-project-context"]
    }
  }
}

Docker

Se il Server ha dipendenze esterne (database, Redis), impacchettarlo con Docker è più pulito:

{
  "mcpServers": {
    "project-context": {
      "command": "docker",
      "args": ["run", "-i", "--rm", "yourcompany/mcp-project-context"]
    }
  }
}

Nota il parametro -i — il trasporto stdio richiede che lo stdin resti aperto.

Prossimi passi

L'esempio di questo articolo è un MCP Server minimale ma completo. A partire da qui, si può espandere in molte direzioni:

  • Aggiungere una timeline del progetto (registrare automaticamente cosa è stato fatto ogni giorno)
  • Integrare il git log (collegare i commit alle decisioni)
  • Supportare più progetti (un unico Server che gestisce il contesto di più progetti)
  • Aggiungere template di prompt (ad esempio, predefinire un formato di generazione per un «report giornaliero del progetto»)

I limiti di MCP dipendono dalla tua immaginazione. Tutto ciò che si può programmare può diventare uno strumento per Claude.