Zum Inhalt springen

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:

  1. Server-Instanz -- Name und Version
  2. Tool-Definitionen -- Funktionen mit Zod-Validierung
  3. Resource-Definitionen -- Daten-Endpunkte mit URI
  4. 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:

  1. Name -- Eindeutiger Bezeichner
  2. Beschreibung -- Was das Tool macht (für das LLM)
  3. Schema -- Parameter mit Zod-Validierung
  4. 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.