Max Nardit
← Artikel
9 mindev

MCP-Server bauen 2026: eine TypeScript-Factory, beide Protokoll-Generationen und was der HTTP-Handler des SDK dir überlässt

Eine Factory bedient beide Transporte und beide Generationen von Clients. In einem Testlauf beantwortete der SDK-Handler einen gefälschten Origin mit 200 und gab Clients kein Zeitfenster, in dem sie cachen dürfen, und der Inspector prüfte nur den alten Handshake, bis man ihn nach dem neuen fragte. Die zwei Guards, die du ergänzen musst, die Cache-Hints, die du setzen solltest, und was ein erster Server weglassen kann.

Inhalt

Die offizielle Anleitung zum Bau eines MCP-Servers bringt dich in jeder von acht Sprachen zu einem funktionierenden stdio-Server, und für TypeScript deckt sie die Protokollspaltung, die mit der Revision 2026-07-28 kam, bereits ab. Was sie nicht abdeckt, ist der Betrieb über HTTP. Genau dort hat diese Revision am meisten verändert, und genau dort muss ein Server laufen, den auch andere erreichen sollen.

Ich habe den HTTP-Teil am 15. November 2026 gebaut und getestet. Der HTTP-Handler des TypeScript-SDK bediente neue und alte Clients über einen Endpoint und prüfte die neuen Header gegen den Body. Er beantwortete aber auch einen Request mit Origin: https://evil.example mit 200, schickte List-Ergebnisse ohne Frische-Zeitfenster, und der MCP Inspector testete mit seinen Defaults nur den alten Handshake. Die Lösung für das Erste sind zwei Zeilen, für das Zweite eine Option, für das Dritte ein Flag. Versionen: @modelcontextprotocol/server 2.4.0, @modelcontextprotocol/node 2.1.2, Inspector 2.15.0, Node 22.23.5. Befehle und Ausgaben stammen aus diesem Lauf; wo ich eine Ausgabe gekürzt habe, sage ich es.

Zum Vergleich nehme ich den read-only MCP-Endpoint auf dieser Site, eine Next.js-Route mit 276 Zeilen, die ich im Oktober von Hand geschrieben habe. Sie deckt die Teilmenge ab, die diese Site braucht (Discovery und drei Tools, keine Subscriptions), und wo sie und der SDK-Handler sich unterscheiden, weise ich darauf hin, sobald es relevant wird.

Was hat MCP 2026-07-28 für jemanden geändert, der einen Server baut?

Das Protokoll ist jetzt zustandslos. Der initialize-Handshake, Protokoll-Sessions und der Header Mcp-Session-Id sind weg; jeder Request trägt seine Protokollversion und seine Client-Capabilities in _meta, und Server müssen eine neue Methode server/discover implementieren. Über HTTP spiegelt der Client einen Teil des Bodys in Header, und der Server muss einen Request ablehnen, dessen Header nicht zum Body passen.

Was davon bei dir als Server-Autor landet, laut Changelog und Streamable-HTTP-Binding:

  • Jeder Request nennt seine Version in io.modelcontextprotocol/protocolVersion und seine Capabilities in io.modelcontextprotocol/clientCapabilities, beides innerhalb von params._meta.
  • Jedes Ergebnis trägt resultType, bei einem gewöhnlichen Ergebnis "complete".
  • tools/list und die anderen List-Ergebnisse müssen ttlMs und cacheScope tragen: einen Frische-Hinweis und die Angabe, ob eine gecachte Kopie über einen einzelnen Autorisierungskontext hinaus geteilt werden darf.
  • Jeder HTTP-POST trägt MCP-Protocol-Version und Mcp-Method, dazu Mcp-Name für tools/call, resources/read und prompts/get. Ein fehlender oder abweichender Header bekommt 400 Bad Request mit dem Fehlercode -32020 (HeaderMismatch).
  • Der GET-Stream und ping sind entfernt, ebenso logging/setLevel: Das Log-Level reist jetzt pro Request mit, und das Logging-Feature insgesamt ist deprecated. Ein Server, der nur die neue Revision spricht, beantwortet GET und DELETE mit 405.

Die Spec geht nicht davon aus, dass alle Clients auf einmal umsteigen. Ihre Versioning-Seite nennt einen Server, der beide Arten bedient, „dual-era“ und beschreibt, wie er sie unterscheidet: Ein Request mit dem _meta pro Request wird zustandslos bedient, ein initialize-Request wählt das alte Verhalten. Ein reiner Modern-Server weist einen Legacy-Client mit 400 ab, und die Spec merkt an, dass Legacy-Clients „have no fall-forward mechanism“. Wenn sich die Clients verbinden sollen, die Leute heute schon nutzen, bedienst du beide.

