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.
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:
I dati vengono salvati in un file JSON locale, senza dipendenze da servizi esterni.
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.
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:
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);
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.
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.
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
Dopo aver completato questo Server, ecco alcune lezioni pratiche.
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).
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.
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}` }] };
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.
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.
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.
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`);
}
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.
L'esempio di questo articolo è un MCP Server minimale ma completo. A partire da qui, si può espandere in molte direzioni:
I limiti di MCP dipendono dalla tua immaginazione. Tutto ciò che si può programmare può diventare uno strumento per Claude.