Crea tu propio MCP Server: construye una capacidad a medida para Claude Code

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.

Qué vamos a hacer: un gestor de contexto de proyecto

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:

  • Registrar decisiones y contexto del proyecto («Decidimos usar Redis para la caché, por la razón X»)
  • Gestionar tareas pendientes y en curso
  • Recuperar el contexto registrado previamente en nuevas sesiones

Los datos se almacenan en un archivo JSON local, sin depender de ningún servicio externo.

Inicialización del proyecto

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.

Capa de datos

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:

  • Los ID usan los primeros 8 caracteres de un UUID, lo suficientemente cortos para referirse a ellos verbalmente («actualiza la tarea a3f2b1c9»)
  • Cada operación de escritura se persiste de inmediato, sin caché en memoria
  • La estructura de datos se mantiene intencionalmente simple: lo justo y necesario

Registro de herramientas

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);

Registro de Resources

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.

Compilación y configuración

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.

Uso en la práctica

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

Puntos clave del desarrollo de MCP Servers

Tras completar este Server, resumo algunas lecciones prácticas.

Los errores deben ser amigables

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).

No expongas demasiadas herramientas

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.

Los valores de retorno deben ser informativos

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}` }] };

Usa zod para la validación de parámetros

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.

stdio es el mecanismo de transporte más sencillo

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.

Métodos de depuración

Usa MCP Inspector

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.

Añade logs

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`);
}

Publicación y distribució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.

Siguientes pasos

El ejemplo de este artículo es un MCP Server mínimo pero completo. A partir de él, puedes expandir en muchas direcciones:

  • Añadir una línea de tiempo del proyecto (registrar automáticamente lo que se hizo cada día)
  • Integrar git log (vincular los commits con las decisiones)
  • Soportar múltiples proyectos (un solo Server que gestione el contexto de varios proyectos)
  • Añadir plantillas de prompts (por ejemplo, predefinir un formato de generación para un «informe diario del proyecto»)

Los límites de MCP dependen de tu imaginación. Todo lo que puedas programar puede convertirse en una herramienta para Claude.