Zum Inhalt springen
27/30Kapitel 27 von 30

MCP Server shippen: TypeScript und Python im Messvergleich

Derselbe Server zweimal: drei Tools, eine Ressource, ein prompt. 94 Pakete gegen 28, Cold Start 145 ms gegen 709.

Auf dieser Seite

Hier ist das gesamte Sprachargument, gemessen, bevor auch nur ein Wort davon gemacht wird.

spawn → tools/list answered, median of 25 launchesTEXT
node ./incidents.js         144.5 ms
python incidents.py         709.4 ms
npx incidents-mcp           712.6 ms

Die ersten beiden Zeilen sind der Vergleich, den alle wollen. Die dritte Zeile ist derselbe TypeScript-Server aus der ersten Zeile, gestartet so, wie er tatsächlich verteilt würde — und er landet drei Millisekunden von Python entfernt.

Kapitel 26 hat das Model Context Protocol anhand seiner eigenen Spezifikation mit rohem JSON-RPC gelesen, weil rohes JSON-RPC keine Sprache hat. Dieses Kapitel hat zwei, und das Gewicht des Arguments liegt hier: derselbe Server, zweimal geschrieben. Drei Tools, eine Ressource, ein prompt, beide SDKs, keine Abkürzungen auf einer Seite. Dann die Transports, der Inspector, der 401 und die Zahlen, die niemand veröffentlicht hat.

Der Server, und warum diese fünf Dinge darin sind

Link zum Abschnitt: Der Server, und warum diese fünf Dinge darin sind

Ein Incident-Log. Drei Tools, weil die Trennung aus Kapitel 18 zwischen Reads und Writes sichtbar sein muss: search_incidents liest, open_incident schreibt und gibt ein Handle zurück, resolve_incident nimmt dieses Handle und schließt. Eine Ressource, incidents://open, weil das Lesen der aktuellen Liste etwas ist, das die Anwendung anhängt. Ein prompt, postmortem, weil „schreib das auf“ der Slash-Befehl einer Person ist. Das ist die Kontrollhierarchie aus Kapitel 26 — Modell, Anwendung, Person — in fünf Registrierungen übersetzt.

Das Handle ist wichtiger, als es aussieht. Kapitel 26 hat einen Spielzeugkalender kaputtgemacht, indem es seinen Zustand in einem Array auf Modulebene hielt: Das Protokoll hat keine Session, also gibt ein Creation-Tool einen opaken Identifier zurück und jeder spätere Call nimmt ihn als gewöhnliches Argument. Nichts in einer der beiden Dateien nimmt an, dass der Aufrufer der Prozess ist, der sie geöffnet hat.

Hier ist dasselbe Tool in beiden Sprachen, nebeneinander registriert:

incidents.tsTS
server.registerTool(
  "resolve_incident",
  {
    description:
      "Close an incident by handle and record its cause.",
    inputSchema: {
      id: z.string().describe(
        "The handle returned by open_incident, e.g. INC-3."),
      cause: z.string().describe(
        "One sentence. What actually broke."),
    },
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
    },
  },
  async ({ id, cause }) => {
    const at = OPEN.findIndex((i) => i.id === id);
    if (at < 0) {
      return { isError: true, content: [{ type: "text",
        text: `No open incident ${id}. ` +
              `Call search_incidents first.` }] };
    }
    const [done] = OPEN.splice(at, 1);
    return { content: [{ type: "text",
      text: JSON.stringify({ ...done, cause }) }] };
  },
);
incidents.pyPYTHON
@server.tool(
    description=
      "Close an incident by handle and record its cause.",
    annotations=ToolAnnotations(
        readOnlyHint=False,
        destructiveHint=True,
        idempotentHint=True,
    ),
)
def resolve_incident(
    id: Annotated[str, Field(description=
        "The handle returned by open_incident, e.g. INC-3.")],
    cause: Annotated[str, Field(description=
        "One sentence. What actually broke.")],
) -> Incident:
    for at, i in enumerate(OPEN):
        if i["id"] == id:
            done = OPEN.pop(at)
            return {**done, "cause": cause}
    raise ValueError(
        f"No open incident {id}. Call search_incidents first.")

Lies zuerst, was gleich ist, denn das ist der Befund. Beide deklarieren einen Namen, eine Beschreibung, zwei beschriebene String-Argumente und drei Annotationen; beide sind eine Funktion; keines erwähnt JSON-RPC, Framing, stdout oder eine Protokollversion. Die beiden SDKs sind auf dieselbe Form konvergiert, und genau das soll „Tier 1“ bedeuten.1

Zwei Unterschiede sind real, und beide kommen später wieder. TypeScript beschreibt Argumente mit einer Schema-Library — hier Zod — und das Schema ist ein Wert, den du schreibst. Python beschreibt sie mit den Type Hints der Funktion selbst und liest sie zur Importzeit, weshalb es Dinge über die Funktion weiß, die die TypeScript-Datei ihm nie gesagt hat. Und der Fehlerpfad: TypeScript gibt ein Tool-Ergebnis mit isError zurück, Python wirft. Merk dir das.

