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.
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:
Die Daten werden in einer lokalen JSON-Datei gespeichert, ohne Abhängigkeit von externen Diensten.
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.
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:
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);
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.
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.
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
Nach Abschluss dieses Servers folgen einige praktische Erkenntnisse.
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).
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.
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}` }] };
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.
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.
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.
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`);
}
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.
Das Beispiel in diesem Artikel ist ein minimaler, aber vollständiger MCP Server. Darauf aufbauend lässt sich in viele Richtungen erweitern:
Die Grenzen von MCP hängen von deiner Vorstellungskraft ab. Alles, was sich programmieren lässt, kann zu einem Tool für Claude werden.