Construye desde cero un MCP Server de gestión de contexto de proyecto con TypeScript: registro de decisiones, gestión de tareas, búsqueda entre sesiones, registro de herramientas, Resources, depuración y publicación.
Los tres artículos anteriores sobre MCP trataban sobre la integración de elementos ya existentes: MCP Servers disponibles, bases de datos, APIs internas. Este artículo es diferente: vamos a construir un MCP Server desde cero, para que haga algo que las herramientas actuales no pueden hacer.
No se trata de envolver la API de otra persona, sino de implementar nuestra propia lógica.
Claude Code tiene un punto débil real: no tiene memoria entre sesiones. Cada vez que inicias una nueva sesión, tienes que volver a explicarle el contexto del proyecto, las decisiones recientes, las tareas en curso. CLAUDE.md resuelve parte del problema, pero es estático y no se actualiza automáticamente conforme avanza el proyecto.
Vamos a crear un MCP Server que permita a Claude:
Los datos se almacenan en un archivo JSON local, sin depender de ningún servicio externo.
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/**/*"]
}
Añade "type": "module" en package.json.
Primero resolvemos la lectura y escritura de datos. Todos los datos se almacenan en .claude/project-context.json en la raíz del proyecto.
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;
}
}
Algunas decisiones de diseño:
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",
});
// ---- Gestión de decisiones ----
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) }],
};
}
);
// ---- Gestión de tareas ----
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) }],
};
}
);
// ---- Inicio ----
const transport = new StdioServerTransport();
server.connect(transport);
Además de herramientas, MCP también soporta Resources: datos que Claude puede leer de forma proactiva. Registramos un «resumen del proyecto» como Resource, para que Claude pueda obtener el contexto completo al iniciar una sesión:
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 diferencia entre Resource y Tool: un Tool es algo que Claude invoca bajo demanda durante la conversación; un Resource es contexto estático que Claude puede leer, similar a CLAUDE.md pero generado dinámicamente.
npx tsc
Configuración de 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"
}
}
}
}
Reinicia Claude Code e introduce /mcp para confirmar que project-context aparece en la lista.
Una vez configurado, las conversaciones con Claude serán así:
Registrar una decisión
Acabamos de discutirlo y decidimos usar Bull + Redis para la cola de tareas,
en lugar de RabbitMQ. La razón principal es que el equipo ya usa Redis
y no queremos introducir una nueva infraestructura.
Anótalo, por favor.
→ Claude invoca record_decision
→ Decisión registrada [a3f2b1c9]: Selección de cola de tareas: Bull + Redis
Búsqueda entre sesiones
En una nueva sesión:
¿Por qué elegimos Bull en lugar de RabbitMQ?
→ Claude invoca search_decisions("Bull")
→ Encuentra el registro anterior y reproduce la justificación completa
Seguimiento de tareas
Hay tres cosas por hacer a continuación:
1. Implementar el endpoint de registro de usuarios
2. Crear la plantilla del correo de registro
3. Añadir el flujo de verificación de correo electrónico
→ Claude invoca add_task tres veces
→ Se crean las tres tareas con estado todo
El endpoint de registro está listo, actualiza el estado.
→ Claude invoca update_task(id, "done")
→ A la vez, lista las tareas pendientes restantes
Tras completar este Server, resumo algunas lecciones prácticas.
Cuando una herramienta MCP falla, Claude le muestra el mensaje de error al usuario. Por eso los mensajes de error deben ser comprensibles:
// Mal
throw new Error("ENOENT");
// Bien
return {
content: [{ type: "text", text: `找不到任务 ${id},用 list_tasks 查看所有任务` }],
isError: true,
};
isError: true le indica a Claude que la llamada falló, y ajustará su comportamiento en consecuencia (por ejemplo, reintentando de otra forma).
Un MCP Server con entre 5 y 10 herramientas es razonable. Con más de 15, la probabilidad de que Claude elija la herramienta incorrecta aumenta notablemente. Si tienes demasiadas capacidades, divídelas en varios Servers.
Que una herramienta devuelva «operación exitosa» no es suficiente. Claude necesita saber cuál fue el resultado de la operación para poder continuar la conversación:
// Mal
return { content: [{ type: "text", text: "OK" }] };
// Bien
return { content: [{ type: "text", text: `已添加任务 [${task.id}]: ${task.title}` }] };
El SDK de MCP soporta zod de forma nativa. Usarlo para definir parámetros no solo permite la validación de tipos, sino que también proporciona instrucciones de uso a Claude a través de .describe(). Sin descripción, Claude solo puede adivinar.
MCP soporta dos mecanismos de transporte: stdio (entrada/salida estándar) y HTTP+SSE. Para MCP Servers de desarrollo local, stdio es suficiente: Claude Code arranca el Server como un proceso hijo y la comunicación se realiza a través de stdin/stdout.
HTTP+SSE es más adecuado para Servers desplegados de forma remota, como servicios MCP proporcionados internamente en una empresa.
Anthropic proporciona una herramienta de depuración llamada MCP Inspector que permite probar tu MCP Server directamente en el navegador:
npx @modelcontextprotocol/inspector node dist/index.js
Lanza una interfaz web donde puedes invocar cada herramienta manualmente, ver las entradas y salidas, y revisar errores. Mucho más eficiente que probar una y otra vez dentro de Claude Code.
El stdout del MCP Server está ocupado (se usa para la comunicación JSON-RPC), así que no puedes usar console.log. Usa console.error para la información de depuración, que se escribe en stderr:
console.error("[debug] processing request:", JSON.stringify(params));
O escribe en un archivo:
import { appendFileSync } from "fs";
function log(msg: string) {
appendFileSync("/tmp/mcp-debug.log", `${new Date().toISOString()} ${msg}\n`);
}
Cuando tu MCP Server esté estable, hay varias formas de distribuirlo:
Paquete npm
Añade el campo bin y un shebang, y publícalo en npm:
{
"name": "@yourcompany/mcp-project-context",
"bin": {
"mcp-project-context": "./dist/index.js"
}
}
Añade en la parte superior de dist/index.js:
#!/usr/bin/env node
Para usarlo:
{
"mcpServers": {
"project-context": {
"command": "npx",
"args": ["-y", "@yourcompany/mcp-project-context"]
}
}
}
Docker
Si el Server tiene dependencias externas (base de datos, Redis), empaquetarlo con Docker es más limpio:
{
"mcpServers": {
"project-context": {
"command": "docker",
"args": ["run", "-i", "--rm", "yourcompany/mcp-project-context"]
}
}
}
Nota el parámetro -i: el transporte stdio necesita mantener stdin abierto.
El ejemplo de este artículo es un MCP Server mínimo pero completo. A partir de él, puedes expandir en muchas direcciones:
Los límites de MCP dependen de tu imaginación. Todo lo que puedas programar puede convertirse en una herramienta para Claude.