Die anderen vier Registrierungen unterscheiden sich strukturell durch nichts. Die Ressource ist server.registerResource("open-incidents", "incidents://open", …) gegen @server.resource("incidents://open", …); der prompt ist registerPrompt gegen @server.prompt. Die letzte Zeile jeder Datei ist der Transport: await server.connect(new StdioServerTransport()) gegen server.run().

Ganze Dateien: 81 nicht-leere Zeilen und 3.060 Bytes TypeScript gegen 63 und 2.555. Nimm das mit der nötigen Prise Salz — Zeilenzahlen messen einen Formatter genauso sehr wie eine Sprache, weshalb keine der beiden Zahlen in der Headline-Tabelle unten steht.

Der Beweis, dass die Sprache unsichtbar ist, ist ein Client, zweimal ausgeführt, in elf Zeilen:

client.tsTS
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const client = new Client({ name: "incident-cli", version: "1.0.0" });
await client.connect(new StdioClientTransport({
  command: process.argv[2], args: process.argv.slice(3) }));

const { tools } = await client.listTools();
console.log("tools:", tools.map((t) => t.name).join(", "));

const opened = await client.callTool({ name: "open_incident",
  arguments: { title: "Queue backed up", severity: "sev2" } });
console.log("open_incident ->", JSON.stringify(opened.content));

Richte ihn nacheinander auf jeden Server. Echte Ausgabe, gekürzt:

TEXT
$ node client.ts node incidents.ts
tools: search_incidents, open_incident, resolve_incident
open_incident -> [{"type":"text","text":"{\"id\":\"INC-3\"}"}]

$ node client.ts ./py/.venv/bin/python ./py/incidents.py
tools: search_incidents, open_incident, resolve_incident
open_incident -> [{"type":"text","text":"{\n  \"id\": \"INC-3\"\n}"}]

Gleiche Tools, gleiche Reihenfolge, gleiches Handle. Ein TypeScript-Client kann nicht erkennen, worin der Server geschrieben ist, und er fragt nie danach. Das ist das ganze Versprechen eines Protokolls, das hier hält.

Schau jetzt auf die Whitespaces im zweiten Ergebnis, denn sie sind nicht kosmetisch: Das Python-SDK serialisiert Payloads mit pydantic_core.to_json(result, fallback=str, indent=2). Beim Lesen der Ressource mit zwei Incidents in der Liste hat der TypeScript-Body 136 Zeichen und 37 o200k_base tokens; der Python-Body hat 185 und 62. Achtundsechzig Prozent mehr tokens für identische Zeilen, bezahlt von demjenigen, der die Ressource in einen prompt liest, jedes Mal.

Der Katalog erzählt dieselbe Geschichte mit einer größeren Ursache. Beide Server, dieselben drei Tools, tools/list Schlüssel für Schlüssel gewogen:

keyTypeScriptPython
name2121
description4646
annotations4646
inputSchema211192
outputSchema187
execution27
total342480

Pythons Input-Schemas sind günstiger — TypeScripts Zod-Bridge stempelt auf jedes einen $schema und einen additionalProperties. Die gesamte Lücke von 138 tokens ist ein Output-Schema, das niemand geschrieben hat. resolve_incident ist mit -> Incident annotiert, also hat das SDK ein JSON Schema für den Rückgabetyp abgeleitet und mitgeschickt. Es ist wirklich nützlich — es ermöglicht einem Client, structuredContent zu validieren — und es sind 187 tokens deiner context window, die wegen eines Type Hint ankommen. Die Regel aus Kapitel 24, dass Definitionen das verdrängen, worauf es ankommt, gilt auch für Schemas, von denen du nicht wusstest, dass du sie hast.

Absichtlich kaputtmachen: die Fehlermeldung, die geleakt ist

Link zum Abschnitt: Absichtlich kaputtmachen: die Fehlermeldung, die geleakt ist

Die beiden Fehlerpfade oben sind keine Stilfrage. Gib jedem Server ein Tool, das so scheitert, wie eine echte Integration scheitert, und lies, was beim Modell ankommt.

tools/call on a tool that raisesTEXT
TypeScript  {"content":[{"type":"text","text":
              "connect ECONNREFUSED 10.0.3.7:5432 (db-prod-eu, user=reporting)"}],
             "isError":true}

Python      {"content":[{"text":"Error executing tool boom","type":"text"}],
             "isError":true}

Das TypeScript-SDK hat eine interne Adresse, einen Port, einen Datenbanknamen und ein Service Account in den Kontext des Modells gelegt. Das Python-SDK hat nichts davon dort abgelegt; der Traceback ging nach stderr und blieb auf dem Server.

