Zwei Assistenten, dieselben Tools

Product

In Notez gibt es zwei Wege, eine KI auf deine Notizen anzusetzen. Der eine ist der Chat in der App, der andere der MCP-Server, über den dein eigener Assistent – Claude Code, Cursor oder was du sonst nutzt – denselben Workspace liest und beschreibt.

Gedacht waren sie als zwei Türen in denselben Raum. Letzte Woche stellte jemand eine berechtigte Frage: Kann der Assistent eine Notiz löschen? Die Antwort war auf beiden Seiten Nein, und das war in Ordnung. Die Antwort auf die nächste Frage war unangenehmer.

Sechzehn gegen zehn

Der Chat hatte sechzehn Tools. MCP hatte zehn.

Diese Lücke hatte niemand beschlossen. Im August habe ich dem Chat in der App beigebracht, eigene Eigenschaften, Tags und Board-Spalten zu lesen und zu schreiben – sechs neue Tools. Diese Arbeit landete im Chat und blieb dort. Niemand hatte entschieden, dass MCP sie nicht bekommen sollte. Niemand hatte bemerkt, dass es sie nicht hatte.

Schlimmer noch: Auch die Überschneidung war so uneinheitlich, dass sich die beiden wie verschiedene Produkte anfühlten. Der Chat hatte search_notes, MCP hatte list_notes, das dieselbe Aufgabe mit mehr Filtern erledigte. Der Chat hatte semantic_search, bei MCP hieß es search_similar_notes. Dieselbe Fähigkeit, anderer Name, andere Form – die Art Abweichung, die niemand plant und über die irgendwann alle stolpern.

So verrottet Software gewöhnlich. Nicht durch eine schlechte Entscheidung, sondern durch eine, die nicht wiederholt wurde: eine Änderung, die für die Oberfläche richtig war, die sie anfasste, und zur anderen schwieg.

Was ich nicht vereinheitlicht habe

Die naheliegende Lösung ist eine gemeinsame Implementierung, die beide Oberflächen aufrufen. Das habe ich versucht und wieder zurückgenommen, weil sich zwei Unterschiede als tragend herausstellten.

Die Ergebnisse müssen sich unterscheiden. MCP antwortet in JSON und wirft typisierte Fehler, weil es mit einem Programm spricht. Der Chat antwortet in Fließtext mit Links wie [Budget 2024](note:a1b2…), weil die App diese wieder herausliest und daraus die kleinen klickbaren Notiz-Chips unter einer Antwort rendert. Liefert der Chat JSON, verschwinden die Chips.

Auch die Fehler müssen sich unterscheiden. Stößt der Chat auf ein Problem, gibt er einen Satz zurück – „oldText was not found in the note; re-read it rather than guessing“ –, weil das Modell diesen Satz liest und sich in der nächsten Runde korrigiert, innerhalb eines Budgets von fünf Runden. MCP wirft eine Exception und überlässt die Entscheidung dem Client. Zwingt man beiden denselben Vertrag auf, wird einer von beiden schlechter.

Also habe ich den Teil vereinheitlicht, der identisch sein soll – welche Tools es gibt, wie sie heißen, welche Argumente sie nehmen –, und die Darstellung jeder Oberfläche selbst überlassen. Darüber wacht ein Test, der die echte MCP-Toolliste baut, sie mit den Deklarationen des Chats vergleicht und bei jedem Unterschied den Build scheitern lässt. Es gab genau eine erlaubte Ausnahme, im Test samt Begründung ausgeschrieben: Karten zwischen Board-Spalten zu verschieben blieb der App vorbehalten, weil ein Board wie eine visuelle Anordnung aussah, die ein Assistent auf der anderen Seite einer API nicht sehen kann. Diese Ausnahme gibt es nicht mehr: Heute verschiebt auch MCP Karten zwischen Spalten, und die einzigen Tools, die MCP hat und dem Chat fehlen, sind die beiden zum Hochladen von Dateien und, bei einer Verbindung per Anmeldung, das eine, das deine Workspaces auflistet.

Ein Kommentar „keep these in sync“ hätte nichts gekostet. Er wäre aber auch ignoriert worden, wie der letzte.

Dann wollte ich „Verschieben“ hinzufügen

Löschen, Anpinnen und direkt verschachteltes Erstellen waren unkompliziert. Eine Notiz unter eine andere zu verschieben, sah genauso unkompliziert aus: Die App hat seit Monaten den Menüpunkt Verschieben unter..., der schwierige Teil war also sicher erledigt.

Er war es nicht.