Wie baust du einen MCP-Server mit dem TypeScript-SDK?

Schreib eine Funktion, die einen frischen McpServer erzeugt und deine Tools registriert, und übergib diese Funktion dem Transport, den du bedienst. Das SDK ruft sie für jeden HTTP-Request und für jede stdio-Verbindung auf, auf stdio manchmal noch ein weiteres Mal für einen Discovery-Probe, den es danach verwirft. Sie muss also eine Factory sein, deren Aufruf billig ist, keine geteilte Instanz.

Setz das Projekt mit den Versionen auf, die ich getestet habe. Das Paket Inspector 2.15.0 deklariert Node ^22.23.4, ^24.22.1 oder >=26.12.1, einen engeren Bereich, als seine Doku-Seite angibt.

shell
mkdir notes-mcp && cd notes-mcp
npm init -y && npm pkg set type=module
npm install @modelcontextprotocol/server@2.4.0 @modelcontextprotocol/node@2.1.2 zod@4.6.7
npm install -D typescript@7.0.2 @types/node

Die tsconfig.json, die ich verwendet habe:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "types": ["node"],
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

Der Server, in src/server.ts. Er hat ein read-only Tool über eine fest codierte Liste, damit es im Beispiel um MCP geht und nicht um eine Datenbank:

ts
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
 
const NOTES = [
  { slug: "prompt-cache", title: "How prompt caching changes an agent bill" },
  { slug: "mcp-vs-api", title: "MCP vs a plain HTTP API" },
];
 
// One factory for every entry point: stdio calls it per connection,
// the HTTP handler calls it per request.
export function createServer() {
  const server = new McpServer(
    { name: "notes", version: "1.0.0" },
    {
      // Public, rarely changing data: let clients cache the lists.
      cacheHints: {
        "server/discover": { ttlMs: 3_600_000, cacheScope: "public" },
        "tools/list": { ttlMs: 3_600_000, cacheScope: "public" },
      },
    },
  );
 
  server.registerTool(
    "search_notes",
    {
      title: "Search notes",
      description:
        "Search note titles. Returns titles and canonical URLs, not full text.",
      inputSchema: z.object({
        query: z.string().min(1).max(100).describe('Words to look for, e.g. "prompt".'),
        limit: z.number().int().min(1).max(10).default(5),
      }),
      annotations: { readOnlyHint: true, openWorldHint: false },
    },
    async ({ query, limit }) => {
      const q = query.toLowerCase();
      const results = NOTES.filter((n) => n.title.toLowerCase().includes(q))
        .slice(0, limit)
        .map((n) => ({ title: n.title, url: `https://example.com/notes/${n.slug}` }));
      return {
        content: [{ type: "text", text: JSON.stringify({ results }) }],
        structuredContent: { results },
      };
    },
  );
 
  return server;
}

Der stdio-Einstieg in src/stdio.ts ist das, was die offizielle Anleitung zeigt:

ts
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { createServer } from "./server.js";
 
serveStdio(createServer, { onerror: (e) => console.error("notes:", e) });
console.error("notes MCP server on stdio");

Logge nach stderr, niemals nach stdout: Auf stdio ist stdout der Protokollkanal, und ein verirrtes console.log beschädigt ihn. Die Anleitung warnt außerdem, dass ein von Hand verdrahteter Server mit server.connect(new StdioServerTransport()) zwar weiterhin kompiliert, aber nur Clients der 2025er Generation bedient. serveStdio ist der Teil, der beide beantwortet. Um den Server aus einem Desktop-Client zu nutzen, trag das gebaute build/stdio.js in die MCP-Konfiguration des Clients ein, so wie es der Schritt zu Claude for Desktop in der Anleitung zeigt.

Wie testest du einen MCP-Server mit dem Inspector in beiden Protokoll-Generationen?

Starte die CLI des Inspectors zweimal, einmal mit --protocol-era modern und einmal mit --protocol-era legacy. Der Inspector steht standardmäßig auf legacy und schickt dann nur den alten initialize-Handshake. Ein einzelner grüner Lauf mit Default-Flags sagt dir also nichts über Clients auf der neuen Revision.

shell
npx tsc
npx @modelcontextprotocol/inspector@2.15.0 --cli node build/stdio.js \
  --protocol-era modern --method tools/list --format json
npx @modelcontextprotocol/inspector@2.15.0 --cli node build/stdio.js \
  --protocol-era legacy --method tools/list --format json

Beide Läufe listeten search_notes mit demselben Schema.

