Der leere Server
Der Server ist in zwanzig Zeilen fertig. Die Entscheidung ist der Transport, nicht der Code.
Meilenstein Ein Werkzeug antwortet über stdio und über Streamable HTTP, beide Male gegen denselben Client.
Ein MCP-Server ist kleiner, als sein Ruf vermuten lässt. Zwanzig Zeilen, und ein Client kann ihn befragen. Das ist der Grund, warum die meisten Anleitungen nach diesen zwanzig Zeilen aufhören. Und der Grund, warum der zweite Server dann so unangenehm wird.
Fangen wir trotzdem damit an. Was in diesem Kapitel entsteht, kannst du später wegwerfen; was du dabei siehst, entscheidet über den Rest.
Der Aufbau
mcp-kurs/
package.json
k1/
server.ts
client.ts
Die package.json braucht genau drei Angaben:
{
"name": "simhaven-helpdesk",
"private": true,
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^3.23.8"
}
}
"type": "module" darfst du nicht weglassen. Das SDK liefert ESM, und wer es aus
einem CommonJS-Modul lädt, bekommt eine Fehlermeldung über require von einem
ES-Modul, in der von MCP keine Rede ist. Zehn Minuten Suche, jedes Mal.
Zwanzig Zeilen
// k1/server.ts
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: "simhaven-helpdesk", version: "0.1.0" });
server.registerTool(
"ticket_count",
{
description: "Wie viele Tickets liegen im Helpdesk von Simhaven?",
inputSchema: { status: z.enum(["offen", "geschlossen"]).optional() },
},
async ({ status }) => ({
content: [
{ type: "text", text: JSON.stringify({ status: status ?? "alle", count: 8 }) },
],
}),
);
await server.connect(new StdioServerTransport());
Drei Dinge stecken darin. McpServer ist die Registratur: Er weiß, welche
Werkzeuge es gibt, und beantwortet tools/list. registerTool hängt eines
hinein, mit Name, Beschreibung, Eingabeschema und Rumpf. Und connect heftet das
Ganze an einen Transport, der ab dann JSON-RPC über Standardein- und -ausgabe
spricht.
Auffällig ist, was nicht dasteht. Kein Routing. Kein Serialisieren. Keine
Prüfung der Eingaben. Das Schema aus zod wird vom SDK in JSON Schema übersetzt,
mit der Werkzeugliste verschickt und vor jedem Aufruf durchgesetzt, sodass dein
Rumpf die Argumente schon geprüft und getippt in die Hand bekommt.
Der Client ist auch nur Code
Ein MCP-Server ohne Client behauptet nur etwas. Bevor irgendein Harness ins Spiel kommt, sollte deshalb ein eigener Prüfstand danebenstehen, den du selbst geschrieben hast und der dir bei jedem Aufruf zeigt, was wirklich über die Leitung geht statt was ein fremdes Programm daraus gemacht hat. Er läuft schneller. Er sagt mehr. Und er lügt nicht.
// k1/client.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const transport = new StdioClientTransport({
command: "node",
args: ["--experimental-strip-types", "k1/server.ts"],
});
const client = new Client({ name: "pruefstand", version: "0.1.0" });
await client.connect(transport);
console.log((await client.listTools()).tools.map((t) => t.name));
console.log(await client.callTool({ name: "ticket_count", arguments: { status: "offen" } }));
await client.close();
Der Client startet den Server selbst. Bei stdio ist das der Normalfall, und darin liegt der ganze Unterschied zum Netzwerk. Es gibt keinen laufenden Dienst, den du vorher hochfahren müsstest.
npm install
node --experimental-strip-types k1/client.ts
[ 'ticket_count' ]
{ content: [ { type: 'text', text: '{"status":"offen","count":8}' } ] }
Das ist der komplette Dreitakt aus dem Baustein: verbinden, tools/list,
tools/call. Zwei Zeilen Ausgabe, mehr passiert nicht.
Derselbe Server über HTTP
Und wenn der Server nicht auf dem Rechner des Nutzers laufen soll? Dann tauschst du den Transport. Und stellst fest, dass der Transport das kleinere Problem war.
// k1/http.ts
import http from "node:http";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
function buildServer(): McpServer {
const server = new McpServer({ name: "simhaven-helpdesk", version: "0.1.0" });
server.registerTool(
"ticket_count",
{
description: "Wie viele Tickets liegen im Helpdesk von Simhaven?",
inputSchema: { status: z.enum(["offen", "geschlossen"]).optional() },
},
async ({ status }) => ({
content: [
{ type: "text", text: JSON.stringify({ status: status ?? "alle", count: 8 }) },
],
}),
);
return server;
}
const transports = new Map<string, StreamableHTTPServerTransport>();
const httpServer = http.createServer(async (req, res) => {
if ((req.url ?? "/").split("?")[0] !== "/mcp") {
res.statusCode = 404;
res.end();
return;
}
const header = req.headers["mcp-session-id"];
const sessionId = Array.isArray(header) ? header[0] : header;
const known = sessionId ? transports.get(sessionId) : undefined;
if (known) {
await known.handleRequest(req, res);
return;
}
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => transports.set(sid, transport),
});
transport.onclose = () => {
if (transport.sessionId) transports.delete(transport.sessionId);
};
await buildServer().connect(transport);
await transport.handleRequest(req, res);
});
httpServer.listen(8787, () => console.log("helpdesk auf :8787"));
Aus zwanzig Zeilen sind vierzig geworden, und die zwanzig neuen handeln von
etwas anderem als von Werkzeugen. Sie handeln von Sitzungen. buildServer ist
jetzt eine Funktion, weil jeder Client seinen eigenen Server bekommt. Warum
das keine Geschmacksfrage ist, steht in Kapitel 4.
Der Client dazu kennt keinen Prozess mehr, nur eine Adresse:
// k1/client-http.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const client = new Client({ name: "pruefstand", version: "0.1.0" });
await client.connect(new StreamableHTTPClientTransport(new URL("http://127.0.0.1:8787/mcp")));
console.log((await client.listTools()).tools.map((t) => t.name));
console.log(await client.callTool({ name: "ticket_count", arguments: {} }));
await client.close();
helpdesk auf :8787
[ 'ticket_count' ]
{ content: [ { type: 'text', text: '{"status":"alle","count":8}' } ] }
Dieselbe Antwort, derselbe Werkzeugrumpf, ein anderer Weg dorthin.
Welchen Transport, und warum
Die Faustregel steht im Baustein und stimmt. Läuft es beim Nutzer, nimm stdio; läuft es für mehrere, nimm Streamable HTTP. Nur wird sie in der Praxis meistens falsch herum begründet.
| stdio | Streamable HTTP | |
|---|---|---|
| Wer startet | der Client | der Betrieb |
| Berechtigt ist | wer den Prozess starten darf | wer sich ausweist |
| Sitzungen | eine, sie ist der Prozess | viele, du verwaltest sie |
| Erreichbar von | nur diesem Rechner | überall, wo die Adresse hinreicht |
| Was du zusätzlich bauen musst | nichts | Auth, Deckel, Reaper, Gesundheitsprüfung |
Die letzte Zeile entscheidet. stdio ist die einfachere Betriebsform und nicht die einfachere Bauweise. Der Werkzeugcode bleibt Zeile für Zeile derselbe; was wegfällt, ist alles um ihn herum. Und von diesem Drumherum brauchst du erfahrungsgemäß entweder nichts oder alles.
Ein Werkzeug, das die Zwischenablage liest, das lokale Git bedient oder eine Datei auf der Platte des Nutzers öffnet, gehört nach stdio. Ein Helpdesk, an dem zehn Leute hängen und von dem keiner die Sitzung eines anderen sehen darf, gehört ins Netz. Der Helpdesk dieses Kurses bekommt am Ende beides. Das kann er sich leisten, weil beide Wege denselben Werkzeugcode benutzen und der Unterschied damit auf zwei Startdateien von je sieben Zeilen zusammenschrumpft.