Notez speichert den Notizbaum als Closure Table: nicht nur „das Elternelement von B ist A“, sondern eine Zeile für jedes Paar aus Vorfahr und Nachfahr, mit dem Abstand dazwischen. Damit ist „zeig mir jede Notiz unter dieser, in beliebiger Tiefe“ eine einzige indizierte Abfrage statt eines rekursiven Durchlaufs.

Der Code zum Verschieben schrieb diese Zeilen für die verschobene Notiz neu. Nur für die verschobene Notiz.

Bei A > B > C ergab das Verschieben von B unter D also Folgendes:

  • Vorfahren von B: D. Richtig.

  • Vorfahren von C: A und B. Falsch. A hat mit C nichts mehr zu tun, und D fehlt ganz.

Der Breadcrumb von C lautete Tree A › Tree B › Tree C, obwohl B längst unter D lag. Der Bug ließ sich die ganze Zeit schon über das Menü der App auslösen. Es musste nur jemand eine Notiz verschieben, die schon Kinder hatte – und sich danach eines dieser Kinder ansehen.

Ich hätte ein Tool ausgeliefert, mit dem ein Assistent das hundertmal pro Minute tun könnte.

Der Test, den ich fast nicht geschrieben hätte

Die Korrektur liest den Teilbaum, löscht nur die Zeilen, die aus ihm hinausführen, und hängt jeden Nachfahren neu an die neuen Vorfahren. Die Unit-Tests waren grün. Sie mocken die Datenbank, bestätigen also eigentlich nur, dass der Code tut, was sein Autor erwartet hat.

Der End-to-End-Test – echter Browser, echte Datenbank, eine Notiz mit Kind verschieben, zum Enkel hinabsteigen und seinen Breadcrumb lesen – schlug beim ersten Lauf fehl. Mit der Korrektur. Genau dafür hatte ich ihn geschrieben.

Die Ursache war ein Detail, das niemand erraten und kein Mock aufdecken würde: Eine auf oberster Ebene erstellte Notiz hat in der Closure Table keine Zeile auf sich selbst. Jede andere Notiz hat eine Zeile, die sagt: „Ich bin mein eigener Vorfahr im Abstand null“. Eine schlichte Notiz auf oberster Ebene hat sie nicht, weil nur der Codepfad, der ein Elternelement anhängt, sie schreibt. Also lieferte „wähle alles unter dieser Notiz“ die Kinder, aber nicht die Notiz selbst – und der Löschschritt, der die Verbindungen aus dem Teilbaum hinaus entfernen sollte, kappte vergnügt die Verbindung der Notiz zu ihren eigenen Kindern.

Das Ergebnis: Das neue Elternelement hatte überhaupt keine Kinder mehr, und der einzige Vorfahr des Enkels war eine Notiz zwei Ebenen über ihm.

Eine frühere manuelle Prüfung hatte nichts gefunden. Das war reines Glück: Die Notiz, mit der ich zufällig getestet hatte, war vom alten Code schon einmal umgehängt worden, und der schrieb immer eine Zeile auf die Notiz selbst. Sie hatte also eine.

Das ist das ganze Argument für Tests, die das echte System berühren. Ein Mock hält fest, was du glaubst. Eine Datenbank hält fest, was stimmt. Wenn beide sich widersprechen, willst du das von einem Test erfahren und nicht von jemandem, dessen Notizen sich still umsortiert haben.

Was du davon hast

Beide Oberflächen haben jetzt dieselben achtzehn Tools. Dein Assistent hängt eine Notiz unter eine andere, pinnt sie an, legt sie in den Papierkorb – und sagt dir, dass vier Unternotizen mitgegangen sind. So kennst du den Umfang dessen, was gerade passiert ist, bevor du entscheidest, ob du es rückgängig machst.

Zwei kleinere Korrekturen kamen mit. Das Auflisten von Notizen über MCP lieferte früher Notizen aus dem Papierkorb zwischen den aktiven, solange man sie nicht ausdrücklich ausschloss, entgegen der eigenen Dokumentation. Und die Frage an den Chat „Woran habe ich gearbeitet?“ sortierte früher nach Erstellungsdatum und beantwortete eine Frage zu dieser Woche mit Notizen aus dem März.

Nichts davon ist ein Feature, von dem jemand einen Screenshot macht. Es ist die Version des Produkts, die tut, was ihre eigenen Hilfeseiten schon behauptet haben – und ab einem gewissen Punkt im Leben eines Projekts ist das die nützlichere Art von Release.

Weiterlesen