Es gibt einen dritten Wert, auto, der server/discover probiert und auf initialize zurückfällt. Das kommt dem, was ein Dual-Era-Client tut, näher, aber die Seite zu den Protokoll-Generationen erklärt, warum es nicht der Default ist: Ein Probe kann an einem stummen Legacy-stdio-Server hängen bleiben. Um deinen eigenen Server zu testen, ist es die klarere Prüfung, jede Generation fest einzustellen.

Wie bedienst du mit einem MCP-Server beide Generationen über Streamable HTTP?

Übergib dieselbe Factory an createMcpHandler. Das liefert ein Handler-Objekt, dessen Methode fetch(request) zu einer web-standardkonformen Response auflöst, und standardmäßig bedient es beide Generationen über einen Endpoint: moderne Requests zustandslos, Legacy-Requests über einen zustandslosen Fallback. Für einen Node-Server passt toNodeHandler aus @modelcontextprotocol/node ihn an, und dasselbe Paket enthält die zwei Guards, die der Handler nicht von sich aus anwendet.

src/http.ts:

ts
import { createServer as createHttpServer } from "node:http";
import { createMcpHandler } from "@modelcontextprotocol/server";
import {
  localhostHostValidation,
  localhostOriginValidation,
  toNodeHandler,
} from "@modelcontextprotocol/node";
import { createServer } from "./server.js";
 
const mcp = toNodeHandler(createMcpHandler(createServer, { responseMode: "json" }));
const hostOk = localhostHostValidation();
const originOk = localhostOriginValidation();
 
createHttpServer((req, res) => {
  if (req.url !== "/mcp") return void res.writeHead(404).end();
  if (!hostOk(req, res) || !originOk(req, res)) return; // already answered 403
  void mcp(req, res);
}).listen(8787, "127.0.0.1");

Auf einer Plattform, die web-standardkonforme Handler annimmt (Workers, Bun, Deno), lässt du den Adapter weg und reichst jeden Request an die fetch-Methode des Handlers weiter.

Die Option legacy bestimmt, wie alte Clients behandelt werden: "stateless" ist der Default, "reject" macht den Endpoint zu einem reinen Modern-Endpoint. responseMode: "json" gibt modernen Requests einfaches JSON zurück, statt auf einen SSE-Stream umzuschalten. Der Preis: Fortschrittsmeldungen, die vor dem Ergebnis gesendet werden, fallen weg, und ein Lookup-Tool sendet keine. Ein subscriptions/listen-Request bekommt trotzdem einen Stream, egal was diese Option sagt.

createMcpHandler ist nicht neu. Es steht in den Typdeklarationen von @modelcontextprotocol/server 2.0.0, veröffentlicht am 27. Juli 2026, einen Tag vor der Spec-Revision, und in 2.3.1, dem neuesten Release, als ich den Endpoint auf dieser Site schrieb. Eine handgeschriebene Route ist also kein Workaround für ein fehlendes SDK-Feature, und ein Server, den du heute anfängst, kann den Handler nutzen.

Bau und teste ihn genauso, über HTTP:

shell
npx tsc
node build/http.js &
npx @modelcontextprotocol/inspector@2.15.0 --cli http://127.0.0.1:8787/mcp --transport http \
  --protocol-era modern --method tools/call --tool-name search_notes --tool-arg query=prompt --format json
npx @modelcontextprotocol/inspector@2.15.0 --cli http://127.0.0.1:8787/mcp --transport http \
  --protocol-era legacy --method tools/call --tool-name search_notes --tool-arg query=prompt --format json

Beide lieferten die passende Notiz. Das hier ist die dekodierte Ausgabe des Inspectors für den Modern-Lauf; der SDK-Client verarbeitet resultType, das auf der Leitung vorhanden ist. Der Legacy-Lauf druckte dasselbe Ergebnis ohne den _meta-Block, weil sich der Server in dieser Generation nur einmal ausweist, im initialize-Ergebnis:

json
{"result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"notes","version":"1.0.0"}},"content":[{"type":"text","text":"{\"results\":[{\"title\":\"How prompt caching changes an agent bill\",\"url\":\"https://example.com/notes/prompt-cache\"}]}"}],"structuredContent":{"results":[{"title":"How prompt caching changes an agent bill","url":"https://example.com/notes/prompt-cache"}]}}}

Was der Handler von sich aus tut, mit curl geprüft: Ein tools/call für search_notes, gesendet mit Mcp-Name: delete_everything im Header, bekam 400 und -32020, und ein GET bekam 405. Die Header-Regel gibt es wegen Gateways. Ein Load Balancer oder eine Policy-Schicht routet womöglich anhand des Headers, ohne den Body zu parsen, während der Server ausführt, was im Body steht. Deshalb müssen beide übereinstimmen.

