Einen eigenen MCP Server bauen: Eine maßgeschneiderte Fähigkeit für Claude Code entwickeln

Einen MCP Server zur Projektkontextverwaltung von Grund auf mit TypeScript bauen — Entscheidungsprotokollierung, Aufgabenverwaltung, sitzungsübergreifende Suche, Tool-Registrierung, Resources, Debugging und Veröffentlichung.


Die drei vorherigen MCP-Artikel drehten sich um die Integration bestehender Komponenten — fertige MCP Server, Datenbanken, interne APIs. Dieser Artikel ist anders: Wir bauen einen MCP Server von Grund auf, der etwas kann, was kein bestehendes Tool bietet.

Es geht nicht darum, die API eines anderen zu wrappen, sondern eigene Logik zu implementieren.

Was wir bauen: einen Projektkontext-Manager

Claude Code hat eine echte Schwachstelle: Es gibt kein sitzungsübergreifendes Gedächtnis. Bei jeder neuen Sitzung muss man den Projektkontext, die jüngsten Entscheidungen und die laufenden Aufgaben erneut erklären. CLAUDE.md löst einen Teil des Problems, ist aber statisch und aktualisiert sich nicht automatisch mit dem Projektfortschritt.

Wir bauen einen MCP Server, mit dem Claude:

  • Projektentscheidungen und Kontext festhalten kann („Wir haben uns für Redis als Cache entschieden, aus Grund X")
  • Offene und laufende Aufgaben verwalten kann
  • In neuen Sitzungen zuvor gespeicherten Kontext abrufen kann

Die Daten werden in einer lokalen JSON-Datei gespeichert, ohne Abhängigkeit von externen Diensten.

Projekt initialisieren

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

Füge "type": "module" in der package.json hinzu.

Datenschicht

Zuerst kümmern wir uns um das Lesen und Schreiben der Daten. Alle Daten werden in .claude/project-context.json im Projektstammverzeichnis gespeichert.

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

Einige Design-Entscheidungen:

  • IDs verwenden die ersten 8 Zeichen einer UUID, kurz genug, um sie mündlich zu referenzieren („aktualisiere Aufgabe a3f2b1c9")
  • Jede Schreiboperation wird sofort persistiert, ohne In-Memory-Cache
  • Die Datenstruktur ist bewusst einfach gehalten — gerade genug für den Zweck

Tools registrieren

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

// ---- Entscheidungsverwaltung ----

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

// ---- Aufgabenverwaltung ----

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

// ---- Start ----

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

Resource registrieren

Neben Tools unterstützt MCP auch Resources — Daten, die Claude proaktiv lesen kann. Wir registrieren eine „Projektübersicht" als Resource, sodass Claude beim Start einer Sitzung den vollständigen Kontext abrufen kann:

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

Der Unterschied zwischen Resource und Tool: Ein Tool wird von Claude bei Bedarf während der Konversation aufgerufen; eine Resource ist statischer Kontext, den Claude lesen kann — ähnlich wie CLAUDE.md, aber dynamisch generiert.

Kompilieren und konfigurieren

npx tsc

Konfiguration von 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"
      }
    }
  }
}

Starte Claude Code neu und gib /mcp ein, um zu überprüfen, dass project-context in der Liste erscheint.

Praktischer Einsatz

Nach der Konfiguration sehen die Gespräche mit Claude so aus:

Eine Entscheidung festhalten

Wir haben gerade besprochen, dass wir Bull + Redis für die Task-Queue nehmen,
statt RabbitMQ. Der Hauptgrund ist, dass das Team Redis bereits nutzt
und wir keine neue Infrastruktur einführen wollen.
Halte das bitte fest.

→ Claude ruft record_decision auf
→ Entscheidung festgehalten [a3f2b1c9]: Task-Queue-Wahl: Bull + Redis

Sitzungsübergreifende Suche

In einer neuen Sitzung:

Warum hatten wir uns für Bull statt RabbitMQ entschieden?

→ Claude ruft search_decisions("Bull") auf
→ Findet den früheren Eintrag und gibt die vollständige Begründung wieder

Aufgaben-Tracking

Als Nächstes stehen drei Dinge an:
1. Den Registrierungs-Endpoint implementieren
2. Das E-Mail-Template für die Registrierung erstellen
3. Den E-Mail-Verifizierungsablauf hinzufügen

