Eigenen MCP-Server bauen
Wissen
In diesem Abschnitt baust du Schritt für Schritt einen eigenen MCP-Server. Am Ende hast du einen funktionierenden Server, den du in Claude Desktop oder Cursor einbinden kannst.
Wir bauen einen Notizen-MCP-Server, der:
- Notizen erstellen kann (Tool)
- Notizen lesen kann (Resource)
- Ein Zusammenfassungs-Template bereitstellt (Prompt)
Verstehen
Aufbau eines MCP-Servers
Ein MCP-Server besteht aus vier Teilen:
- Server-Instanz -- Name und Version
- Tool-Definitionen -- Funktionen mit Zod-Validierung
- Resource-Definitionen -- Daten-Endpunkte mit URI
- Transport-Anbindung -- stdio für lokale Server
Bei stdio-Transport ist ein wichtiges Detail zu beachten: stdout wird für JSON-RPC-Kommunikation genutzt. Log-Nachrichten müssen deshalb über stderr (console.error) ausgegeben werden.
Anwenden
Schritt 1: Projekt einrichten
Erstelle ein neues Verzeichnis und initialisiere das Projekt:
mkdir mcp-notizen-server
cd mcp-notizen-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
Erstelle eine tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
Schritt 2: Server-Grundgerüst
Erstelle src/index.ts -- das Herzstück deines Servers:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// In-Memory Speicher für Notizen
const notizen: Map<string, { title: string; content: string; createdAt: string }> = new Map();
// Server erstellen
const server = new McpServer({
name: "notizen-server",
version: "1.0.0",
});
Das ist das Grundgerüst: Du importierst das SDK, erstellst einen Datenspeicher und initialisierst den Server mit Name und Version.
Schritt 3: Tool definieren -- Notiz erstellen
Jetzt fügen wir das erste Tool hinzu:
// Tool: Notiz erstellen
server.tool(
"create_note",
"Erstellt eine neue Notiz mit Titel und Inhalt",
{
title: z.string().describe("Titel der Notiz"),
content: z.string().describe("Inhalt der Notiz"),
},
async ({ title, content }) => {
const id = `note_${Date.now()}`;
notizen.set(id, {
title,
content,
createdAt: new Date().toISOString(),
});
return {
content: [
{
type: "text",
text: `Notiz "${title}" erstellt (ID: ${id})`,
},
],
};
}
);
Beachte die Struktur: server.tool() nimmt vier Parameter:
- Name -- Eindeutiger Bezeichner
- Beschreibung -- Was das Tool macht (für das LLM)
- Schema -- Parameter mit Zod-Validierung
- Handler -- Die eigentliche Logik
Schritt 4: Resource definieren -- Notizen lesen
// Resource: Alle Notizen auflisten
server.resource(
"notes-list",
"notes://list",
async (uri) => {
const alleNotizen = Array.from(notizen.entries()).map(
([id, note]) => `- [${id}] ${note.title} (${note.createdAt})`
);
return {
contents: [
{
uri: uri.href,
mimeType: "text/plain",
text: alleNotizen.length > 0
? alleNotizen.join("\n")
: "Keine Notizen vorhanden.",
},
],
};
}
);
*Resources vs. Tools
Die Resource liest nur Daten -- sie verändert nichts. Das LLM kann diese Resource nutzen, um den Kontext seiner Antworten mit aktuellen Notizen anzureichern.
Schritt 5: Prompt definieren -- Zusammenfassungs-Template
// Prompt: Notizen zusammenfassen
server.prompt(
"summarize_notes",
"Erstellt eine Zusammenfassung aller Notizen",
async () => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Hier sind meine aktuellen Notizen:\n\n${
Array.from(notizen.entries())
.map(([id, n]) => `## ${n.title}\n${n.content}`)
.join("\n\n")
}\n\nBitte erstelle eine strukturierte Zusammenfassung mit den wichtigsten Punkten.`,
},
},
],
})
);
Schritt 6: Server starten
Zum Schluss verbindest du den Server mit dem stdio-Transport:
// Server mit stdio-Transport starten
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Notizen MCP-Server laeuft...");
}
main().catch(console.error);
iconsole.error statt console.log
Bei stdio-Transport wird stdout für die JSON-RPC-Kommunikation genutzt. Deshalb müssen Log-Nachrichten über stderr (console.error) ausgegeben werden, damit sie das Protokoll nicht stören.
Schritt 7: Bauen und testen
# TypeScript kompilieren
npx tsc
# Server testen mit dem MCP Inspector
npx @modelcontextprotocol/inspector dist/index.js
Der MCP Inspector ist ein interaktives Debugging-Tool, mit dem du deinen Server testen kannst, ohne ihn in eine KI-Anwendung einbinden zu müssen.
Schritt 8: In Claude Desktop einbinden
Füge deinen Server zur Claude Desktop-Konfiguration hinzu (claude_desktop_config.json):
{
"mcpServers": {
"notizen": {
"command": "node",
"args": ["/absoluter/pfad/zu/dist/index.js"]
}
}
}
Nach einem Neustart von Claude Desktop erscheint dein Notizen-Server in der Tool-Liste. Du kannst jetzt sagen:
"Erstelle eine Notiz mit dem Titel 'Meeting-Ergebnis' und dem Inhalt 'Budget wurde genehmigt'."
Und Claude wird dein create_note-Tool aufrufen.
Der komplette Code
Hier nochmal die vollständige src/index.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const notizen: Map<string, { title: string; content: string; createdAt: string }> = new Map();
const server = new McpServer({
name: "notizen-server",
version: "1.0.0",
});
server.tool(
"create_note",
"Erstellt eine neue Notiz mit Titel und Inhalt",
{
title: z.string().describe("Titel der Notiz"),
content: z.string().describe("Inhalt der Notiz"),
},
async ({ title, content }) => {
const id = `note_${Date.now()}`;
notizen.set(id, {
title,
content,
createdAt: new Date().toISOString(),
});
return {
content: [{ type: "text", text: `Notiz "${title}" erstellt (ID: ${id})` }],
};
}
);
server.resource(
"notes-list",
"notes://list",
async (uri) => {
const alleNotizen = Array.from(notizen.entries()).map(
([id, note]) => `- [${id}] ${note.title} (${note.createdAt})`
);
return {
contents: [{
uri: uri.href,
mimeType: "text/plain",
text: alleNotizen.length > 0 ? alleNotizen.join("\n") : "Keine Notizen vorhanden.",
}],
};
}
);
server.prompt(
"summarize_notes",
"Erstellt eine Zusammenfassung aller Notizen",
async () => ({
messages: [{
role: "user",
content: {
type: "text",
text: `Hier sind meine aktuellen Notizen:\n\n${
Array.from(notizen.entries())
.map(([id, n]) => `## ${n.title}\n${n.content}`)
.join("\n\n")
}\n\nBitte erstelle eine strukturierte Zusammenfassung.`,
},
}],
})
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Notizen MCP-Server laeuft...");
}
main().catch(console.error);
Warum wird in einem MCP-Server mit stdio-Transport console.error statt console.log für Log-Nachrichten verwendet?
Reflektieren
Einen eigenen MCP-Server zu bauen, gibt dir die volle Kontrolle über die Integration deiner Daten und Dienste. Der wichtigste Punkt: stdout ist für JSON-RPC reserviert -- Logs müssen über stderr laufen. Im nächsten Abschnitt geht es um MCP im Enterprise-Kontext.