Was überlässt dir der HTTP-Handler des MCP-SDK?

Zwei Prüfungen und eine Einstellung. Der Handler validierte weder Origin noch Host, und er schickte bei jedem cachebaren Ergebnis ttlMs: 0. Beide Prüfungen zählen am meisten bei einem Server auf deinem eigenen Rechner, denn ein Server, der auf localhost lauscht, ist genau das Ziel von DNS Rebinding.

Origin. Die Spec sagt, Server MUST den Origin-Header validieren, „to prevent DNS rebinding attacks“, und einen ungültigen mit 403 beantworten. In meinem ersten Lauf, nur mit dem Handler, bekam dieser Request ein normales server/discover-Ergebnis und 200:

shell
curl -s http://127.0.0.1:8787/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: server/discover' \
  -H 'Origin: https://evil.example' \
  -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'

Mit den Guards aus src/http.ts davor:

{"jsonrpc":"2.0","error":{"code":-32000,"message":"Invalid Origin: evil.example"},"id":null}
HTTP 403

Host. Derselbe Request mit Host: evil.example bekam stattdessen Invalid Host: evil.example und 403. Das Binden an 127.0.0.1 legt fest, wer sich verbinden kann, nicht welchen Hostnamen der Browser für sein Gegenüber hält, und bei einem Rebinding-Angriff gehört dieser Hostname dem Angreifer.

Beide localhost-Helper lassen nur localhost-artige Namen zu. Ein deployter Server übergibt seine eigene Liste an originValidation() und hostHeaderValidation(), und die Liste enthält Hostnamen, keine URLs: ["example.com"], nicht ["https://example.com"]; Ports werden ignoriert. Requests ohne Origin-Header kommen durch, weil Nicht-Browser-Clients keinen senden. Der Endpoint auf dieser Site entscheidet anders, und das steht in seinem Code-Kommentar: Er ist öffentlich und hält keine Nutzerdaten, also akzeptiert er einen fehlenden Origin, das Literal null und jeden Wert, der sich als URL parsen lässt, und beantwortet alles andere mit 403. Das passt zu öffentlichen read-only Daten. Es ist kein Default zum Abschreiben.

Cache-Hints. Ohne die Option cacheHints kamen server/discover und tools/list mit ttlMs: 0 und cacheScope: "private" zurück. Das erfüllt die Spec, gibt einem Client aber kein Frische-Zeitfenster: Jede gecachte Kopie ist sofort veraltet. Für eine Tool-Liste, die sich nur beim Deploy ändert, habe ich eine Stunde und public gesetzt, und danach trugen beide Ergebnisse "ttlMs":3600000,"cacheScope":"public". Der Preis: Ein Client nutzt nach einem Deploy womöglich bis zu einer Stunde lang die alte Liste weiter, was für öffentliche Daten akzeptabel ist. Mein handgeschriebener Endpoint verwendet dieselben Werte. Eine Liste, die sich pro Nutzer unterscheidet, bleibt private.

Zwei kleinere Überraschungen treffen eine Testsuite. responseMode gilt nur für moderne Requests: Ein Legacy-initialize, mit curl gesendet (mit Accept: application/json, text/event-stream, wie der Transport es verlangt), kam als SSE-Frame zurück, event: message plus eine data:-Zeile, und ein Test, der den Body als JSON parst, verschluckt sich daran. Außerdem listete das server/discover des SDK in supportedVersions nur "2026-07-28", obwohl derselbe Endpoint initialize für 2025-06-18 beantwortete; meiner listet die Legacy-Versionen mit. Ein Dual-Era-Client hängt nicht von dieser Liste ab: Er erkennt die Generation daran, ob sein erster moderner Request erfolgreich ist oder einen modernen Fehler zurückgibt.

Wie entwirfst du ein MCP-Tool-Schema, das ein Modell richtig nutzt?

Halte die Zahl der Tools klein, jedes Tool eng und wo möglich read-only, und sag, was das Tool zurückgibt, nicht nur, was es tut. Das Modell wählt ein Tool anhand von Name, Beschreibung und Schema, und eine Beschreibung wie „returns titles and canonical URLs, not full text“ sagt ihm, wie der nächste Schritt aussehen wird, bevor es den Call überhaupt macht.

