Zurück zum Blog
DevOps · Essay

Ein MCP-Server besteht vor allem aus den Stellen, an denen er Nein sagt

Simon Doba·20. August 2026·8 Min. Lesezeit

Simon Willison hat Ende Juli über die neue Stateless-MCP-Spec geschrieben, und hängen geblieben ist bei mir nicht die Protokolländerung, sondern das hier:

MCP tools are easier to audit and control, and simple enough that smaller models that run on a laptop can still drive them reasonably well.

— Simon Willison

Ich stimme zu, und mir fiel auf, dass ich das ohne Belege tat. In zwei meiner Produkte laufen MCP-Server. Ich habe sie spezifiziert und ich gate sie, aber ich hatte mich nie hingesetzt und einen davon am Stück zurückgelesen.

Also habe ich es getan. Herausgekommen ist: fast nichts am interessanten Code handelt davon, Fähigkeiten bereitzustellen. Es handelt davon, sie zu verweigern — an mehr Stellen, als ich in Erinnerung hatte.

Zwei Server, zwei Ebenen der Kontrolle

Sie sind unterschiedlich groß, und der Unterschied ist lehrreich.

  • Der eine sind 27 Zeilen Route auf mcp-handler, dazu 77 Zeilen Werkzeugdefinitionen. Auth ist ein Callback, der einen Request zu Tenant, Actor und einer Menge Scopes auflöst.
  • Der andere sind 145 Zeilen auf dem rohen SDK, mit einer 319-Zeilen-Registry dahinter.

Der Abstand kommt nicht aus Erfahrung. Er kommt daraus, wie tief man in den Request-Lebenszyklus greifen muss. Der Wrapper gibt dir die Authentifizierung und behält die Protokoll-Handler für sich. Das rohe SDK gibt dir ListToolsRequestSchema und CallToolRequestSchema, und damit kannst du die Werkzeugliste selbst davon abhängig machen, wer fragt.

Die Werkzeugliste ist eine Berechtigungsfläche

Das meiste, was über MCP geschrieben wird, behandelt tools/list als Manifest — die Dokumentation, die ein Client liest, um zu erfahren, was es gibt. Im größeren meiner beiden Server wird sie pro Aufrufer berechnet:

server.setRequestHandler(ListToolsRequestSchema, () => ({
  tools: visibleAgentTools(ctx, flags).map(...)
}));

Zwei Feature-Flags entscheiden, was zurückkommt. Eines schaltet das Gateway überhaupt frei, ein zweites, davon getrenntes schaltet die Werkzeuge frei, die Änderungen vorschlagen dürfen. Ein Key mit dem ersten und ohne das zweite bekommt eine echt kleinere Liste und erfährt nie, dass die anderen Werkzeuge existieren.

Was ich falsch gemacht hätte, wenn ich das schnell hingeschrieben hätte, ist der nächste Schritt. Die Liste ist nicht das Gate. Der Call-Handler prüft erneut:

if (!tool || !canCallAgentTool(ctx, params.name, flags)) {
  throw new AgentToolDeniedError("Tool is unavailable");
}

Etwas aus einer Liste wegzulassen ist keine Autorisierung, sondern Auffindbarkeit. Ein Client, der den Namen eines verborgenen Werkzeugs errät, bekommt eine Ablehnung statt eines Ergebnisses, und die Ablehnung ist ein eigener Fehlertyp und kein generischer Throw, damit sie sich getrennt von einem Absturz zählen lässt.

Es gibt kein Execute

Die Registry typisiert ein Werkzeug als eines von zwei Arten:

kind: "read" | "propose"

Das ist das gesamte Vokabular. Es gibt keinen dritten Wert fürs Schreiben. Ein Agent darf lesen, und er darf einen Änderungsvorschlag erzeugen, der in einer Inbox landet und von einem Menschen angenommen wird, und das Annehmen läuft über den normalen Anwendungspfad, nicht über die Verbindung des Agenten.

Das ist eine stärkere Position, als ein Schreibwerkzeug einzuschränken, weil das gefährliche Verb im Typ gar nicht vorkommt. Es gibt keine Berechtigung, die man fehlkonfigurieren, und keinen Scope, den man versehentlich aufziehen kann. Die Fähigkeit zu entfernen ist die einzige Variante, die kein späteres Flag rückgängig macht.

Zwei Sperrlisten, die in verschiedene Richtungen zeigen

Der Audit-Trail hat eine Erlaubnisliste für Eingabefelder, die er aufzeichnen darf — zwei Namen. Ein Werkzeugaufruf protokolliert also, welcher Standort und welches Datum, und nichts von der Payload.

Die Werkzeugausgaben haben stattdessen eine Sperrliste, und die ist länger:

