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:// 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/ 2.4.0, @modelcontextprotocol/ 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.und seine Capabilities inmodelcontextprotocol/ protocolVersion io., beides innerhalb vonmodelcontextprotocol/ clientCapabilities params._meta. - Jedes Ergebnis trägt
resultType, bei einem gewöhnlichen Ergebnis"complete". tools/listund die anderen List-Ergebnisse müssenttlMsundcacheScopetragen: 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-VersionundMcp-Method, dazuMcp-Namefürtools/call,resources/readundprompts/get. Ein fehlender oder abweichender Header bekommt400 Bad Requestmit dem Fehlercode-32020(HeaderMismatch). - Der GET-Stream und
pingsind entfernt, ebensologging/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 mit405.
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.
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/nodeDie tsconfig.json, die ich verwendet habe:
{
"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:
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:
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.
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 jsonBeide 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/ ihn an, und dasselbe Paket enthält die zwei Guards, die der Handler nicht von sich aus anwendet.
src/http.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/ 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:
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 jsonBeide 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:
{"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:
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 403Host. 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":. 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":, 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
instructionsdes 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/listenersetzt 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äßiglistChanged: true, was Clients zum Zuhören einlädt; mein Endpoint meldet Legacy-ClientslistChanged: falseund modernen ein nacktestools: {}.
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.
02 / Weitere Artikel
Agentic Workflows: ein Wort für zwei gegensätzliche Designs, und der Unterschied ist, wem die Schleife gehört
Anthropics Taxonomie und GitHubs Produkt sind sich nicht einig, was der Begriff abdeckt, also sagt das Label nichts über ein Design. Ein Test schon: Gib einem LLM das Steuer nur dann, wenn eine Prüfung von außen, nicht seine eigene Behauptung, bestätigen kann, dass die Arbeit fertig ist.
Anthropics Leitfaden zu Always-on-Agenten: was Inbox, Timer und Outbox tun, wenn ein Worker stirbt, getestet in SQLite
Der Großteil der Fehlerbehandlung, die der Leitfaden auslässt, läuft auf einen einzigen Schritt hinaus: Mach jedes Aufwachen zu einem Inbox-Event mit einem Schlüssel, den die Datenbank kein zweites Mal speichert. Wiederholte Webhooks, Timer-Replays, Antworten auf Freigaben und deren Ablauf passen alle durch diese eine Constraint. Leases, Retries und die Outbox brauchen etwas mehr.
Self-hosted Claude Managed Agents: Der Server gehört dir, und die stillen Fehler auch
Mit Self-Hosting liegen die Transkripte bei dir, und der Betrieb kommt gleich mit. Anthropics Liste der Unterschiede nennt die Fälle, in denen dein Server Erfolg meldet oder gar nichts, während die Arbeit hängt, verloren geht oder ein anderes Modell antwortet. Genau diese Fälle sind der Abnahmetest für ein Deployment.