Der Endpoint auf dieser Site hat drei Tools: get_contact, list_products und search_articles. Alle drei tragen readOnlyHint: true. Annotations sind Hinweise an den Host, der damit entscheiden kann, wann er den Nutzer fragt; die Spec sagt Clients, sie als nicht vertrauenswürdig zu behandeln, solange der Server es nicht ist. Sie beeinflussen also eine Rückfrage und schützen nichts. Die Suche nimmt höchstens drei Argumente, jedes Schema setzt additionalProperties: false, und der Server erzwingt das Schema selbst: Ein unbekanntes Argument kommt als Tool-Fehler zurück, ein fehlerhafter Call als JSON-RPC -32602.

Zwei Bugs aus seiner Geschichte. Der Lookup des Tool-Namens mit in ließ den String "constructor" auf eine geerbte Property passen und brachte den Handler zum Absturz; jetzt nutzt er Object.hasOwn. Und am 26. Oktober habe ich aufgehört, die Protokollversion 2025-03-26 anzubieten, weil diese Revision die Annahme von JSON-RPC-Batches verpflichtend machte und der Server Batches ablehnt. Ein Client, der danach fragt, bekommt jetzt 2025-11-25 im initialize-Ergebnis zurück, so funktioniert die Aushandlung in dieser Generation.

Bei Zod zeigt sich ein Detail nur in der Ausgabe. Das Schema, das das SDK für z.object veröffentlichte, hatte kein additionalProperties: false, und ein Call mit einem zusätzlichen Argument namens bogus ging ohne Beanstandung durch. Mit z.strictObject deklariert, trug das tools/list desselben Tools "additionalProperties":false, und derselbe Call kam als Tool-Fehler zurück: Unrecognized key: "bogus". Nimm z.strictObject, wenn das Modell es erfahren und der Call abgelehnt werden soll.

Jedes Tool fügt seinen Namen, seine Beschreibung und sein Schema zu dem hinzu, was ein Host in den Kontext laden kann. Über diese Kosten habe ich als die Token-Steuer von Agent-Tools geschrieben.

Was sollte ein erster MCP-Server weglassen?

Auth, die du nicht hast, Zustand zwischen Calls und die Features, die die neue Revision als deprecated markiert hat. Ein read-only Server über öffentliche Daten braucht nichts davon, und alles, was du hinzufügst, ist eine weitere Fläche, die du in zwei Generationen testen musst.

  • Auth. Wenn die Daten öffentlich sind, geh ohne sie live und sag das in den instructions des Servers. Wenn nicht, ist das Autorisierungskapitel der Spec ein eigenes Projekt. Veröffentliche keine OAuth-Discovery-Dokumente für Auth, die du nicht betreibst, nur weil ein Readiness-Checker sie bewertet.
  • Zustand zwischen Calls. Die Revision hat Protokoll-Sessions absichtlich entfernt. Ein Server, der Zustand über Calls hinweg braucht, reicht explizite Handles als Tool-Argumente durch, und ein langer Job gehört in die Tasks-Extension, was ein anderer Build ist.
  • Roots, Sampling und Logging. Alle drei sind in 2026-07-28 deprecated. Logge auf stdio nach stderr, oder nutze OpenTelemetry.
  • Subscriptions. subscriptions/listen ersetzt den alten GET-Stream, und ein Server, dessen Tool-Liste sich nur beim Deploy ändert, hat darauf nichts anzukündigen. Das SDK meldet für Tools standardmäßig listChanged: true, was Clients zum Zuhören einlädt; mein Endpoint meldet Legacy-Clients listChanged: false und modernen ein nacktes tools: {}.

Solltest du überhaupt einen MCP-Server bauen?

Nur wenn schon jemand entschieden hat, dich anzubinden. Das ist die Position, die ich in MCP vs. API für KI-Agents vertreten habe: MCP lohnt sich, wenn eine Host-Anwendung deinen Dienst gezielt integriert, und Agents, die dich beim Lesen im Web finden, sind mit einem einfachen HTTP-Endpoint mit OpenAPI-Beschreibung besser bedient.

Wenn du einen baust, ist der Server inzwischen der billige Teil. Auf einem lokalen Build des Endpoints dieser Site lieferte der Inspector am 15. November 2026 mit modern, legacy und auto dieselben Suchergebnisse, und der eigene End-to-End-Test der Site lief mit null Fehlern durch. Nichts in diesem Lauf sagt, ob jemand von außen ihn aufruft, und ich habe keinen Beleg dafür, dass das jemand tut. Einen Host dazu zu bringen, ihn anzubinden, ist der Teil, den dir kein SDK abnimmt.

01 / Diskussion

Hier gibt es keine Kommentarspalte. Diskussionen laufen auf X.

Max Nardit@mnardit

02 / Weitere Artikel