FORBIDDEN_OUTPUT_FIELDS = ["name", "email", "phone", "clientName",
  "patientName", "birthDate", "chipNumber", "equidePassNumber"]

Beide existieren, weil in jeder Richtung „alles mitschreiben" der faule Default ist. Ein Audit-Trail, der vollständige Argumente aufzeichnet, wird zur zweiten Kopie genau der Daten, die er überwachen sollte. Und ein Werkzeug, das zurückgibt, was die Abfrage hergab, leakt durch Unterlassung statt durch Entscheidung.

Noch eine Sache steht im selben Block, und die gefällt mir besser, als ich erwartet hatte:

if (!(outcome === "OK" && tool?.kind === "propose")) {
  await appendAgentAccessLog(...)
}

Ein erfolgreicher Vorschlag wird nicht ins Zugriffslog geschrieben, weil der Vorschlag selbst bereits der Nachweis ist und ein zweiter Eintrag dasselbe Ereignis an zwei Stellen legen würde, die auseinanderlaufen können. Gescheiterte Vorschläge werden weiterhin protokolliert. Und wenn das Protokollieren selbst scheitert, wird das als Merker am Request vermerkt statt geworfen, damit ein Ausfall des Audits kein Ausfall wird.

Wo ich der Formulierung widerspreche

All das stützt Willisons Punkt. Ein Shell hat ein Werkzeug und das kann alles; hier gibt es eine typisierte Liste, eine Sicht pro Aufrufer, eine zweite Prüfung beim Aufruf und Feldfilter in beide Richtungen. Es ist tatsächlich leichter zu auditieren.

Aber auditierbar leistet in diesem Satz stille Arbeit. Alles oben auditiert, was der Agent mit der Oberfläche gemacht hat, die er bekommen hat. Nichts auditiert die Oberfläche.

Das ist dieselbe Lücke, in die ich bei bewerteten Agent-Loops gelaufen bin, wo ein Lauf perfekt gegen 600 Eval-Szenarien abschnitt, in denen kein einziger HTTP-Request vorkam. Die Instrumente waren in Ordnung. Die Grenze hat ein Mensch gezogen, und nichts innerhalb einer Grenze prüft die Grenze. Ein Werkzeug, das du nie exponiert hast, ist für jede Logzeile hier unsichtbar, und eines, das du exponiert hast und nicht hättest exponieren sollen, ebenso.

Ich würde es also enger formulieren als das Original: MCP macht die Benutzung einer Fähigkeit durch einen Agenten auditierbar. Welche Fähigkeiten es gibt, bleibt genauso überprüfbar wie jede andere Designentscheidung, also davon abhängig, dass jemand hinsieht.

Die Stateless-Änderung, kurz

Weil sie den Anlass gab: Beide Server hatten SSE ohnehin abgeschaltet, den zustandsbehafteten Transport. Autorisiert wird pro Request über einen Key im Header, es gab also nie Sitzungszustand, den zu halten sich gelohnt hätte. Die Juli-Spec macht daraus den Standardweg statt einer Sache, die man abwählt — womit der kleinere meiner beiden Server vor allem eine Zeile löschen darf.

„stateless" hier den Transport meint. Der Audit-Trail, der Zeitstempel der letzten Key-Nutzung und die Vorschlags-Inbox sind alle Zustand, und liegen alle weiterhin in der Datenbank, wo sie hingehören.

Next

Was mir fehlt, ist eine Prüfung, ob die Werkzeugliste richtig ist und nicht bloß durchgesetzt wird. Eine End-to-End-Probe läuft bereits gegen die echte Route und stellt sicher, dass beide Flags die Listen ergeben, die sie sollen — das fängt Regressionen. Sie kann mir nicht sagen, dass ein Werkzeug von vornherein nicht hätte dort sein dürfen.

Falls du einen MCP-Server produktiv betreibst: Gibt es darin etwas, das die Oberfläche prüft statt der Benutzung? Bei mir zeigt jeder Mechanismus nach innen, und ich würde mir gern einen zeigen lassen, der das nicht tut.

Zurückgelesen am 6. August 2026 aus zwei eigenen Produkten. Zeilenzahlen, Flag-Namen, Feldlisten und die zitierten Ausschnitte stammen aus diesen Repositories, nicht aus der Rekonstruktion.

Artikel teilen

Baust du an etwas Ähnlichem?

Ich schreibe hier über Setups, die ich selbst benutze. Wenn du an etwas Vergleichbarem arbeitest, würde mich interessieren, wie dein Workflow aussieht.

Schreib mir

Cookie-Einstellungen

Wir nutzen Cookies für Analyse und Verbesserung unserer Website. Datenschutzerklärung