Keines davon ist ein Bug. Beides sind Entscheidungen, und die Python-Entscheidung steht in ihrem eigenen Docstring: Ein ToolError ist „ein Fehler, den du erwartet hast“, und seine Nachricht wird „in content zurückgegeben, damit das Modell sie lesen kann“; alles andere „wird als Crash behandelt: Das Modell sieht nur Error executing tool <name>, und der Server loggt den Traceback unter ERROR“. Die Klasse für den Crash-Fall sagt den Rest laut — „nichts vom Original erreicht den Client“.

Beide Verhaltensweisen sind die Hälfte der Zeit falsch. Kapitel 18 argumentierte, dass ein Validierungsfehler als Tool-Ergebnis zurückkommen sollte, das das Modell lesen und korrigieren kann, weil das in den meisten Integrationen die Zeile mit dem höchsten Hebel ist; auf der Python-Seite erfordert das, explizit ToolError zu werfen, und ein nacktes ValueError wirft den nützlichen Satz weg. Das Argument aus Kapitel 30 läuft in die andere Richtung: Alles, was ein Tool zurückgibt, landet in einem Kontext, den eine spätere prompt injection wieder herauszulesen versuchen kann, und ein ungeprüfter Exception-String ist der am wenigsten auditierte Text in deinem System.

Die Regel, die beides überlebt: Entscheide pro Tool, was ein Fehler sagen darf, und schreib diesen String selbst. Lass niemals den Default-Text einer Exception entscheiden, in keiner der beiden Sprachen.

Absichtlich kaputtmachen: eine Zeile auf Standard Output

Link zum Abschnitt: Absichtlich kaputtmachen: eine Zeile auf Standard Output

Das offizielle Tutorial formuliert die Regel ohne Absicherung: „For STDIO-based servers: Never write to stdout. Writing to stdout will corrupt the JSON-RPC messages and break your server. The print() function writes to stdout by default, so keep it out of a STDIO server entirely.“1 Kapitel 26 zitierte die normative Version — ein Server „MUST NOT write anything to its stdout that is not a valid MCP message“.2

Füge jedem Server eine Zeile hinzu und lies den Raw Stream:

raw stdout, first two linesTEXT
TypeScript  incidents server starting
            {"result":{"protocolVersion":"2025-11-25", … },"jsonrpc":"2.0","id":1}

Python      {"jsonrpc":"2.0","id":1,"result":{ … }}
            incidents server starting

Der Python-Server ist schlimmer, und der Grund ist nicht MCP. Ein Prozess, dessen stdout eine Pipe statt ein Terminal ist, bekommt einen blockgepufferten Stream, also wird die Streuzeile geflusht, wann immer der Buffer entscheidet — hier beim Exit, nach einer Antwort, vor der sie geschrieben wurde. Die Korruption erscheint nicht dort, wo der Bug ist. Füge flush=True hinzu, oder eine Library, die flusht, und sie wandert.

Dann der Teil, der erklärt, warum so etwas shipped. Füttere den kaputten Server an drei Clients:

TEXT
naive parser, dirty server   SyntaxError: Unexpected token 'i',
                             "incidents "... is not valid JSON
SDK client, dirty server     tools: search_incidents, open_incident, resolve_incident
MCP Inspector, dirty server  full catalogue, no warning

Der Sieben-Zeilen-Parser stirbt sofort. Der offizielle Client und der Inspector zucken beide mit den Schultern — sie überspringen die Zeile und machen weiter. Eine Regel, die nur die Clients bricht, die niemand benutzt, erreicht die Produktion intakt; deshalb lohnt es sich, sie hier absichtlich zu brechen, statt im Log eines Kunden.

Der CLI-Modus des Inspector ist die Hälfte, die vergessen wird: npx @modelcontextprotocol/inspector --cli <command> --method tools/list druckt einen Katalog und beendet sich, wodurch er scriptbar wird auf eine Weise, die die Browser-UI nicht ist.3

Beide SDKs wurden sauber installiert, in eigene Verzeichnisse, nichts geteilt:

TypeScriptPython
package@modelcontextprotocol/sdk 1.30.0 + zod 3.25.76mcp 2.1.1
latest protocol revision implemented2025-11-252026-07-28
transitive packages installed9428
installed size13.9 MiB44.3 MiB
files on disk3,3862,018
third-party packages loaded to serve stdio8 of 9418 of 28
bare interpreter start, median19.4 ms11.1 ms
spawn → tools/list answered, median of 25144.5 ms709.4 ms
tools/list catalogue, o200k_base tokens342480

Jede Zeile überrascht in eine andere Richtung, weshalb es sich lohnt, den Vergleich auszuführen, statt ihn anzunehmen.

TypeScript installiert mehr als dreimal so viele Pakete und weniger als ein Drittel der Bytes. 94 Dependencies sind das npm-Ökosystem, wie es eben ist — fast-deep-equal, es-errors, dunder-proto. Pythons 28 sind weniger und riesig: cryptography, pydantic-core und uvicorn sind kompilierte Artefakte. Wenn dein Instinkt sagt, dass die Anzahl der Dependencies das Problem ist, ist diese Zeile das Gegenbeispiel.

