25 min

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.

stdioStreamable HTTP
Wer startetder Clientder Betrieb
Berechtigt istwer den Prozess starten darfwer sich ausweist
Sitzungeneine, sie ist der Prozessviele, du verwaltest sie
Erreichbar vonnur diesem Rechnerüberall, wo die Adresse hinreicht
Was du zusätzlich bauen musstnichtsAuth, 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.