→ Claude ruft add_task dreimal auf
→ Die drei Aufgaben werden mit Status todo angelegt
Der Registrierungs-Endpoint ist fertig, aktualisiere den Status.

→ Claude ruft update_task(id, "done") auf
→ Gleichzeitig listet es die verbleibenden offenen Aufgaben auf

Wichtige Punkte bei der MCP-Server-Entwicklung

Nach Abschluss dieses Servers folgen einige praktische Erkenntnisse.

Fehlermeldungen müssen verständlich sein

Wenn ein MCP-Tool fehlschlägt, zeigt Claude die Fehlermeldung dem Benutzer. Deshalb sollten Fehlermeldungen verständlich formuliert sein:

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

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

isError: true signalisiert Claude, dass der Aufruf fehlgeschlagen ist, und es passt sein weiteres Verhalten entsprechend an (z. B. einen anderen Ansatz probieren).

Nicht zu viele Tools exponieren

Ein MCP Server mit 5 bis 10 Tools ist sinnvoll. Ab 15 steigt die Wahrscheinlichkeit, dass Claude das falsche Tool wählt, merklich an. Wenn es zu viele Fähigkeiten gibt, verteile sie auf mehrere Server.

Rückgabewerte müssen informativ sein

Wenn ein Tool nur „Aktion erfolgreich" zurückgibt, reicht das nicht. Claude muss wissen, was das Ergebnis der Aktion war, um die Konversation fortsetzen zu können:

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

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

zod zur Parametervalidierung verwenden

Das MCP SDK unterstützt zod nativ. Damit Parameter zu definieren ermöglicht nicht nur Typvalidierung, sondern liefert Claude über .describe() auch Nutzungshinweise. Ohne Beschreibung kann Claude nur raten.

stdio ist der einfachste Transportmechanismus

MCP unterstützt zwei Transportmechanismen: stdio (Standardein-/-ausgabe) und HTTP+SSE. Für MCP Server in der lokalen Entwicklung reicht stdio: Claude Code startet den Server als Kindprozess, die Kommunikation läuft über stdin/stdout.

HTTP+SSE eignet sich besser für remote bereitgestellte Server, zum Beispiel für zentral angebotene MCP-Dienste innerhalb eines Unternehmens.

Debugging-Methoden

MCP Inspector verwenden

Anthropic stellt ein Debugging-Tool namens MCP Inspector bereit, mit dem man seinen MCP Server direkt im Browser testen kann:

npx @modelcontextprotocol/inspector node dist/index.js

Es startet eine Web-Oberfläche, über die man jedes Tool manuell aufrufen, Ein- und Ausgaben einsehen und Fehler prüfen kann. Deutlich effizienter als wiederholtes Testen in Claude Code.

Logging hinzufügen

Der stdout des MCP Servers ist belegt (er wird für die JSON-RPC-Kommunikation verwendet), daher kann man console.log nicht nutzen. Verwende console.error für Debug-Informationen, die auf stderr geschrieben werden:

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

Oder schreibe in eine Datei:

import { appendFileSync } from "fs";

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

Veröffentlichung und Distribution

Wenn der MCP Server stabil ist, gibt es mehrere Möglichkeiten zur Verteilung:

npm-Paket

Füge das bin-Feld und einen Shebang hinzu und veröffentliche auf npm:

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

Am Anfang von dist/index.js hinzufügen:

#!/usr/bin/env node

Verwendung:

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

Docker

Wenn der Server externe Abhängigkeiten hat (Datenbank, Redis), ist das Packaging mit Docker sauberer:

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

Beachte den Parameter -i — der stdio-Transport erfordert, dass stdin offen bleibt.

Nächste Schritte

Das Beispiel in diesem Artikel ist ein minimaler, aber vollständiger MCP Server. Darauf aufbauend lässt sich in viele Richtungen erweitern:

  • Eine Projekt-Timeline hinzufügen (automatisch protokollieren, was jeden Tag gemacht wurde)
  • Git Log integrieren (Commits mit Entscheidungen verknüpfen)
  • Mehrere Projekte unterstützen (ein Server verwaltet den Kontext mehrerer Projekte)
  • Prompt-Templates hinzufügen (z. B. ein vordefiniertes Format für einen „täglichen Projektbericht")

Die Grenzen von MCP hängen von deiner Vorstellungskraft ab. Alles, was sich programmieren lässt, kann zu einem Tool für Claude werden.