Pythons Interpreter startet schneller als Node, und zwar deutlich — 11,1 ms gegen 19,4 ms bei einem leeren Programm. Die 565 ms in der Cold-Start-Zeile sind also nicht die Sprache. Es ist das SDK, und die Zeile zu geladenen Paketen sagt warum:

third-party modules loaded to answer one tools/list over stdioTEXT
TypeScript   8 of 94   sdk, zod, zod-to-json-schema, ajv, ajv-formats,
                       fast-deep-equal, fast-uri, json-schema-traverse

Python      18 of 28   mcp, mcp_types, pydantic, pydantic_core, anyio,
                       starlette, uvicorn, sse_starlette, httpx2,
                       cryptography, _cffi_backend, opentelemetry, click, …

Ein Server, dessen einziges I/O eine Pipe ist, importiert einen ASGI-Webserver, einen HTTP-Client und eine TLS-Library, bevor er seine erste Zeile liest. Das TypeScript-SDK bringt Express, Hono, jose und eventsource ebenfalls mit — sie liegen ungelesen auf der Platte, weil die Package-Grenze sie aus einem server/stdio.js-Import heraushält. Pythons Package ist ein Import-Graph, also ist import mcp alles davon: python -X importtime schreibt 727 ms import mcp.server.mcpserver zu — ein Wert, der unter dem Import-Profiler gemessen wurde, weshalb er über den 709 ms liegt, die der unprofilierte Lauf von Spawn bis Antwort braucht — und 269 davon allein dem mcp.types-Subtree. Die Wire Types sind Pydantic-Modelle, eine Klasse pro Protokollnachricht pro Revision, und sie zu bauen ist Arbeit beim Import. Das ist ein Design Trade-off, keine Schlamperei — eager Imports sind der Grund, warum das Python-SDK dir in der nächsten Zeile run(transport="streamable-http") geben kann, ohne eine zweite Installation.

Und dann hebt die letzte Zeile des Eröffnungsblocks das Argument wieder auf. Packe den TypeScript-Server ordentlich — ein bin-Eintrag, ein Shebang, npm link, nichts herunterzuladen — und starte ihn über npx mit --no-install, also so, wie ein veröffentlichter stdio-Server tatsächlich gestartet wird:

median of 25, spawn → tools/list answeredTEXT
node ./incidents.js       144.5 ms
npx incidents-mcp         712.6 ms      (+568.1 ms of launcher)
python incidents.py       709.4 ms

Der Launcher kostet 568 ms pro Start — viereinhalbmal so viel wie der gesamte Import des TypeScript-SDK — und er wird bei jedem Launch bezahlt, weil ein MCP Host einen stdio-Server startet, indem er diesen Command ausführt. Die ehrliche Form von „TypeScript startet fünfmal schneller“ lautet also: tut es, bis du es auf die normale Weise verteilst. Dieselbe Einschränkung gilt vermutlich für uvx; auf dieser Maschine war kein uv installiert, also existiert diese Zeile nicht. Nichts Ungemessenes kommt in die Tabelle.

Kapitel 26 behandelte stdios Framing. Zwei Dinge blieben für hier übrig.

Das erste: Einen Server mit npx oder uvx auszuführen ist der stdio-Transport. Es gibt keinen separaten „Package-Modus“. Die Konfiguration eines Hosts nennt einen Command und Argumente; der Host spawnt ihn und spricht über die Pipes. Deshalb sind „wie verteile ich das“ und „welchen Transport spricht es“ lokal eine Frage, und deshalb gehören die Kosten des Launchers in ein Kapitel über Shipping.

Das zweite: stdio hat überhaupt keinen Abschnitt zur Autorisierung, und die Spezifikation sagt das in einer Zeile — Implementierungen, die stdio verwenden, „SHOULD NOT follow this specification, and instead retrieve credentials from the environment“.4 Sein Sicherheitsmodell ist das des Betriebssystems, und seine Grenze auch: Ein lokaler Subprozess bedient genau eine Maschine und einen Benutzer.

Der andere Live-Transport ist Streamable HTTP: ein einzelner Endpoint, der POST akzeptiert, ein HTTP-Request pro JSON-RPC-Nachricht, und ein Accept-Header, der sowohl application/json als auch text/event-stream auflisten muss, weil der Server pro Request auswählt, mit welchem von beiden er antwortet.5 Kapitel 14 hat diesen Event Stream von Hand geparst, also ist am Wire Format nichts neu — nur das, was es umhüllt. Drei Pflichten der aktuellen Revision übersieht man leicht, und alle drei sind testbar:

Jeder POST trägt MCP-Protocol-Version, und sein Wert muss zum protocolVersion im eigenen _meta des Requests passen. Ein Mismatch ist ein 400 mit einem Header-Mismatch-Fehler, kein Schulterzucken.5

Zwei weitere Header sind für Compliance erforderlich

Link zum Abschnitt: Zwei weitere Header sind für Compliance erforderlich

