Créer son propre MCP Server : construire une capacité sur mesure pour Claude Code

Construisez de zéro un MCP Server de gestion de contexte projet en TypeScript — enregistrement des décisions, gestion des tâches, recherche inter-sessions, enregistrement d'outils, Resources, débogage et publication.


Les trois articles précédents sur MCP portaient sur l'intégration de composants existants — des MCP Servers disponibles, des bases de données, des API internes. Cet article est différent : nous allons construire un MCP Server de zéro, pour qu'il fasse quelque chose qu'aucun outil existant ne sait faire.

Il ne s'agit pas d'encapsuler l'API de quelqu'un d'autre, mais d'implémenter notre propre logique.

Ce que l'on va construire : un gestionnaire de contexte de projet

Claude Code a un vrai point faible : il n'a pas de mémoire d'une session à l'autre. À chaque nouvelle session, il faut tout réexpliquer — le contexte du projet, les décisions récentes, les tâches en cours. CLAUDE.md résout une partie du problème, mais il est statique et ne se met pas à jour automatiquement au fil de l'avancement du projet.

Nous allons créer un MCP Server qui permettra à Claude de :

  • Enregistrer les décisions et le contexte du projet (« On a choisi Redis pour le cache, pour la raison X »)
  • Gérer les tâches à faire et en cours
  • Retrouver dans une nouvelle session le contexte enregistré précédemment

Les données sont stockées dans un fichier JSON local, sans dépendance à un service externe.

Initialisation du projet

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/**/*"]
}

Ajoutez "type": "module" dans package.json.

Couche de données

On commence par gérer la lecture et l'écriture des données. Toutes les données sont stockées dans .claude/project-context.json à la racine du projet.

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

Quelques choix de conception :

  • Les identifiants utilisent les 8 premiers caractères d'un UUID, assez courts pour être cités à l'oral (« mets à jour la tâche a3f2b1c9 »)
  • Chaque opération d'écriture est persistée immédiatement, sans cache en mémoire
  • La structure de données est volontairement simple — juste ce qu'il faut

Enregistrement des outils

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",
});

// ---- Gestion des décisions ----

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) }],
    };
  }
);

// ---- Gestion des tâches ----

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) }],
    };
  }
);

// ---- Démarrage ----

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

Enregistrement d'une Resource

En plus des outils, MCP prend en charge les Resources — des données que Claude peut lire de manière proactive. On enregistre un « résumé du projet » comme Resource, afin que Claude puisse disposer du contexte complet au démarrage d'une session :

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 différence entre Resource et Tool : un Tool est invoqué par Claude à la demande pendant la conversation ; une Resource est un contexte statique que Claude peut consulter, comparable à CLAUDE.md mais généré dynamiquement.

Compilation et configuration

npx tsc

Configuration 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"
      }
    }
  }
}

Relancez Claude Code et tapez /mcp pour vérifier que project-context apparaît dans la liste.

Utilisation concrète

Une fois configuré, les conversations avec Claude ressemblent à ceci :

Enregistrer une décision

On vient d'en discuter : on part sur Bull + Redis pour la file de tâches,
plutôt que RabbitMQ. La raison principale, c'est que l'équipe utilise déjà
Redis et qu'on ne veut pas ajouter une nouvelle brique d'infrastructure.
Note-le, s'il te plaît.

→ Claude appelle record_decision
→ Décision enregistrée [a3f2b1c9] : Choix de file de tâches : Bull + Redis

Recherche inter-sessions

Dans une nouvelle session :

Pourquoi on avait choisi Bull plutôt que RabbitMQ ?

→ Claude appelle search_decisions("Bull")
→ Il retrouve l'enregistrement précédent et restitue la justification complète

Suivi des tâches

Il y a trois choses à faire ensuite :
1. Implémenter l'endpoint d'inscription des utilisateurs
2. Créer le template de l'e-mail d'inscription
3. Ajouter le flux de vérification d'e-mail

→ Claude appelle add_task trois fois
→ Les trois tâches sont créées avec le statut todo
L'endpoint d'inscription est terminé, mets à jour le statut.

→ Claude appelle update_task(id, "done")
→ Et liste en même temps les tâches restantes à faire

Points clés du développement d'un MCP Server

Après avoir terminé ce Server, voici quelques leçons pratiques.

Les erreurs doivent être compréhensibles

Quand un outil MCP échoue, Claude montre le message d'erreur à l'utilisateur. Les messages d'erreur doivent donc être écrits de manière intelligible :

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

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

isError: true indique à Claude que l'appel a échoué, et il adaptera son comportement en conséquence (par exemple en réessayant autrement).

Ne pas exposer trop d'outils

Un MCP Server qui expose 5 à 10 outils, c'est raisonnable. Au-delà de 15, la probabilité que Claude choisisse le mauvais outil augmente sensiblement. Si les capacités sont trop nombreuses, répartissez-les dans plusieurs Servers.

Les valeurs de retour doivent être informatives

Qu'un outil renvoie « opération réussie » ne suffit pas. Claude a besoin de connaître le résultat de l'opération pour poursuivre la conversation :

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

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

Utiliser zod pour la validation des paramètres

Le SDK MCP prend en charge zod nativement. L'utiliser pour définir les paramètres permet non seulement de valider les types, mais aussi de fournir des instructions d'usage à Claude via .describe(). Sans description, Claude ne peut que deviner.

stdio est le mode de transport le plus simple

MCP supporte deux modes de transport : stdio (entrée/sortie standard) et HTTP+SSE. Pour un MCP Server de développement local, stdio suffit : Claude Code lance le Server en tant que processus enfant, et la communication passe par stdin/stdout.

HTTP+SSE convient mieux aux Servers déployés à distance, par exemple un service MCP mutualisé au sein d'une entreprise.

Méthodes de débogage

Utiliser MCP Inspector

Anthropic fournit un outil de débogage appelé MCP Inspector, qui permet de tester votre MCP Server directement dans le navigateur :

npx @modelcontextprotocol/inspector node dist/index.js

Il lance une interface web où l'on peut appeler chaque outil manuellement, visualiser les entrées et sorties, et vérifier les erreurs. Bien plus efficace que de tester en boucle dans Claude Code.

Ajouter des logs

Le stdout du MCP Server est occupé (il sert à la communication JSON-RPC), on ne peut donc pas utiliser console.log. Utilisez console.error pour les informations de débogage, qui sont écrites sur stderr :

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

Ou écrivez dans un fichier :

import { appendFileSync } from "fs";

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

Publication et distribution

Quand votre MCP Server est stable, plusieurs options de distribution s'offrent à vous :

Package npm

Ajoutez le champ bin et un shebang, puis publiez sur npm :

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

Ajoutez en haut de dist/index.js :

#!/usr/bin/env node

Utilisation :

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

Docker

Si le Server a des dépendances externes (base de données, Redis), le packager avec Docker est plus propre :

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

Notez le paramètre -i — le transport stdio nécessite de garder stdin ouvert.

Et ensuite ?

L'exemple de cet article est un MCP Server minimal mais complet. À partir de là, vous pouvez l'étendre dans de nombreuses directions :

  • Ajouter une timeline du projet (enregistrer automatiquement ce qui a été fait chaque jour)
  • Intégrer le git log (relier les commits aux décisions)
  • Gérer plusieurs projets (un seul Server qui gère le contexte de plusieurs projets)
  • Ajouter des templates de prompts (par exemple, prédéfinir un format de génération pour un « rapport quotidien du projet »)

Les limites de MCP dépendent de votre imagination. Tout ce qui peut s'écrire en code peut devenir un outil pour Claude.