Ein eigener MCP-Server in einer Stunde: Ein Notizspeicher für den Agenten
Was beim Bau eines minimalen MCP-Servers tatsächlich anfällt: SDK-Auswahl, Tool-Design, Validierung und die Fehler, die man einmal gemacht haben muss.
Nach dem Artikel zur neuen MCP-Spezifikation blieb eine offene Frage: Wie viel Arbeit ist es wirklich, einen eigenen Server zu bauen? Die Antwort aus diesem Versuch: Der Server in diesem Artikel ist in weniger als einer Stunde entstanden, inklusive Tests. Er hat zwei Tools, rund neunzig Zeilen und läuft über stdio. Der interessante Teil ist nicht der Code, sondern wo die eigentliche Arbeit liegt.
Das Beispiel ist schlicht gehalten: ein Notizspeicher, über den ein Agent kurze Einträge ablegen und wiederfinden kann. Das ist ein realer Anwendungsfall, etwa wenn ein Coding-Agent Entscheidungen aus einer früheren Session wiederfinden soll. Wer den Mechanismus einmal verstanden hat, kann dahinter beliebige eigene Systeme hängen.
TypeScript statt Python
Für MCP gibt es offizielle SDKs für TypeScript und Python. Ich habe TypeScript gewählt, aus einem einzigen praktischen Grund: Verbreitung per npx. Ein Agenten-Host startet den Server als Kindprozess, und npx mein-server braucht weder ein venv noch eine systemweite Python-Installation. Beim Python-SDK landet man sonst schnell bei der Frage, welche Umgebung der Host denn nun sieht.
Verwendet wird @modelcontextprotocol/sdk in Version 1.30.0 mit zod für die Eingabeschemas. Die einzigen Abhängigkeiten:
@modelcontextprotocol/sdk
zod
Die zod-Schemas sind keine Dekoration. Das SDK erzeugt daraus die JSON Schemas für tools/list und validiert jede Eingabe, bevor mein Code läuft. Was das konkret bringt, zeigt der Test weiter unten.
Der Server
Der komplette Server, gekürzt nur um die Notiz-Persistenz, die aus readFile/writeFile auf eine JSON-Datei besteht:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "notes-server",
version: "0.1.0",
});
server.registerTool(
"add_note",
{
title: "Notiz speichern",
description:
"Speichert eine kurze Notiz mit Schlagworten im lokalen Notizspeicher.",
inputSchema: {
text: z.string().min(1).max(2000),
tags: z.array(z.string()).max(10).optional(),
},
},
async ({ text, tags }) => {
const notes = await loadNotes();
const note = {
id: crypto.randomUUID().slice(0, 8),
text,
tags: tags ?? [],
created: new Date().toISOString(),
};
notes.push(note);
await saveNotes(notes);
return {
content: [
{ type: "text", text: `Notiz ${note.id} gespeichert (${notes.length} Einträge).` },
],
};
},
);
const transport = new StdioServerTransport();
await server.connect(transport);
Das zweite Tool folgt demselben Muster: eine optionale Suchanfrage, ein limit mit Default, und es filtert Text und Schlagworte:
server.registerTool(
"search_notes",
{
title: "Notizen durchsuchen",
description:
"Durchsucht Text und Schlagworte aller gespeicherten Notizen. Leere Anfrage liefert die neuesten Einträge.",
inputSchema: {
query: z.string().max(200).optional(),
limit: z.number().int().min(1).max(50).default(10),
},
},
async ({ query, limit }) => {
const notes = await loadNotes();
const q = query?.toLowerCase();
const hits = notes
.filter(
(n) =>
!q ||
n.text.toLowerCase().includes(q) ||
n.tags.some((t) => t.toLowerCase().includes(q)),
)
.slice(-limit)
.reverse();
if (hits.length === 0) {
return { content: [{ type: "text", text: "Keine passenden Notizen gefunden." }] };
}
const lines = hits.map(
(n) =>
`[${n.id}] ${n.created.slice(0, 10)} ${n.text}` +
(n.tags.length ? ` (#${n.tags.join(" #")})` : ""),
);
return { content: [{ type: "text", text: lines.join("\n") }] };
},
);
Drei Entscheidungen daran verdienen eine Begründung. Erstens antwortet jedes Tool mit Klartext statt mit strukturiertem JSON. Das Modell, das das Tool aufruft, liest die Antwort als Kontext; ein Text wie „Notiz 48870a7a gespeichert (1 Einträge)” ist dafür besser als ein Objekt, das es erst interpretieren muss. Zweitens bekommt jede Notiz eine kurze, undurchsichtige ID. Damit kann ein späteres Tool gezielt löschen oder aktualisieren, ohne dass das Modell den vollständigen Text wiederholen muss. Drittens liegt der Speicher in einer simplen JSON-Datei. Das ist absichtlich primitiv und gleichzeitig die ehrlichste Grenze des Ganzen, dazu unten mehr.
stdio statt HTTP
Der Server spricht über Standard-Ein- und Ausgabe, nicht über HTTP. Für einen lokalen Server, den genau ein Host als Kindprozess startet, ist das die richtige Wahl: kein Port, kein Zertifikat, keine Authentifizierung. Die Prozessgrenze ist die Sicherheitsgrenze.
HTTP wird interessant, sobald mehrere Clients oder Maschinen denselben Server nutzen sollen. Genau dafür ist die neue stateless Spezifikation gedacht, über die ich hier geschrieben habe. Für den Einstieg ist stdio aber der kürzere Weg, und alles in diesem Artikel gilt für beide Transporte.
Eingehängt wird der Server im Host über einen Eintrag wie diesen:
{
"mcpServers": {
"notes": {
"command": "node",
"args": ["/pfad/zu/server.mjs"],
"env": { "NOTES_FILE": "/pfad/zu/notes.json" }
}
}
}
Testen ohne Agenten-Host
Man muss keinen kompletten Agenten anschliessen, um den Server zu prüfen. Das SDK bringt alles für einen kleinen Test-Client mit, alternativ gibt es den offiziellen MCP Inspector. Mein Test-Client startet den Server, listet die Tools und ruft beide auf:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const transport = new StdioClientTransport({
command: "node",
args: ["server.mjs"],
env: { ...process.env, NOTES_FILE: "./test-notes.json" },
});
const client = new Client({ name: "test-client", version: "0.1.0" });
await client.connect(transport);
const { tools } = await client.listTools();
const add = await client.callTool({
name: "add_note",
arguments: { text: "MCP-Server gebaut und getestet", tags: ["mcp"] },
});
Die echte Ausgabe dieses Laufs:
TOOLS: add_note: Speichert eine kurze Notiz mit Schlagworten im lokalen Notizspeicher.
search_notes: Durchsucht Text und Schlagworte aller gespeicherten Notizen.
ADD: Notiz 48870a7a gespeichert (1 Einträge).
SEARCH: [48870a7a] 2026-07-29 MCP-Server gebaut und getestet (#mcp)
Interessanter sind die Fehlerfälle. Ein Aufruf von add_note mit leerem Text kommt gar nicht erst zu meinem Code:
MCP error -32602: Input validation error: Invalid arguments for tool add_note:
Too small: expected string to have >=1 characters
Und ein Tool, das es nicht gibt:
MCP error -32602: Tool no_such_tool not found
Beides kommt als normale Fehlerantwort zurück, nicht als Absturz. Das ist wichtig, weil der aufrufende Agent diese Meldung lesen und sein Verhalten korrigieren kann. Wer in seinem Tool-Code eine Exception wirft, sollte sie daher ebenfalls in eine lesbare Fehlerantwort übersetzen, statt den Serverprozess sterben zu lassen.
Die Sackgasse: stdout gehört dem Protokoll
Der eine Fehler, den fast jeder beim ersten stdio-Server macht, ist ein unschuldiges console.log("Server startet...") am Anfang der Datei. Bei stdio ist der gesamte Standard-Output der JSON-RPC-Kanal. Jede Zeile, die kein JSON ist, ist Protokollmüll.
Ich habe das ausprobiert, statt mich auf die Warnung in der Dokumentation zu verlassen. Das Ergebnis mit SDK 1.30.0 ist differenzierter als die Folklore: Der Client meldet Unexpected token 'S', "Server startet..." is not valid JSON, die Verbindung überlebt aber, weil der Parser die Zeile verwirft und die nachfolgenden gültigen Nachrichten noch verarbeitet. Funktionsaufrufe liefen in meinem Test trotzdem durch.
Was dieses Beispiel nicht löst
Die JSON-Datei als Speicher ist die grösste verbleibende Schwäche. Sie hat kein Locking: Zwei Hosts, die gleichzeitig schreiben, können Einträge verlieren. Für einen lokalen Server mit einem Client ist das vertretbar, für alles darüber gehört SQLite dahinter. Der Aufwand dafür ist gering, weil sich an der Tool-Schnittstelle nichts ändert.
Zweitens ist die Suche eine reine Substring-Suche. Sobald der Bestand wächst, will man semantische Suche über Embeddings. Auch das ist ein lokales Problem und kein MCP-Problem; die Schnittstelle bleibt dieselbe.
Drittens hängt man an einem SDK, dessen Protokoll sich schnell bewegt. Die Revision 2026-07-28 hat gerade Sessions abgeschafft und mehrere Bestandteile als veraltet markiert. Wer heute einen Server baut, sollte die SDK-Version pinnen und die Release Notes im Blick behalten.
Bilanz
Die MCP-Mechanik ist der einfache Teil: SDK installieren, Tools registrieren, Transport anschliessen. Das ist in einer Stunde erledigt, und der Code in diesem Artikel ist vollständig lauffähig.
Die eigentliche Arbeit liegt im Tool-Design. Die description eines Tools landet im Kontext des Modells und entscheidet darüber, ob der Agent das Tool richtig einsetzt. Die Granularität der Tools entscheidet darüber, wie viele Aufrufe ein Agent für eine Aufgabe braucht. Und die Fehlerantworten entscheiden darüber, ob der Agent aus einem Misserfolg lernen kann oder ratlos wiederholt. Das sind Designfragen, keine Protokollfragen, und sie sind der Grund, warum ein eigener Server trotzdem mehr ist als eine Fingerübung: Man lernt, wie ein Modell die eigene Software sieht.