Mcp-Method spiegelt die Methode bei jedem Request; Mcp-Name spiegelt params.name oder params.uri bei tools/call, resources/read und prompts/get. Sie existieren, damit ein Proxy routen kann, ohne Bodys zu parsen.5

Die alten Formen sind weg und antworten mit Ablehnung

Link zum Abschnitt: Die alten Formen sind weg und antworten mit Ablehnung

Der GET-Stream, Mcp-Session-Id und Last-Event-ID-Resumption wurden alle entfernt. Ein Server, der nur diese Revision spricht, sollte auf GET oder DELETE mit 405 Method Not Allowed antworten, einen Session-Header ignorieren, ohne einen zu minten, und Last-Event-ID ignorieren.5

Jetzt die Messung, die das ganze Kapitel neu rahmt. Sende einen Request der aktuellen Revision per HTTP an jeden Server.

POST /mcp, MCP-Protocol-Version: 2026-07-28TEXT
Python   200  {"result":{"resultType":"complete","cacheScope":"private","ttlMs":0,
              "tools":[…],"_meta":{"io.modelcontextprotocol/serverInfo":{…}}}}

TypeScript    {"error":{"code":-32000,"message":"Bad Request: Unsupported protocol
              version: 2026-07-28 (supported versions: 2025-11-25, 2025-06-18,
              2025-03-26, 2024-11-05, 2024-10-07)"}}

Die Konstanten stimmen mit dem Verhalten überein: LATEST_PROTOCOL_VERSION des Python-SDK liest 2026-07-28, die des TypeScript-SDK liest 2025-11-25. Sende den Header-Mismatch-Request aus dem Schritt oben, und der Python-Server antwortet 400 mit Fehler -32020 und der Nachricht „mcp-protocol-version header does not match the request envelope's protocol version“; das TypeScript-SDK hat keinen solchen Code, weil es die Revision nicht implementiert, die ihn definiert.

Die Seite, die beide als Tier 1 listet, sagt auch „Each SDK provides the same functionality“.1 Am unten genannten Datum ist dieser Satz für die aktuelle Revision aspirational. Prüfe LATEST_PROTOCOL_VERSION im SDK, das du gleich installierst; es ist eine Zeile und die einzige Aussage in diesem Kapitel, die in einem Jahr noch zählen wird.

Verschiebe einen Server von deinem Laptop, und der Client eines Fremden taucht mit einem token auf. Das ist die Hälfte, die Kapitel 26 ausgelassen hat, und die Hälfte, die ein Multi-User-Produkt nicht überspringen kann.

Die Spezifikation setzt den MCP Server in eine OAuth-2.1-Rolle und benennt sie: Ein geschützter MCP Server ist ein Resource Server, der Client ist ein OAuth Client, und der Authorization Server ist das Problem von jemand anderem.4 Aus dieser Rolle folgen vier verpflichtende Klauseln, vollständig zitiert, weil Paraphrasieren genau der Weg ist, wie der Fehler entsteht:

MCP servers, acting in their role as an OAuth 2.1 resource server, MUST validate access tokens as described in OAuth 2.1 Section 5.2. MCP servers MUST validate that access tokens were issued specifically for them as the intended audience, according to RFC 8707 Section 2. […] MCP clients MUST NOT send tokens to the MCP server other than ones issued by the MCP server's authorization server. MCP servers MUST only accept tokens that are valid for use with their own resources. MCP servers MUST NOT accept or transit any other tokens.4

„Must not accept or transit“ ist die Anti-Passthrough-Regel, und deshalb gibt es den ganzen Audience-Apparat. Ein Server, der den Bearer token, den man ihm gegeben hat, bei einer Third-Party-API wieder abspielt, ist ein confused deputy: Er verleiht sein eigenes Vertrauen an denjenigen, der ihn aufgerufen hat. Die Regel verbietet die Wiederverwendung, nicht nur die Speicherung.

Das erzwingbar zu machen braucht vier RFCs, jeweils mit einem Job.6 RFC 9728 ist, wie der Client den Authorization Server überhaupt findet: Der MCP Server stellt ein Protected-Resource-Metadata-Dokument bereit und ein 401 zeigt darauf. RFC 8707 ist der resource-Parameter — der Client muss die kanonische URI des Servers sowohl im Authorization Request als auch im Token Request senden, „regardless of whether authorization servers support it“, damit der ausgegebene token seine Audience benennt. RFC 9207 schließt die Schleife von der anderen Seite: Der Client notiert den Issuer vor dem Redirect und vergleicht den zurückgegebenen iss als exakten String, ohne Normalisierung — kein Case Folding, kein Weglassen von Default-Ports, kein trailing Slash. Und RFC 7591, Dynamic Client Registration, ist jetzt zugunsten von Client ID Metadata Documents deprecated, „retained for backwards compatibility with authorization servers that do not support“ them.4

Verdrahte das auf beiden Servern mit einem Token-Verifier, der nichts tut, außer die Audience zu prüfen. Die TypeScript-Leiter:

POST /mcp — TypeScript, with requireBearerAuthTEXT
no token            401  WWW-Authenticate: Bearer error="invalid_token",
                         error_description="Missing Authorization header",
                         scope="incidents:read",
                         resource_metadata="…/.well-known/oauth-protected-resource/mcp"
aud=other server    401  error_description="token audience is not this server"
no exp claim        401  error_description="Token has no expiration time"
right aud, no scope 403  error="insufficient_scope", scope="incidents:read"
right aud + scope   200  {"result":{"tools":[…]}}
GET /.well-known/oauth-protected-resource/mcpTEXT
{"resource":"http://127.0.0.1:8931/mcp",
 "authorization_servers":["https://auth.example.com/"],
 "scopes_supported":["incidents:read","incidents:write"],
 "resource_name":"Incidents"}

Beide SDKs stellen dieses Dokument bereit, und beide lassen ein 401 darauf zeigen, was die gesamte Discovery-Story ist: Ein Client, der deinen Server noch nie gesehen hat, lernt aus einer Ablehnung, wo er sich authentifizieren muss. Der 403 ist ein anderes Tier — der token ist in Ordnung, der Scope nicht — und die Challenge benennt, was fehlt, damit der Client nachlegen kann, statt von vorne anzufangen.

Zwei Sprossen unterscheiden sich, und keiner der Unterschiede steht in der Spezifikation. Das TypeScript-SDK lehnt einen token mit keinem Expiry-Claim ab; das Python-SDK gibt 200 zurück, weil expires_at auf seinem AccessToken optional ist und None „keine Meinung“ bedeutet. Und Pythons 403 trägt error_description="Required scope: incidents:read" ohne den scope-Parameter, den die Spezifikation Servern empfiehlt. Ein Verifier ist kein Ort, um einen Library-Default zu akzeptieren: Der Audience-Check ist in beiden Sprachen deiner, und der Expiry-Check auch.

Ein ehrlicher Nit aus demselben Lauf. Ein GET auf den Endpoint antwortete 404 beim Express-Wiring und 400 Bad Request: Missing session ID beim Python-Wiring, wo die Spezifikation 405 Method Not Allowed verlangt und wo „Session ID“ Vokabular ist, das diese Revision entfernt hat. Nichts davon ist gefährlich; beides ist die Form eines Ökosystems mitten in der Migration.

Das letzte Stück beim Shipping ist, wo du veröffentlichst, und es hat eine Antwort mit einer Zahl. Heute gecrawlt, jeder Server im offiziellen Registry in seiner neuesten Version:7

servers
total (latest version, not deleted)28,170
active / deprecated27,853 / 317
ship at least one installable package13,065
remote only — a URL, nothing to install14,696
npm8,275
PyPI3,603
OCI images867
mcpb bundles706
NuGet / Cargo107 / 43

Zwei Lesarten, die in entgegengesetzte Richtungen zeigen. Nach veröffentlichten Servern führt npm 2,3 zu 1 — die Zahl, die Leute zitieren, wenn sie sagen, das Ökosystem sei TypeScript. Nach Downloads führt Python: In den letzten dreißig Tagen kam mcp auf 286,7 Millionen gegen @modelcontextprotocol/sdk mit 194,7 Millionen, bevor fastmcp mit 72,1 Millionen dazukommt.7 Beide sind Tier 1, das normative Schema ist ein schema.ts, und das offizielle Tutorial „Build an MCP server“ öffnet auf dem Python-Tab.1 Welche Hälfte davon du auch im Kopf hattest, die andere Hälfte stimmt ebenfalls.

Und die Zeile, die wichtiger ist als beide: Mehr als die Hälfte des Registry — 14.696 von 28.170 — hat nichts zu installieren. Das sind Web Services. Die Transport-Zählungen bestätigen es von der anderen Seite: Von 14.290 Package-Einträgen deklarieren 13.787 stdio; von 16.640 Remote-Einträgen deklarieren 15.570 Streamable HTTP und 1.070 noch das deprecated HTTP+SSE. „Ein MCP Server ist ein Subprozess auf deinem Laptop“ beschreibt also eine schrumpfende Minderheit, und jeder einzelne der 14.696 braucht den Abschnitt oben statt einer Umgebungsvariable.

Details anzeigen

Absichtlich zweisprachig, und der Präzedenzfall dafür.

Das ist das einzige zweisprachige Kapitel im Kurs, weil die ehrliche Antwort sich teilt: Das Registry ist npm-first und die Downloads sind Python-first, gleichzeitig, heute. Nur eine der beiden Sprachen zu schreiben würde die Hälfte der Frage verschenken und dabei das Ökosystem falsch beschreiben. Es gibt einen offenen Präzedenzfall — der Hugging Face MCP Course listet unter seinen Voraussetzungen „Experience with at least one programming language (Python or TypeScript examples will be shown)“ und lehrt beides.8 Ein Protokoll, dessen ganzer Wert in der Zahl seiner Implementierungen liegt, ist ein schlechter Ort für Einsprachigkeit.

Datierter Abschnitt: alles oben, was ein Haltbarkeitsdatum hat

Link zum Abschnitt: Datierter Abschnitt: alles oben, was ein Haltbarkeitsdatum hat

Gelesen und gemessen am 7. September 2026, gegen Protokollrevision 2026-07-28.

value
@modelcontextprotocol/sdk1.30.0, published 27 July 2026; 4,322,438 bytes unpacked, 693 files, 17 direct dependencies
latest revision it implements2025-11-25
mcp (PyPI)2.1.1, published 25 August 2026; 357,912-byte wheel, plus mcp-types 2.1.1 at 69,656 bytes
latest revision it implements2026-07-28
SDK tiersTypeScript, Python, C#, Go, Rust at Tier 1; Java, Ruby at Tier 2; Swift, PHP, Kotlin at Tier 3
registry servers28,170
downloads, last 30 daysmcp 286,653,871 · fastmcp 72,097,269 · @modelcontextprotocol/sdk 194,679,333

Eine Migrationsnotiz, die keine Zahl ist. In mcp 2.x wurde FastMCP in MCPServer umbenannt, und fast jedes Tutorial online beginnt noch mit dem alten Import. Das SDK liefert ein Modul mit, dessen einziger Zweck darin besteht, das zu erklären; das ist die rücksichtsvollste Deprecation in diesem Kapitel:

from mcp.server.fastmcp import FastMCPTEXT
ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x,
where FastMCP was renamed to MCPServer (from mcp.server.mcpserver import
MCPServer) and other APIs changed; see the migration guide … or pin 'mcp<2'
to keep running v1 code.

Mit der Tabelle vor dir ist die Empfehlung langweilig, was ein gutes Zeichen ist.

Wenn der Server in einer Webanwendung lebt, die du bereits betreibst, schreib ihn in TypeScript. Derselbe Prozess, derselbe deploy, derselbe Request Handler; Streamable HTTP ist ein Endpoint, den du neben die anderen setzt; und die 13,9 MiB und die 145 ms sind gratis, weil die Runtime ohnehin schon lief. Das sind die meisten der 14.696 Remote-Server.

Wenn der Server Data Tooling wrappt, schreib ihn in Python. Was du exponierst, ist pandas, ein Warehouse-Client, ein Notebook voller Transforms, und ein Server in einer anderen Sprache wäre ein Subprozess-Call mit Schema als Verkleidung. Siebenhundert Millisekunden Import in einem Service, der einmal startet, sind kein Kostenpunkt; in einem Subprozess, den ein Host den ganzen Tag neu startet, schon.

Und vorerst sticht die Revision-Zeile beides. Wenn du 2026-07-28 brauchst — Multi-Round-Trip-Requests, resultType, Cache-Hints, server/discover — hat eines der beiden SDKs das heute, das andere nicht.

Du kannst jetzt denselben Server in beiden Sprachen shippen, die Wahl mit einer Tabelle statt einer Vorliebe verteidigen, ihn über beide Live-Transports laufen lassen und ihm einen token geben, den er ablehnen wird.

Was du gebaut hast, ist immer noch eine Funktion: ein Schema, ein Endpoint, eine deterministische Sache, die das Modell aufruft. Eine ganze Klasse von Wissen passt nicht in diese Form — wie wir ein Postmortem schreiben, welche Felder unsere Incident Reports brauchen, in welcher Reihenfolge wir Dinge tun und warum. Es ist Prozedur, es ist Prosa, und es in eine Tool-Beschreibung zu zwingen, ist der Weg, wie System-prompts auf zweitausend tokens wachsen, die bei jedem einzelnen Turn bezahlt werden, egal ob es im Gespräch um Incidents geht oder nicht.

Kapitel 28 ist die andere Antwort: ein Ordner mit einem SKILL.md darin, den das Modell liest, statt ihn aufzurufen, in drei Ebenen geladen, sodass das Referenzmaterial fast nichts kostet, bis es in diesem Turn gebraucht wird. Es hat keine Hauptsprache, und das ist das Erste, was es lehrt.


Alles hier wurde am 7. September 2026 auf Node 22.22.3 und Python 3.14.4 gemessen, gegen @modelcontextprotocol/sdk 1.30.0 mit zod 3.25.76 und mcp 2.1.1, jeweils in ein eigenes Wegwerfverzeichnis installiert. Timings sind Mediane aus 25 Starts, Wall Clock von spawn bis zur Zeile mit der tools/list-Antwort; token-Zählungen sind o200k_base via tiktoken über das JSON jeder Definition. Keine bezahlte API wurde aufgerufen: Nichts hier braucht ein Modell.

Die beiden Server haben 81 und 63 nicht-leere Zeilen; eines ihrer drei Tools ist oben in beiden Sprachen reproduziert, und die anderen vier Registrierungen unterscheiden sich nur wie beschrieben. Die Error-Disclosure-Policy des Python-SDK wird aus den Docstrings von ToolError und UnexpectedToolError in mcp/server/mcpserver/exceptions.py zitiert; der Pretty-Printing-Default ist pydantic_core.to_json(result, fallback=str, indent=2) in mcp/server/mcpserver/resources/types.py und utilities/func_metadata.py. Die Protokollversionskonstanten sind LATEST_PROTOCOL_VERSION in mcp_types/version.py und im types.js des TypeScript-SDK, beide aus den installierten Packages gelesen und nicht aus einem Changelog.

  1. SDKs, modelcontextprotocol.io/docs/sdk, und Build an MCP server, modelcontextprotocol.io/docs/develop/build-server, beide gelesen am 7. September 2026. Quelle der Tier-Tabelle, des Satzes „Each SDK provides the same functionality but follows the idioms and best practices of its language“, der Sprach-Tab-Reihenfolge des Tutorials (Python, TypeScript, Java, Kotlin, C#, Ruby, Rust, Go) und der zitierten Logging-Regel zu print() und stdout. 2 3 4

  2. stdio transport, .../basic/transports/stdio. Quelle des Newline-Framings und der stdout-Purity-Regel. Kapitel 26 liest diese Seite vollständig; sie wird hier für die Zeile zitiert, gegen die der kaputte Server verstößt.

  3. MCP Inspector, modelcontextprotocol.io/docs/2026-07-28/tools/inspector, gelesen am 7. September 2026. Ein Package, drei Clients hinter einem Binary — Web, --cli und --tui — die einen Core, ein Set von Transports und einen OAuth-Zustand auf Disk teilen. Die CLI erzeugte die Katalog-Traces hier.

  4. Authorization, modelcontextprotocol.io/specification/2026-07-28/basic/authorization, gelesen am 7. September 2026. Quelle der Resource-Server-Rolle; der vier vollständig zitierten Token-Handling-Klauseln; der Anforderung, dass Server RFC 9728 implementieren und Clients es für Discovery verwenden; der resource-Parameterregeln und der Definition der kanonischen URI; der Issuer-Validation-Tabelle; der Deprecation von Dynamic Client Registration; der Tabelle 401/403/400 und der insufficient_scope-Challenge; sowie der stdio-Ausnahme: „Implementations using an STDIO transport SHOULD NOT follow this specification, and instead retrieve credentials from the environment.“ 2 3 4

  5. Streamable HTTP, .../basic/transports/streamable-http, und Transports overview, .../basic/transports. Quelle der Single-Endpoint-POST-Regel, der dualen Accept-Anforderung, des MCP-Protocol-Version-Headers und seiner Must-match-the-body-Regel, der als „REQUIRED for compliance“ beschriebenen Header Mcp-Method und Mcp-Name, der Entfernung des GET-Streams, von Sessions und Last-Event-ID, der 405-Guidance, der verpflichtenden Origin-Validation und der Einstufung des HTTP+SSE-Transports von 2024-11-05 als Deprecated unter SEP-2596. 2 3 4

  6. Die vier, auf die sich die Spezifikation stützt, mit dem Draft, den sie profiliert: The OAuth 2.1 Authorization Framework, draft-ietf-oauth-v2-1-13. Campbell, B., Bradley, J. and Tschofenig, H., Resource Indicators for OAuth 2.0, RFC 8707, Februar 2020 — der resource-Parameter und die Audience, die er bindet. Jones, M.B., Hunt, P. and Parecki, A., OAuth 2.0 Protected Resource Metadata, RFC 9728, April 2025 — das Dokument, auf das ein 401 zeigt. Meyer zu Selhausen, K. and Fett, D., OAuth 2.0 Authorization Server Issuer Identification, RFC 9207, März 2022 — der iss-Parameter und der Exact-String-Vergleich. Richer, J. (ed.) et al., OAuth 2.0 Dynamic Client Registration Protocol, RFC 7591, Juli 2015, für diese Nutzung deprecated. Und Jones, M. and Hardt, D., The OAuth 2.0 Authorization Framework: Bearer Token Usage, RFC 6750, Oktober 2012, Abschnitt 3, für die WWW-Authenticate-Challenge-Form oben.

  7. Offizielles MCP Registry, registry.modelcontextprotocol.io/v0/servers, gecrawlt am 7. September 2026 mit version=latest: 282 Seiten, 28.170 Server, gezählt mit registryType über unterschiedliche Servernamen. Download-Zahlen: api.npmjs.org/downloads/point/last-month für @modelcontextprotocol/sdk (194.679.333 für 8. August – 6. September 2026) und pypistats.org/api/packages/<name>/recent für mcp und fastmcp, beide am selben Tag gelesen. Package-Größen stammen aus dem npm-Registry-Dokument und der PyPI JSON API. 2

  8. MCP Course, Hugging Face, huggingface.co/learn/mcp-course, Unit 0, gelesen am 7. September 2026: unter den Voraussetzungen „Experience with at least one programming language (Python or TypeScript examples will be shown)“.

Bereit, LIA die Wahl zu überlassen?

Bau mit jedem KI-Modell an einem Ort — starte heute kostenlos.