Die Oberfläche als Beweisführung

Der vorangehende Beitrag dieser Reihe hat einen MCP-Server mit den Bordmitteln des JDK errichtet und das Model Context Protocol konzeptionell eingeordnet. Sein Ergebnis war jedoch nicht der Server selbst, sondern ein einziges Bild: zwei Antworten desselben Sprachmodells, nebeneinandergestellt, links ohne angebundenen Server, rechts mit. Die linke Antwort kennt nur das allgemeine Weltwissen des Modells und schlägt Rezepte vor, die mit der Lebenswirklichkeit des Anwenders nichts zu tun haben. Die richtige Antwort greift über den MCP-Server auf den tatsächlichen Vorrat und das hinterlegte Ernährungsprofil zu und empfiehlt, was sich aus den vorhandenen Zutaten unter Beachtung der Ernährungsform und der medizinischen Auflagen zubereiten lässt.

Dieses Bild ist mehr als nur eine Veranschaulichung. Es ist das eigentliche Argument des gesamten Projekts. Über den Nutzen eines Protokolls zur Werkzeuganbindung ließe sich trefflich streiten, solange er bloß behauptet wird; sobald jedoch zwei sichtlich verschiedene Antworten im selben Augenblick vor den Augen des Betrachters entstehen, erübrigt sich der Streit. Die Gegenüberstellung ersetzt die Behauptung durch unmittelbare Wahrnehmung. Eben deshalb verdient nicht nur der Server, sondern auch die Oberfläche, die dieses Bild herstellt, eine eigene Betrachtung. Wo der erste Beitrag von der Mechanik des Protokolls handelte, steigt der vorliegende Teil zur Frage seiner Wahrnehmbarkeit hinab.

Der Quelltext befindet sich auf GitHub unter:

https://8g8.eu/vlskmz

Startansicht des Rezeptassistenten: Eingabefeld, leere Spalten ohne und mit MCP sowie Seitenleiste mit Vorratstabelle und Ernährungsprofil
Startansicht des Rezeptassistenten: Eingabefeld, leere Spalten ohne und mit MCP sowie Seitenleiste mit Vorratstabelle und Ernährungsprofil

So schlicht die Zweispaltigkeit dem Betrachter erscheint, so wenig selbstverständlich ist ihre Herstellung. Damit das überzeugende Bild entsteht, müssen zwei Anfragen an dasselbe Modell parallel geführt werden: eine ohne, die andere mit angebundenem Werkzeugkatalog. Ihre Antworten treffen zeichenweise und in unterschiedlichem Tempo ein und müssen zeitgleich in zwei getrennte Bereiche fließen, ohne sich zu vermengen. Der Protokollverkehr, der die richtige Antwort überhaupt erst ermöglicht, läuft im Normalbetrieb verborgen ab und muss eigens lesbar gemacht werden. Und all dies hat zu geschehen, ohne dass die Oberfläche ins Stocken gerät oder den Eindruck der Gleichzeitigkeit verliert, von dem ihre Wirkung lebt.

Nach einer Anfrage: links generische Antwort, rechts Antwort mit MCP-Werkzeugaufrufen und Ernährungsprofil, darüber die Protokollschritte
Nach einer Anfrage: links generische Antwort, rechts Antwort mit MCP-Werkzeugaufrufen und Ernährungsprofil, darüber die Protokollschritte

Daraus ergibt sich die leitende Beobachtung dieses Teils: Die Überzeugungskraft der Gegenüberstellung hängt weniger an der Logik der Antworten als an der Disziplin ihrer Darstellung. Eine Oberfläche für modellgesteuerte Werkzeugnutzung hat nicht nur Ergebnisse anzuzeigen, sondern den Vorgang ihrer Entstehung nachvollziehbar zu halten.

Der vorliegende Teil ist daher als Rundgang durch die konkreten Herausforderungen angelegt, nicht als vollständiger Katalog aller Bestandteile der Oberfläche. Jedes der folgenden Kapitel greift eine eigene Hürde auf: den Betrieb von Vaadin ohne den Komfort eines Frameworks, die nebenläufige Führung zweier Anfragen, das Einschleusen eines zeichenweisen Stroms in einen serverseitigen Komponentenbaum, die ehrliche Unterscheidung zwischen unfertiger und abgeschlossener Antwort, die Sichtbarmachung des Protokolls und schließlich die Wahrung eines konsistenten Zustands. Den Anfang macht die Frage, wie die Anwendung überhaupt zur Laufzeit kommt — und zwar ohne Spring, ohne Jakarta EE und mit so wenigen Abhängigkeiten wie möglich.

Vaadin ohne Starter betreiben

Wer eine Vaadin-Anwendung aufsetzt, greift für gewöhnlich zu einem Starter. Ein einziger Abhängigkeitseintrag, eine Annotation, und schon läuft die Oberfläche; der Servlet wird angemeldet, die Initialisierer werden gefunden, die statischen Ressourcen werden eingehängt, und der WebSocket-Kanal steht bereit. All dies geschieht hinter einer Fassade, die der Entwickler weder sieht noch zu verstehen braucht. Für ein Projekt jedoch, das den bewussten Verzicht auf Spring und Jakarta EE zum Grundsatz erhebt, ist eben diese Fassade der Gegenstand des Interesses. Das vorliegende Kapitel öffnet sie und zeigt, aus welchen wenigen, klar benennbaren Schritten der Start tatsächlich besteht.

Vergleich: Mit Starter bleibt die Verdrahtung verborgen, ohne Starter laufen sieben Schritte von Server bis Serverstart sichtbar nacheinander ab
Vergleich: Mit Starter bleibt die Verdrahtung verborgen, ohne Starter laufen sieben Schritte von Server bis Serverstart sichtbar nacheinander ab

Der Einstiegspunkt ist denkbar schlicht. Die Main-Klasse des Moduls setzt zunächst die Standard-Locale der virtuellen Maschine auf Englisch und startet sodann den eingebetteten Server am zentral hinterlegten Port.

package com.svenruppert.mcp.ui;

import com.svenruppert.dependencies.core.logger.HasLogger;
import com.svenruppert.mcp.common.Ports;

import java.util.Locale;

public final class Main implements HasLogger {

    public static void main(String[] args) throws Exception {
        Locale.setDefault(Locale.ENGLISH);
        new Main().run();
    }

    private void run() throws Exception {
        logger().info(“vaadin-ui starting on port {}", Ports.VAADIN_UI);
        new UiServer(Ports.VAADIN_UI).start();
    }
}

Das Festlegen der Locale ist kein beiläufiges Detail. Die gesamte Anwendung tritt in englischer Sprache auf, bis hin zu dem lang-Attribut, das Vaadin in das ausgelieferte HTML schreibt. Ohne diese Festlegung übernähme die Anwendung die Locale des jeweiligen Rechners und spräche auf einem deutschen System unversehens Deutsch. Die eigentliche Arbeit verrichtet der UiServer, dessen Startroutine den Rest des Kapitels gliedert.

Der erste Schritt erstellt den Server und den Servlet-Kontext. Hier fällt bereits die erste nicht offensichtliche Entscheidung.

public void start() throws Exception {
    server = new Server(port);

    ServletContextHandler context = new ServletContextHandler();
    context.setContextPath(”/");
    context.setSessionHandler(new SessionHandler());
    // Sharing the parent classloader avoids the server/system class isolation
    // that a WebAppContext would impose.
    context.setClassLoader(Thread.currentThread().getContextClassLoader());

    // Expose every META-INF/resources/ directory from the classpath as a
    // virtual webapp root. Servlet 3.0 lets JARs ship static assets there
    // (Vaadin’s flow-push delivers vaadinPush-min.js under
    // META-INF/resources/VAADIN/static/push/), but ServletContextHandler
    // does not auto-mount them the way WebAppContext does.
    context.setBaseResource(metaInfResources(context));

Verwendet wird ein schlichter ServletContextHandler, nicht der naheliegendere WebAppContext. Der Unterschied ist hier entscheidend. Ein WebAppContext installiert einen eigenen Klassenlader, dessen Trennung zwischen Server- und Anwendungsklassen die Klassen aus org.eclipse.jetty.* vor der Anwendung verbirgt. Vaadin benötigt diese Klassen jedoch während seiner eigenen Initialisierung, weshalb der WebAppContext die Vaadin-Initialisierer scheitern ließe. Der ServletContextHandler teilt sich stattdessen den Klassenlader des aufrufenden Fadens, und die Isolation entfällt. Eben diese Schlichtheit erkauft sich allerdings einen Mehraufwand an anderer Stelle: Statische Ressourcen, die ein WebAppContext von selbst einhängen würde, müssen nun ausdrücklich bereitgestellt werden. Die hierfür zuständige Hilfsmethode wird weiter unten betrachtet.

Im zweiten Schritt wird der VaadinServlet angemeldet.

    // Register VaadinServlet at “/*". Async + WebSocket support is needed for @Push.
    ServletHolder vaadinHolder = new ServletHolder(“vaadin”, new VaadinServlet());
    vaadinHolder.setInitParameter(“productionMode”, “true”);
    vaadinHolder.setAsyncSupported(true);
    vaadinHolder.setInitOrder(1);
    context.addServlet(vaadinHolder, “/*”);

    // ServletDeployer is a context listener. With VaadinServlet already registered,
    // it logs and skips creation.
    context.addEventListener(new ServletDeployer());

Der Servlet wird unter dem Pfadmuster /* registriert und beantwortet damit jede Anfrage. Drei Einstellungen verdienen Beachtung. Der Produktionsmodus

schaltet den Entwicklungsbetrieb mit seinem fortlaufenden Frontend-Bau ab und setzt voraus, dass das zuvor erzeugte Produktionsbündel vorhanden ist. Die Freigabe asynchroner Verarbeitung ist die Bedingung dafür, dass @Push später überhaupt arbeiten kann. Die Startreihenfolge schließlich sorgt dafür, dass der Servlet früh in der Initialisierung des Kontexts an die Reihe kommt. Der anschließend angemeldete ServletDeployer ist ein Kontext-Lauscher, der den Servlet andernfalls selbst erzeugte; da dieser bereits von Hand registriert wurde, beschränkt er sich auf einen Protokolleintrag.

Der dritte und umfangreichste Schritt bildet das Kernstück des frameworkfreien Starts. Seit Vaadin 25 meldet das Rahmenwerk seine Initialisierer nicht mehr selbsttätig an; sie sind daher von Hand zu registrieren, und zwar in der richtigen Reihenfolge.

    // Vaadin SCIs in correct order. LookupServletContainerInitializer must run first.
    context.addServletContainerInitializer(new ServletContainerInitializerHolder(
            LookupServletContainerInitializer.class,
            LookupInitializer.class,
            loadVaadinClass(“com.vaadin.flow.di.LookupInitializer$ResourceProviderImpl”),
            loadVaadinClass(“com.vaadin.flow.di.LookupInitializer$StaticFileHandlerFactoryImpl”),
            loadVaadinClass(“com.vaadin.flow.di.LookupInitializer$AppShellPredicateImpl”),
            DefaultApplicationConfigurationFactory.class,
            DefaultRoutePathProvider.class
    ));
    context.addServletContainerInitializer(new ServletContainerInitializerHolder(
            RouteRegistryInitializer.class,
            MainView.class,
            WalkthroughView.class,
            MainLayout.class
    ));
    context.addServletContainerInitializer(new ServletContainerInitializerHolder(
            VaadinAppShellInitializer.class,
            AppShell.class
    ));
    context.addServletContainerInitializer(new ServletContainerInitializerHolder(
            AnnotationValidator.class,
            AppShell.class
    ));
    context.addServletContainerInitializer(new ServletContainerInitializerHolder(
            ErrorNavigationTargetInitializer.class
    ));
    context.addServletContainerInitializer(new ServletContainerInitializerHolder(
            WebComponentExporterAwareValidator.class
    ));
    context.addServletContainerInitializer(new ServletContainerInitializerHolder(
            WebComponentConfigurationRegistryInitializer.class
    ));

Jeder dieser sieben Aufrufe meldet einen ServletContainerInitializer an und nennt ihm zugleich die Klassen, für die er zuständig ist. Diese Klassen entsprechen den @HandlesTypes, die ein Servlet-Container andernfalls durch ein Absuchen des Klassenpfads ermittelte; da hier von Hand registriert wird, werden sie ausdrücklich übergeben. Der erste Initialisierer ist die Dienstauflösung und muss zuerst laufen, da alle weiteren auf ihr aufbauen. Der zweite richtet die Routenverwaltung ein und erhält dazu die drei navigierbaren Klassen MainView, WalkthroughView und MainLayout; nur so findet die Routenverwaltung die beiden Ansichten und das gemeinsame Layout. Die übrigen fünf richten den Seitenrahmen ein, prüfen Annotationen, bestimmen die Fehlerziele und validieren beziehungsweise registrieren etwaige Web-Komponenten.

Auffällig sind die drei über loadVaadinClass geladenen Klassen im ersten Aufruf. Es handelt sich um interne, nicht öffentlich exportierte Klassen Vaadins, die sich nicht unmittelbar als Klassenliteral schreiben lassen. Die Hilfsmethode lädt sie daher namentlich und scheitert mit einer klaren Meldung, sollte sich der innere Aufbau Vaadins einmal ändern.

private static Class<?> loadVaadinClass(String name) {
    try {
        return Class.forName(name, false,
                Thread.currentThread().getContextClassLoader());
    } catch (ClassNotFoundException e) {
        throw new IllegalStateException(“Vaadin runtime class not found: " + name, e);
    }
}

Der vierte Schritt schließt die Verdrahtung ab und startet den Server. Zuvor wird die Jakarta-WebSocket-Unterstützung konfiguriert, ohne die der Server-Push über den WebSocket-Kanal nicht zustande käme.

    // Jakarta WebSocket support so @Push can use the websocket transport.
    JakartaWebSocketServletContainerInitializer.configure(context, null);

    server.setHandler(context);
    Runtime.getRuntime().addShutdownHook(new Thread(this::safeStop, “ui-shutdown”));

    server.start();
    logger().info(“Vaadin UI listening on http://localhost:{}/", port);

    CountDownLatch latch = new CountDownLatch(1);
    Runtime.getRuntime().addShutdownHook(new Thread(latch::countDown, “ui-latch”));
    latch.await();
}

Nach dem Setzen des Kontexts als Behandler wird ein Abschalthaken eingerichtet, der den Server bei einem Abbruch geordnet anhält. Der eigentliche Start meldet sodann die erreichbare Adresse. Da der Hauptfaden andernfalls endete und die virtuelle Maschine die Anwendung beendete, hält ein CountDownLatch den Faden offen, bis ein zweiter Abschalthaken ihn freigibt. Der Server läuft damit, bis er ausdrücklich beendet wird.

Es bleibt die Hilfsmethode, die die statischen Ressourcen bereitstellt — jene Arbeit, die der WebAppContext von selbst übernähme.

private static Resource metaInfResources(ServletContextHandler context) throws Exception {
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    ResourceFactory factory = ResourceFactory.of(context);
    // Servlet 3 convention: each JAR may carry static files under META-INF/resources/.
    // flow-push delivers vaadinPush-min.js under META-INF/resources/VAADIN/static/push/,
    // and Vaadin’s prebuilt production bundle ships under
    // META-INF/resources/VAADIN/build/ in vaadin-prod-bundle.jar — both are
    // exposed by combining every classpath entry’s META-INF/resources tree.
    List<Resource> resources = new ArrayList<>();
    for (URL u : Collections.list(cl.getResources(“META-INF/resources”))) {
        resources.add(factory.newResource(u));
    }
    if (resources.isEmpty()) {
        return factory.newResource(”.”);
    }
    return resources.size() == 1 ? resources.get(0) : ResourceFactory.combine(resources);
}

Nach der Servlet-3-Konvention darf jedes Archiv im Klassenpfad statische Dateien unter META-INF/resources mitführen. Vaadin nutzt dies an mehreren Stellen, etwa für das Push-Skript und für das vorgefertigte Produktionsbündel. Die Methode sammelt sämtliche solcher Verzeichnisse aus dem Klassenpfad ein und fügt sie zu einer gemeinsamen virtuellen Wurzel zusammen, sodass der Server sie als eine einzige Ressourcensammlung ausliefert.

Der gesamte Ablauf lässt sich auf einen Blick erfassen.

Ablauf des Starts von Main.main() über Jetty-Kontext, VaadinServlet und sieben Vaadin-Initialisierer bis WebSocket-Konfiguration und Serverstart
Ablauf des Starts von Main.main() über Jetty-Kontext, VaadinServlet und sieben Vaadin-Initialisierer bis WebSocket-Konfiguration und Serverstart

Damit ist der frameworkfreie Start vollständig. Er besteht aus dem Errichten des Kontexts, dem Bereitstellen der statischen Ressourcen, der Anmeldung des Servlets, der ausdrücklichen Registrierung der sieben Initialisierer, der WebSocket-Konfiguration und dem eigentlichen Start. Jeder dieser Schritte ist im Quelltext sichtbar und nachvollziehbar. Was ein Starter als Bequemlichkeit verbirgt, liegt hier offen — und eben diese Offenheit ist der Gewinn des Verzichts. Der Preis ist eine Handvoll zusätzlicher Zeilen; der Ertrag ist ein Start, der sich vollständig mit den Bordmitteln der Plattform verstehen lässt, ohne den Umweg über eine weitere Abstraktionsschicht. Auf diesem Fundament setzt das folgende Kapitel auf, das den Aufbau der Hauptansicht selbst behandelt.

Zwei Anfragen nebeneinander führen

Die Überzeugungskraft der Gegenüberstellung, von der das einleitende Kapitel sprach, ruht auf zwei Bedingungen. Die erste ist die Gleichzeitigkeit: Der Betrachter soll beide Antworten im selben Augenblick entstehen sehen. Die zweite ist die Gleichheit der Bedingungen: Beide Antworten müssen auf dieselbe Frage und unter denselben Voraussetzungen ergehen, damit der sichtbare Unterschied allein der Anbindung des Servers zuzuschreiben ist und nicht einer beiläufigen Ungleichbehandlung im Aufbau. Beide Bedingungen sind keine Selbstverständlichkeit, sondern in den Quelltext eingearbeitet. Dieses Kapitel zeigt, wie.

Die Gleichheit der Bedingungen beginnt bei der Anlage der Ansicht. Für jede der beiden Spalten existiert ein gleichartiges Feld; was es links gibt, gibt es rechts in derselben Gestalt.

    private final TextArea questionInput = new TextArea();
    private final Checkbox mcpToggle = new Checkbox(“Enable MCP”);
    private final Button sendButton = new Button(“Ask”);

    private static final String PLACEHOLDER_PLAIN =
            “_Generic answers will appear here…_”;
    private static final String PLACEHOLDER_MCP =
            “_Answers using your pantry will appear here…_”;

    private final Markdown leftBody = new Markdown(PLACEHOLDER_PLAIN);
    private final Markdown rightBody = new Markdown(PLACEHOLDER_MCP);
    private final StringBuilder leftBuffer = new StringBuilder();
    private final StringBuilder rightBuffer = new StringBuilder();
    private final ChatProgressView leftProgress = new ChatProgressView();
    private final ChatProgressView rightProgress = new ChatProgressView();

Jeder Spalte ist eine eigene Markdown-Komponente für die fertige Antwort, ein eigener Puffer für den eintreffenden Text und eine eigene Fortschrittsanzeige zugeordnet. Diese paarweise Anlage ist die bauliche Grundlage der Symmetrie; sie sorgt dafür, dass linke und rechte Spalte aus demselben Holz geschnitzt sind.

Konsequenter noch zeigt sich die Symmetrie darin, dass beide Spalten aus einer einzigen Methode hervorgehen. Ein boolescher Parameter entscheidet, ob die schlichte oder die MCP-gestützte Variante entsteht.

    private Div buildAnswerColumn(boolean mcp) {
        Div col = new Div();
        col.addClassNames(“recipe-answer”, mcp ? “recipe-answer–mcp” : “recipe-answer–plain”);

        Span chip = new Span();
        chip.addClassNames(“recipe-chip”, mcp ? “recipe-chip–mcp” : “recipe-chip–plain”);
        if (mcp) {
            chip.add(new Icon(VaadinIcon.MAGIC), new Span(“With MCP”));
        } else {
            chip.add(new Icon(VaadinIcon.COMMENT_O), new Span(“Without MCP”));
        }

        Div header = new Div(chip);
        header.addClassName(“recipe-answer__header”);

        Span subtitle = new Span(mcp
                ? “Has access to your pantry & profile”
                : “Generic recipe knowledge only”);
        subtitle.addClassName(“recipe-card__subtitle”);

        ChatProgressView progress = mcp ? rightProgress : leftProgress;
        Markdown body = mcp ? rightBody : leftBody;
        body.addClassName(“recipe-answer__body”);

        col.add(header, subtitle, progress, body);
        return col;
    }

Erzeugt werden die beiden Spalten an einer Stelle durch zwei Aufrufe mit den Werten false und true; die linke ist mithin die schlichte, die rechte die MCP-gestützte. Bis auf die Beschriftung, die Farbgebung und die Auswahl des zugehörigen Feldpaares ist der Aufbau identisch. Wer im Code nach einem Unterschied zwischen den Spalten sucht, findet ihn allein in dem einen booleschen Wert — und genau das ist die im Quelltext sichtbare Zusicherung, dass die Gegenüberstellung redlich ist.

Die Gleichzeitigkeit wiederum entsteht beim Auslösen über die Schaltfläche. Die Methode handleSend bereitet die Oberfläche vor und stößt sodann die nebenläufige Verarbeitung an.

    private void handleSend() {
        String question = questionInput.getValue();
        if (question == null || question.isBlank()) return;

        leftBuffer.setLength(0);
        rightBuffer.setLength(0);
        leftBody.setContent(”");
        rightBody.setContent("");
        leftProgress.clear();
        rightProgress.clear();
        sendButton.setEnabled(false);

        UI ui = UI.getCurrent();
        boolean withMcp = mcpToggle.getValue();
        AtomicReference<Boolean> leftDone  = new AtomicReference<>(Boolean.FALSE);
        AtomicReference<Boolean> rightDone = new AtomicReference<>(!withMcp);

        Thread.startVirtualThread(() -> runPlain(ui, question, leftDone, rightDone));
        if (withMcp) {
            Thread.startVirtualThread(() -> runWithMcp(ui, question, leftDone, rightDone));
        } else {
            ui.access(() -> rightBody.setContent("_(MCP disabled - check the box to enable.)_"));
        }
    }

Nach dem Zurücksetzen beider Paneele und dem Sperren der Schaltfläche werden zwei voneinander unabhängige virtuelle Threads gestartet — einer für die schlichte, einer für die MCP-gestützte Anfrage. Virtuelle Threads sind hier das angemessene Mittel: Sie sind so leichtgewichtig, dass sich für jede Anfrage bedenkenlos ein eigener erzeugen lässt, und sie dürfen blockierend auf das Eintreffen der Antwort warten, ohne eine Plattformressource zu binden. Die schlichte Anfrage wird stets gestellt; die MCP-gestützte nur, wenn der Schalter aktiviert ist. Andernfalls erhält die rechte Spalte einen schlichten Hinweis.

Die Arbeitsteilung hinter den beiden Threads verdient eine eigene Bemerkung. Die schlichte Anfrage treibt die Methode runPlain, die MCP-gestützte die Methode runWithMcp; letztere bedient sich desselben WalkthroughEngine, der auch die Schritt-für-Schritt-Ansicht des zweiten Teils antreibt — hier lediglich als automatische Schleife ohne Pausen zwischen den Phasen. Die eigentliche Protokolllogik liegt damit an genau einer Stelle, und nur der Antrieb unterscheidet sich. Die Rümpfe beider Methoden enthalten den Streaming-Rückruf, der die eintreffenden Textfragmente in die Oberfläche überträgt; ihm ist das folgende Kapitel gewidmet, weshalb er hier ausgespart bleibt.

Bleibt die Zusammenführung der beiden Stränge. Jeder Thread vermerkt an seinem Ende, dass er fertig ist, und ruft sodann eine schlichte Koordinationsmethode auf.

    private void maybeReenable(UI ui,
                               AtomicReference<Boolean> leftDone,
                               AtomicReference<Boolean> rightDone) {
        if (leftDone.get() && rightDone.get()) {
            ui.access(() -> sendButton.setEnabled(true));
        }
    }

Erst wenn beide Marken gesetzt sind, wird die Schaltfläche wieder freigegeben. Da die beiden Marken in getrennten Threads gesetzt und gelesen werden, sind sie als AtomicReference ausgeführt, deren Wert über Threadgrenzen hinweg sicher sichtbar ist. Hier zahlt sich die bereits erwähnte Anfangsbelegung aus: Die rechte Marke wird mit !withMcp vorbelegt und gilt damit ohne aktivierten Schalter sogleich als erledigt. So genügt im schlichten Betrieb der eine Thread der linken Spalte, um die Schaltfläche freizugeben, während im MCP-Betrieb beide Threads abgewartet werden. Eine eigene Synchronisation ist nicht nötig; die Logik trägt sich selbst.

Der gesamte Ablauf lässt sich als Gabelung und Zusammenführung lesen.

Flussdiagramm: Ask startet runPlain und optional runWithMcp in eigenen Threads; erst wenn leftDone und rightDone gelten, wird der Button wieder aktiv
Flussdiagramm: Ask startet runPlain und optional runWithMcp in eigenen Threads; erst wenn leftDone und rightDone gelten, wird der Button wieder aktiv

Während das Diagramm die Struktur der Gabelung zeigt, macht das folgende Schaubild die zeitliche Seite sichtbar: Beide Stränge laufen tatsächlich im selben Zeitraum, und die MCP-gestützte Spur dauert länger, weil sie zwischendurch Werkzeuge aufruft.

Zeitachse: runPlain und runWithMcp laufen nebenläufig in virtuellen Threads; die MCP-Spur dauert wegen Tool-Aufrufen länger
Zeitachse: runPlain und runWithMcp laufen nebenläufig in virtuellen Threads; die MCP-Spur dauert wegen Tool-Aufrufen länger

Damit sind die beiden Bedingungen erfüllt, auf denen die Gegenüberstellung beruht. Die Symmetrie der Erzeugung stellt sicher, dass beide Antworten unter gleichen Voraussetzungen entstehen; die nebenläufige Führung über zwei virtuelle Threads stellt sicher, dass sie es im selben Augenblick tun. Was bislang offenblieb, ist die Frage, wie die in den Hintergrund-Threads eintreffenden Textfragmente überhaupt ihren Weg in die serverseitige Oberfläche finden. Eben dieser Frage wendet sich das folgende Kapitel zu.

Einen zeichenweisen Strom in den Komponentenbaum führen

Das vorangegangene Kapitel ließ eine Frage offen: wie die in den beiden Hintergrund-Threads eintreffenden Textfragmente überhaupt ihren Weg in die Oberfläche finden. Drei Hürden stehen dem entgegen. Erstens hält Vaadin den Komponentenbaum auf dem Server; die Antwort des Modells entsteht aber nicht dort, sondern trifft über das Netz ein. Zweitens darf der Komponentenbaum nicht aus einem beliebigen Thread heraus verändert werden, sondern allein im gesperrten Kontext der zugehörigen Sitzung. Drittens fragt der Browser von sich aus keine Aktualisierungen ab; ohne weiteres Zutun bliebe das Bild stehen, bis der Anwender die Seite neu lädt. Dieses Kapitel zeigt, wie diese drei Hürden zusammen überwunden werden — entlang des Weges, den ein einzelnes Fragment zurücklegt.

Am Anfang dieses Weges steht der Strom selbst. Der OllamaClient fordert die Antwort des Modells mit aktiviertem Streaming an und liest sie als Server-Sent-Events. Die schlichte Anfrage der linken Spalte läuft über die Methode chatPlain, die den Systemprompt und die Frage zu einer Nachrichtenliste fügt und sodann die innere Streaming-Methode aufruft. Bemerkenswert ist der dritte Parameter: ein Consumer<String>, an den jedes eintreffende Textfragment übergeben wird.

  public void chatPlain(String systemPrompt, String userMessage,
                        Consumer<String> textChunk,
                        Consumer<ChatProgress> progress) {
    Instant start = Instant.now();
    progress.accept(ChatProgress.info(“Sending request to Ollama”,
                                      “model: " + model));
    List<ObjectNode> messages = new ArrayList<>();
    messages.add(message(“system”, systemPrompt));
    messages.add(message(“user”, userMessage));
    try {
      progress.accept(ChatProgress.stream(“Streaming reply…”));
      StreamResult outcome = streamOnce(messages, null, textChunk);
      progress.accept(ChatProgress.success(
          “Reply complete (” + outcome.assistantText.length() + " chars)”,
          Duration.between(start, Instant.now())));
    } catch (RuntimeException e) {
      progress.accept(ChatProgress.error(e.getMessage()));
      throw e;
    }
  }

Der Methode werden zwei Rückrufe übergeben: der eine für die Textfragmente, der andere für strukturierte Fortschrittsereignisse. Den eigentlichen Strom überlässt chatPlain der inneren Methode streamOnce, der sie den Textrückruf unverändert weiterreicht; das übergebene null an Stelle der Werkzeugliste kennzeichnet die schlichte Anfrage.

Die eigentliche Zerlegung des Stroms geschieht in dieser inneren Methode. Sie liest die Antwort Zeile für Zeile; leere und nicht zum Strom gehörende Zeilen werden übersprungen, und die Sentinel-Zeile [DONE] beendet die Schleife.

      try (BufferedReader reader = new BufferedReader(
          new InputStreamReader(response.body(), StandardCharsets.UTF_8))) {
        String line;
        while ((line = reader.readLine()) != null) {
          if (line.isEmpty()) continue;
          if (!line.startsWith(“data:”)) continue;
          String data = line.substring(5).trim();
          if ("[DONE]".equals(data)) break;
          JsonNode event;
          try {
            event = MAPPER.readTree(data);
          } catch (Exception parseError) {
            logger().warn(“Skipping malformed SSE chunk: {}", data);
            continue;
          }
          JsonNode choice = event.path(“choices”).path(0);
          JsonNode delta = choice.path(“delta”);
          if (delta.hasNonNull(“content”)) {
            String piece = delta.get(“content”).asText();
            if (!piece.isEmpty()) {
              assistantText.append(piece);
              if (textChunk != null) textChunk.accept(piece);
            }
          }
          if (delta.hasNonNull(“tool_calls”)) {
            for (JsonNode tc : delta.get(“tool_calls”)) {
              int idx = tc.path(“index”).asInt(0);
              ToolCallAccumulator acc = calls.computeIfAbsent(idx,
                                                              k -> new ToolCallAccumulator());
              if (tc.hasNonNull(“id”)) acc.id = tc.get(“id”).asText();
              JsonNode fn = tc.path(“function”);
              if (fn.hasNonNull(“name”)) acc.name = fn.get(“name”).asText();
              if (fn.hasNonNull(“arguments”)) {
                acc.argumentsBuffer.append(fn.get(“arguments”).asText());
              }
            }
          }
          String finish = choice.path(“finish_reason”).asText(null);
          if (finish != null && !finish.isBlank()) {
            break;
          }
        }
      }

Für den Textpfad ist allein der mittlere Abschnitt von Belang: Trägt die Differenz einen Inhalt, so wird dieser Anteil dem gesammelten Antworttext angehängt und zugleich an den Rückruf übergeben. Auf diese Weise verlässt jedes Fragment die Methode in dem Augenblick, in dem es eintrifft. Der darauffolgende Abschnitt sammelt die Bestandteile etwaiger Werkzeugaufrufe; er bleibt hier unerörtert und ist Gegenstand des sechsten Kapitels. Das Auftreten eines Abschlussgrundes beendet die Schleife.

Damit ist die Quelle geklärt: Der Consumer<String> erhält das Fragment. Bevor dieses jedoch im Browser erscheinen kann, ist die erste der genannten Hürden zu nehmen — der Browser muss unaufgefordert beliefert werden dürfen. Das leistet die Annotation @Push, die nicht an einer einzelnen Ansicht, sondern an der zentralen AppShell hängt und damit für die gesamte Anwendung gilt.

package com.svenruppert.mcp.ui;

import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.component.page.Push;
import com.vaadin.flow.theme.Theme;
import com.vaadin.flow.theme.lumo.Lumo;

@Push
@Theme(value = “recipe”, variant = Lumo.LIGHT)
public class AppShell implements AppShellConfigurator {
}

Die Annotation erlaubt dem Server, Veränderungen von sich aus an den Browser zu senden — über den WebSocket-Kanal, der im zweiten Kapitel eigens konfiguriert wurde. Weil sie an der AppShell sitzt, gilt sie für beide Ansichten gleichermaßen; die zweite Annotation bestimmt lediglich das Erscheinungsbild.

Bleibt die zweite Hürde: die Veränderung im richtigen Kontext. Das empfangende Ende in der Hauptansicht ist die Methode runPlain. Sie übergibt chatPlain als Rückruf eine Funktion, die jedes Fragment über UI.access verarbeitet.

    private void runPlain(UI ui, String question,
                          AtomicReference<Boolean> leftDone, AtomicReference<Boolean> rightDone) {
        try {
            services.ollama().chatPlain(Prompts.SYSTEM_PLAIN, question,
                    chunk -> ui.access(() -> {
                        leftBuffer.append(chunk);
                        leftBody.setContent(leftBuffer.toString());
                    }),
                    ev -> ui.access(() -> leftProgress.append(ev)));
        } catch (Exception e) {
            logger().warn(“Plain chat failed”, e);
            ui.access(() -> leftBody.setContent(”**Error:** " + e.getMessage()));
        } finally {
            leftDone.set(Boolean.TRUE);
            maybeReenable(ui, leftDone, rightDone);
        }
    }

Hier laufen die Fäden zusammen. Der Verweis auf die UI wurde zuvor im auslösenden Faden festgehalten und in den Hintergrund-Thread mitgegeben. Jedes eintreffende Fragment wird nicht unmittelbar verarbeitet, sondern in einen UI.access-Aufruf gehüllt. Eben dies ist der Schlüssel: Eine Veränderung des Komponentenbaums aus einem fremden Thread heraus wäre unzulässig, da sie die Sitzungssperre stattdessen umgehen und zu Race conditions führen würde. UI.access reiht die Veränderung stattdessen in den gesperrten Kontext der Sitzung ein und stößt, da @Push aktiv ist, zugleich ihre Übertragung an den Browser an. So überwindet ein einziger Aufruf die zweite und die dritte Hürde in einem.

Innerhalb des Rückrufs zeigt sich schließlich das Puffermuster. Jedes Fragment wird an einen StringBuilder angehängt, und der gesamte angesammelte Inhalt wird der Markdown-Komponente als neuer Inhalt gesetzt. Dass nicht am Bestand fortgeschrieben, sondern bei jedem Fragment der vollständige Text erneut gesetzt wird, ist Absicht: Das Vorgehen ist schlicht, in sich abgeschlossen und entspricht der Schnittstelle der Komponente, die einen vollständigen Text entgegennimmt. Der Fortschrittsrückruf verfährt auf dieselbe Weise. Am Ende vermerkt die Methode im finally-Block, dass die linke Spalte fertig ist, und ruft die im vorigen Kapitel betrachtete Koordinationsmethode auf; der Fehlerpfad setzt, ebenfalls über UI.access, eine Fehlermeldung.

Der gesamte Weg eines Fragments lässt sich als Folge weniger Schritte lesen.

Sequenzdiagramm: Fragment vom Ollama-Modell über OllamaClient und Consumer per UI.access in den Puffer, dann Push über WebSocket an den Browser
Sequenzdiagramm: Fragment vom Ollama-Modell über OllamaClient und Consumer per UI.access in den Puffer, dann Push über WebSocket an den Browser

Damit sind die drei eingangs genannten Hürden gemeinsam genommen: Der Strom wird im Client zerlegt, @Push erlaubt die unaufgeforderte Belieferung des Browsers, und UI.access führt jedes Fragment im richtigen Kontext aus und veranlasst seine Übertragung. Eine Einzelheit bleibt dabei auffällig: Bei jedem Fragment wird der gesamte angesammelte Text erneut auf die Markdown-Komponente gesetzt, sodass diese ein stetig wachsendes, noch unvollständiges Dokument darstellt. Was der Betrachter wahrnimmt, solange das Dokument unvollständig ist — und ob dies das Richtige ist —, ist die Frage, der sich das folgende Kapitel zuwendet.

Den Strom als Markdown rendern

Das vierte Kapitel hat den Weg eines Fragments bis in den Komponentenbaum verfolgt und am Ende eine Einzelheit hervorgehoben: Bei jedem Fragment wird der gesamte angesammelte Text auf die Markdown-Komponente gesetzt. Was dort als beiläufige Beobachtung stand, ist der eigentliche Gegenstand dieses Kapitels. Während das vorige Kapitel fragte, wie ein Fragment in die Oberfläche gelangt, fragt dieses, was die Oberfläche sichtbar mit ihm tut. Der Kern des Rückrufs lautet:

                    chunk -> ui.access(() -> {
                        leftBuffer.append(chunk);
                        leftBody.setContent(leftBuffer.toString());
                    }),

Das Feld leftBody ist eine com.vaadin.flow.component.markdown.Markdown, also keine schlichte Textkomponente. Ihr Aufruf setContent ersetzt den bisherigen Inhalt vollständig; die Komponente verwirft ihre bisherige Darstellung und rendert den gesamten angesammelten Text neu. Da dies bei jedem Fragment geschieht, erscheint die Antwort dem Betrachter nicht als wachsender Rohtext, sondern als fortlaufend gerendertes Markdown-Dokument.

Für den Betrachter bedeutet dies, dass sich die Antwort als strukturiertes Dokument aufbaut. Eine Überschrift erscheint als Überschrift, sobald ihre Zeile vollständig ist; eine Aufzählung erscheint als Aufzählung, sobald ihre Einträge eintreffen; eine Hervorhebung wird wirksam, sobald sie geschlossen ist. Das Dokument entsteht mithin nicht als Rohfassung, die am Ende in Form gebracht würde, sondern bereits in seiner endgültigen Gestalt, Zug um Zug.

Dieses Vorgehen hat einen Preis. Solange eine Auszeichnung noch unvollständig ist — eine geöffnete, aber noch nicht geschlossene Hervorhebung, eine begonnene, aber noch nicht abgeschlossene Liste, ein erst zur Hälfte eingetroffener Codeblock —, rendert die Komponente diesen Zwischenstand so, wie er gerade vorliegt. Trifft kurz darauf der fehlende Teil ein, ordnet sich der betroffene Abschnitt neu. Der Betrachter nimmt dies als kurzes Umordnen wahr, das sich mit dem Eintreffen des Restes von selbst auflöst.

Die Hauptansicht nimmt dieses gelegentliche Umordnen bewusst in Kauf, und der Grund liegt in ihrem Zweck. Sie ist die Vergleichsansicht, und ihre ganze Überzeugungskraft ruht, wie das einleitende Kapitel darlegte, auf der Unmittelbarkeit der Gegenüberstellung. Der Betrachter soll beide Antworten lebendig und im selben Augenblick entstehen sehen; das fortlaufende Rendern dient eben dieser Lebendigkeit. Die Antworten sind zudem überschaubare Rezepttexte, bei denen das Umordnen selten und geringfügig ausfällt. Der kleine Preis steht damit in einem angemessenen Verhältnis zum gewonnenen Eindruck der Gleichzeitigkeit.

Es sei nicht verschwiegen, dass sich derselbe Sachverhalt auch anders lösen lässt — und im selben Projekt anders gelöst ist. Die Schritt-für-Schritt-Ansicht geht den umgekehrten Weg: Sie zeigt den Strom zunächst als Rohtext und rendert ihn erst nach seinem Abschluss als Markdown.

        if (query.toolCalls().isEmpty()) {
            result.remove(streamDiv);
            Markdown rendered = new Markdown(streamedText.isBlank() ? “_(no text)_” : streamedText);
            rendered.addClassName(“recipe-walkthrough__rendered”);
            result.add(rendered);

Während des Stroms wächst der Text dort in einem schlichten Bereich an; erst nach Abschluss wird dieser entfernt und durch eine frisch erzeugte Markdown-Komponente ersetzt. Die zugehörige Phasenbeschreibung hält es ausdrücklich fest: „Streaming is complete and the text is rendered as Markdown.“ Der Grund für diese andere Wahl liegt im anderen Zweck: Eine lehrende Ansicht bevorzugt ein ruhiges, stabiles Bild jeder Phase, das der Betrachter in seinem Tempo studieren kann, ohne dass sich das Gerenderte unter seinem Blick neu ordnet. Die nähere Betrachtung dieses Gegenentwurfs gehört in den zweiten Teil; hier genügt der Hinweis, dass es sich um zwei begründete Antworten auf dieselbe Frage handelt.

Die beiden Strategien lassen sich knapp gegenüberstellen.

Gegenüberstellung: Schrittansicht rendert Markdown einmal bei [DONE], Hauptansicht rendert nach jedem Fragment das gesamte Dokument neu
Gegenüberstellung: Schrittansicht rendert Markdown einmal bei [DONE], Hauptansicht rendert nach jedem Fragment das gesamte Dokument neu

Beide Wege folgen demselben Grundsatz, dass eine Oberfläche den Zustand ihres Inhalts ehrlich abbilden sollte — sie ziehen daraus nur unterschiedliche Folgerungen. Die Vergleichsansicht zeigt das Dokument, wie es sich bildet, weil ihr an der Gleichzeitigkeit des Entstehens gelegen ist. Die lehrende Ansicht zeigt jede Phase, wie sie abgeschlossen vorliegt, weil ihr an der Ruhe des Studierens gelegen ist. Was beide Ansichten bislang noch nicht offengelegt haben, ist der Verkehr, der die rechte Antwort überhaupt erst ermöglicht — die Aufrufe der Werkzeuge und ihre Ergebnisse. Eben diesem Verkehr und seiner Sichtbarmachung wendet sich das folgende Kapitel zu.

Das Protokoll sichtbar machen

Das vorige Kapitel schloss mit einer Feststellung: Was beide Ansichten bislang nicht offengelegt haben, ist der Verkehr, der die rechte Antwort überhaupt erst ermöglicht. Genau dieser Verkehr ist das Eigentliche, was MCP leistet — und zugleich das, was im Normalbetrieb verborgen bleibt. Der Abruf der Werkzeugliste, die einzelnen Werkzeugaufrufe, ihre Ergebnisse und die wiederholten Modelldurchläufe geschehen hinter den Kulissen. Ohne ihre Sichtbarmachung wäre die rechte Spalte eine bloße black box, die zwar eine bessere Antwort liefert, aber nicht zeigt, warum. Eine Vergleichsansicht, die belehren soll, darf es dabei nicht bewenden lassen. Die Hauptansicht macht den Verkehr daher lesbar — über eine senkrechte Zeitleiste, die neben jeder Antwort mitläuft.

Am Anfang steht ein schlichtes Ereignismodell. Jeder Schritt der Verarbeitung wird als ChatProgress festgehalten, einem record mit einer kleinen, abgeschlossenen Aufzählung von Ereignisarten und je einer Fabrikmethode.

public record ChatProgress(Kind kind, String label, String detail, Instant at) {

    public enum Kind {
        INFO,
        STREAM,
        TOOL_CALL,
        TOOL_RESULT,
        SUCCESS,
        ERROR
    }

    public static ChatProgress info(String label) {
        return new ChatProgress(Kind.INFO, label, null, Instant.now());
    }

    public static ChatProgress info(String label, String detail) {
        return new ChatProgress(Kind.INFO, label, detail, Instant.now());
    }

    public static ChatProgress stream(String label) {
        return new ChatProgress(Kind.STREAM, label, null, Instant.now());
    }

    public static ChatProgress toolCall(String label, String detail) {
        return new ChatProgress(Kind.TOOL_CALL, label, detail, Instant.now());
    }

    public static ChatProgress toolResult(String label, String detail) {
        return new ChatProgress(Kind.TOOL_RESULT, label, detail, Instant.now());
    }

    public static ChatProgress success(String label, Duration elapsed) {
        String d = “%.1fs”.formatted(elapsed.toMillis() / 1000.0);
        return new ChatProgress(Kind.SUCCESS, label, d, Instant.now());
    }

    public static ChatProgress error(String message) {
        return new ChatProgress(Kind.ERROR, “Error”, message, Instant.now());
    }
}

Sechs Ereignisarten genügen, um den gesamten Verlauf eines MCP-Laufs zu beschreiben: eine allgemeine Mitteilung, der Beginn eines Stroms, ein Werkzeugaufruf, ein Werkzeugergebnis, der Erfolg und der Fehler. Jedes Ereignis trägt eine knappe Beschriftung, einen optionalen Detailtext und den Zeitpunkt seines Entstehens. Der Detailtext ist dabei der für das Protokoll entscheidende Teil, denn in ihm stehen die Argumente eines Aufrufs und das zurückgegebene Ergebnis.

Die Darstellung dieser Ereignisse übernimmt die ChatProgressView, eine senkrechte Zeitleiste. Ihre zentrale Methode fügt für jedes Ereignis eine Zeile an.

    public void append(ChatProgress event) {
        Div row = new Div();
        row.addClassName(“recipe-progress__row”);
        row.addClassName(“recipe-progress__row–” + event.kind().name().toLowerCase());

        Icon icon = new Icon(iconFor(event.kind()));
        icon.addClassName(“recipe-progress__icon”);

        Span time = new Span(TIME_FMT.format(
                LocalTime.ofInstant(event.at(), ZoneId.systemDefault())));
        time.addClassName(“recipe-progress__time”);

        Span label = new Span(event.label());
        label.addClassName(“recipe-progress__label”);

        Div head = new Div(icon, time, label);
        head.addClassName(“recipe-progress__head”);
        row.add(head);

        if (event.detail() != null && !event.detail().isBlank()) {
            Pre detail = new Pre(event.detail());
            detail.addClassName(“recipe-progress__detail”);
            row.add(detail);
        }

        add(row);
        getElement().executeJs(“this.scrollTop = this.scrollHeight”);
    }

    private static VaadinIcon iconFor(ChatProgress.Kind k) {
        return switch (k) {
            case INFO        -> VaadinIcon.INFO_CIRCLE_O;
            case STREAM      -> VaadinIcon.PAPERPLANE;
            case TOOL_CALL   -> VaadinIcon.COG;
            case TOOL_RESULT -> VaadinIcon.CHECK_CIRCLE_O;
            case SUCCESS     -> VaadinIcon.CHECK_CIRCLE;
            case ERROR       -> VaadinIcon.WARNING;
        };
    }

Jede Zeile besteht aus einem Kopf — einem zur Ereignisart passenden Symbol, der Uhrzeit und der Beschriftung — und, sofern ein Detailtext vorliegt, einem darunter gesetzten Pre-Block. Eben dieser Pre-Block trägt den Inhalt nichtproportional und damit lesbar gegliedert: Hier erscheinen die Argumente eines Aufrufs und das Ergebnis-JSON in einer Form, die der Betrachter Zeichen für Zeichen nachvollziehen kann. Die Ereignisart bestimmt überdies eine eigene Stilklasse, sodass sich Aufrufe, Ergebnisse und Fehler auch farblich unterscheiden. Abschließend rollt die Methode über einen kleinen JavaScript-Aufruf zum jüngsten Eintrag, damit die Leiste stets das aktuelle Geschehen zeigt.

Erzeugt werden die Ereignisse dort, wo der Verkehr tatsächlich stattfindet — in der Methode runWithMcp, die die MCP-gestützte Anfrage treibt.

    private void runWithMcp(UI ui, String question,
                            AtomicReference<Boolean> leftDone, AtomicReference<Boolean> rightDone) {
        WalkthroughEngine engine = services.engine();
        Instant start = Instant.now();
        try {
            WalkthroughState state = engine.startRun(Prompts.SYSTEM_MCP, question);
            ui.access(() -> rightProgress.append(
                    ChatProgress.info(“Fetching tool list from MCP server”)));
            WalkthroughPhase.ToolCatalog catalog = engine.fetchToolCatalogue(state);
            ui.access(() -> rightProgress.append(
                    ChatProgress.info(“Loaded " + catalog.toolNames().size()
                            + " tools from MCP server”,
                            String.join(", “, catalog.toolNames()))));

            for (int safety = 0; safety < WalkthroughEngine.MAX_ITERATIONS; safety++) {
                final int iterNumber = state.iteration() + 1;
                ui.access(() -> rightProgress.append(ChatProgress.stream(
                        “Iteration " + iterNumber + “: streaming reply…”)));
                WalkthroughPhase.ModelQuery query = engine.runModelQuery(state,
                        chunk -> ui.access(() -> {
                            rightBuffer.append(chunk);
                            rightBody.setContent(rightBuffer.toString());
                        }));
                if (query.toolCalls().isEmpty()) {
                    Duration elapsed = Duration.between(start, Instant.now());
                    int it = state.iteration();
                    ui.access(() -> rightProgress.append(ChatProgress.success(
                            “Reply complete after " + it + " iteration”
                                    + (it == 1 ? "” : “s”),
                            elapsed)));
                    return;
                }
                for (WalkthroughPhase.PendingToolCall pc : query.toolCalls()) {
                    ui.access(() -> rightProgress.append(ChatProgress.toolCall(
                            “Call " + pc.name() + “(…)", pc.argumentsJson())));
                }
                WalkthroughPhase.ToolExecution exec = engine.executeTools(state, query);
                for (WalkthroughPhase.ToolOutcome oc : exec.outcomes()) {
                    ChatProgress event = oc.error()
                            ? ChatProgress.error(oc.name() + “: " + oc.resultText())
                            : ChatProgress.toolResult(“Result from " + oc.name(),
                                    oc.resultText());
                    ui.access(() -> rightProgress.append(event));
                }
            }
            ui.access(() -> rightProgress.append(ChatProgress.info(
                    “Reached max tool-call iteration limit. Stopping.”)));
            ui.access(() -> rightBuffer.append(
                    “\n\n[Reached the maximum tool-call iteration limit. Stopping here.]”));
            ui.access(() -> rightBody.setContent(rightBuffer.toString()));
        } catch (Exception e) {
            logger().warn(“MCP chat failed”, e);
            ui.access(() -> rightProgress.append(ChatProgress.error(e.getMessage())));
            ui.access(() -> rightBody.setContent(”**Error:** " + e.getMessage()));
        } finally {
            rightDone.set(Boolean.TRUE);
            maybeReenable(ui, leftDone, rightDone);
            ui.access(sidebar::refreshGrid);
            ui.access(sidebar::refreshProfileForm);
        }
    }

Die Methode treibt eine Schleife, deren innere Schritte — der Abruf des Katalogs, der Modelldurchlauf, die Werkzeugausführung — vom WalkthroughEngine geleistet und erst im zweiten Teil näher betrachtet werden. Für dieses Kapitel zählt allein, was zwischen diesen Schritten geschieht: An jeder bezeichnenden Stelle wird ein ChatProgress-Ereignis erzeugt und über UI.access an die Zeitleiste angehängt. Zunächst meldet ein Ereignis den Abruf der Werkzeugliste, ein weiteres die Zahl und die Namen der geladenen Werkzeuge. In jedem Durchlauf kündigt ein Ereignis den beginnenden Strom an. Verlangt das Modell keine Werkzeuge mehr, so meldet ein Erfolgsereignis den Abschluss samt verstrichener Zeit. Andernfalls erzeugt die Methode für jeden angeforderten Aufruf ein Aufruf-Ereignis, dessen Detailtext die Argumente trägt, und nach der Ausführung für jedes Ergebnis ein Ergebnis-Ereignis mit dem zurückgegebenen Text — oder, im Fehlerfall, ein Fehlerereignis. So entsteht neben der Antwort eine fortlaufende, mit Zeitstempeln versehene Aufzeichnung des gesamten Verkehrs.

Die Folge dieser Ereignisse lässt sich als Zeitleiste eines Laufs lesen.

Ablauf der Protokollereignisse: Werkzeugliste laden, Antwort streamen, bei Tool-Aufrufen Aufruf und Ergebnis, sonst Abschluss mit Laufzeit
Ablauf der Protokollereignisse: Werkzeugliste laden, Antwort streamen, bei Tool-Aufrufen Aufruf und Ergebnis, sonst Abschluss mit Laufzeit

So nimmt sich die Zeitleiste während eines Laufs aus:

Zeitleiste eines Laufs mit Zeitstempeln: sechs Werkzeuge geladen, Aufruf von list_pantry samt JSON-Ergebnis, Abschluss nach einer Iteration
Zeitleiste eines Laufs mit Zeitstempeln: sechs Werkzeuge geladen, Aufruf von list_pantry samt JSON-Ergebnis, Abschluss nach einer Iteration

Damit ist der Verkehr nicht länger verborgen. Der Betrachter sieht neben der rechten Antwort, welche Werkzeuge das Modell anfordert, mit welchen Argumenten es sie aufruft und welche Ergebnisse zurückfließen — und er sieht es in dem Augenblick, in dem es geschieht. Es sei angemerkt, dass die Schritt-für-Schritt-Ansicht denselben Verkehr noch ausführlicher darstellt: mit einer Karte je Phase und mit parallel ausgeführten Aufrufen als nebeneinanderliegenden Spuren unter einer Parallelmarkierung. Diese reichere, angehaltene Darstellung ist Gegenstand des zweiten Teils; die Hauptansicht begnügt sich mit der knappen, mitlaufenden Zeitleiste.

Eine Einzelheit der Methode weist bereits voraus: Im finally-Block wird nach jedem Lauf die Seitenleiste aktualisiert. Dahinter steht die Frage, wie die Anzeige des Vorrats und des Profils mit dem tatsächlichen Zustand übereinstimmt, den die Werkzeuge womöglich verändert haben. Eben dieser Frage der Zustandskonsistenz wendet sich das folgende Kapitel zu.

Den Zustand konsistent halten

Das vorige Kapitel endete mit einer Einzelheit, die nach vorn wies: Im finally-Block von runWithMcp wird nach jedem Lauf die Seitenleiste aufgefrischt. Dahinter steht ein Konsistenzproblem, dem dieses Kapitel sich widmet. Auf denselben fachlichen Zustand — den Vorrat und das Ernährungsprofil — wirken nämlich zwei Schreiber ein. Der Vorführende ändert ihn von Hand über die Wartungsseitenleiste; das Modell ändert ihn mittelbar über die MCP-Werkzeuge, etwa indem es eine verbrauchte Zutat austrägt oder das Profil anpasst. Eine Anzeige, die beiden Schreibern gerecht werden soll, muss daher mit Sorgfalt geführt werden.

Die erste Entscheidung betrifft den Weg, den die Seitenleiste beim Schreiben nimmt. Sie schreibt unmittelbar gegen den REST-Server und umgeht den MCP-Server bewusst. Der Grund liegt in ihrer Rolle: Die Seitenleiste ist das Werkzeug des Vorführenden, nicht Teil des Modelldialogs. Der REST-Server hält den maßgeblichen Zustand; der MCP-Server greift auf eben diesen Zustand lediglich lesend und schreibend über seine Werkzeuge zu. Es wäre verfehlt, eine Bedienhandlung des Vorführenden durch den Werkzeugkanal des Modells zu leiten. Der Schreibvorgang des Vorrats zeigt dies.

    private void saveItem() {
        try {
            String name = nameField.getValue();
            if (name == null || name.isBlank()) {
                Notification.show(“Name is required”, 2500, Notification.Position.MIDDLE)
                        .addThemeVariants(NotificationVariant.LUMO_ERROR);
                return;
            }
            Double qty = quantityField.getValue();
            if (qty == null) qty = 0.0;
            String unit = blankToDefault(unitField.getValue(), “piece”);
            String category = blankToDefault(categoryField.getValue(), “Pantry”);
            Optional<LocalDate> bestBefore = Optional.ofNullable(bestBeforeField.getValue());
            PantryItem item = new PantryItem(name, qty, unit, bestBefore, category);

            if (nameField.isReadOnly()) {
                pantryClient.replace(item);
                Notification.show(“Updated " + name, 1500, Notification.Position.BOTTOM_START);
            } else {
                pantryClient.create(item);
                Notification.show(“Added " + name, 1500, Notification.Position.BOTTOM_START);
            }
            clearForm();
            refreshGrid();
        } catch (Exception e) {
            Notification.show(e.getMessage(), 3000, Notification.Position.MIDDLE)
                    .addThemeVariants(NotificationVariant.LUMO_ERROR);
        }
    }

Nach einer knappen Prüfung des Namens wird aus den Feldern ein PantryItem gebildet. Ob es angelegt oder ersetzt wird, entscheidet der Zustand des Namensfeldes: Ist es schreibgeschützt, so wurde ein bestehender Eintrag zur Bearbeitung geladen, und der Aufruf replace ersetzt ihn; andernfalls legt create einen neuen an. Entscheidend ist die Wendung am Ende des erfolgreichen Zweigs: Das Formular wird geleert und das Gitter neu geladen. Eben dieselbe Wendung kehrt beim Löschen wieder.

    private void deleteSelected() {
        Optional<PantryItem> sel = grid.getSelectionModel().getFirstSelectedItem();
        if (sel.isEmpty()) {
            Notification.show(“Select an item first”, 1500, Notification.Position.BOTTOM_START);
            return;
        }
        try {
            pantryClient.delete(sel.get().name());
            Notification.show(“Removed " + sel.get().name(), 1500, Notification.Position.BOTTOM_START);
            clearForm();
            refreshGrid();
        } catch (Exception e) {
            Notification.show(e.getMessage(), 3000, Notification.Position.MIDDLE)
                    .addThemeVariants(NotificationVariant.LUMO_ERROR);
        }
    }

Auch hier wird nach dem Schreiben das Gitter neu geladen. Damit zeigt sich der eigentliche Konsistenzgrundsatz, der in der Methode refreshGrid wohnt.

    public void refreshGrid() {
        try {
            grid.setItems(pantryClient.list());
        } catch (Exception e) {
            logger().warn(“Could not refresh pantry grid: {}", e.getMessage());
        }
    }

Das Gitter wird nicht örtlich fortgeschrieben — es wird nicht etwa der gerade angelegte Eintrag der vorhandenen Anzeige hinzugefügt —, sondern vollständig vom Server neu befüllt. Die Anzeige bildet damit nach jeder Änderung den maßgeblichen Zustand ab und niemals eine örtlich gepflegte, womöglich abweichende Sicht. Dieser Grundsatz ist schlicht, aber folgenreich: Solange jede Änderung mit einem Neuladen schließt, kann die Anzeige nicht auseinanderlaufen.

Die Schreibvorgänge selbst führt ein schmaler REST-Client mit den Bordmitteln des JDK aus. Stellvertretend sei die ersetzende Methode betrachtet.

    public PantryItem replace(PantryItem item) {
        try {
            String body = MAPPER.writeValueAsString(item);
            HttpResponse<String> resp = http.send(
                    HttpRequest.newBuilder(URI.create(baseUrl + “/pantry/” + encode(item.name())))
                            .timeout(Duration.ofSeconds(5))
                            .header(“Content-Type”, “application/json”)
                            .PUT(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
                            .build(),
                    HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
            ensureSuccess(resp, “replace pantry item”);
            return MAPPER.readValue(resp.body(), PantryItem.class);
        } catch (RuntimeException re) {
            throw re;
        } catch (Exception e) {
            throw new RuntimeException(“Failed to replace pantry item " + item.name(), e);
        }
    }

Ein HttpRequest mit der Methode PUT trägt den als JSON gefassten Eintrag an die Adresse des REST-Servers; eine Prüfung des Statuscodes stellt den Erfolg sicher. Anlegen und Löschen verfahren mit POST beziehungsweise DELETE nach demselben Muster. Eine zusätzliche Abhängigkeit ist dafür nicht nötig; der HttpClient der Plattform genügt.

Der Profilbereich der Seitenleiste ist nach demselben Muster gebaut. Sein Schreibvorgang überträgt das geänderte Profil über den zugehörigen REST-Client, und seine Auffrischung lädt das Profil ebenso vom Server neu in das Formular. Was für den Vorrat gilt, gilt mithin auch für das Ernährungsprofil: schreiben, dann neu laden.

Bleibt die Gegenrichtung. Die bisher betrachteten Schreibvorgänge gehen vom Vorführenden aus. Der zweite Schreiber ist das Modell, das über die MCP-Werkzeuge denselben Zustand verändert. Damit auch dessen Änderungen sichtbar werden, frischt die Hauptansicht die Seitenleiste nach jedem MCP-Lauf auf — eben jene beiden Aufrufe im finally-Block, die das vorige Kapitel ankündigte: das Neuladen des Gitters und das Neuladen des Profilformulars. So schließt sich der Kreis in beide Richtungen. Ändert der Vorführende die Daten, spiegelt die nächste MCP-gestützte Antwort die Änderung, weil das Modell den Zustand bei jeder Anfrage über die Werkzeuge neu abruft. Ändert das Modell die Daten, spiegelt die Seitenleiste sie, weil sie nach dem Lauf neu lädt.

Datenfluss: Modell und Bediener ändern über MCP-Werkzeuge bzw. Seitenleiste den REST-Server; die Seitenleiste lädt das Grid nach jedem Schreiben neu
Datenfluss: Modell und Bediener ändern über MCP-Werkzeuge bzw. Seitenleiste den REST-Server; die Seitenleiste lädt das Grid nach jedem Schreiben neu

REST-Server als einzige Quelle der Wahrheit: Seitenleiste und MCP-Werkzeuge schreiben dorthin, die Seitenleiste lädt nach jeder Änderung neu
REST-Server als einzige Quelle der Wahrheit: Seitenleiste und MCP-Werkzeuge schreiben dorthin, die Seitenleiste lädt nach jeder Änderung neu

Eben dieser geschlossene Kreis ist der eigentliche Vorführwert der Seitenleiste. Der Vorführende kann ohne Neustart zeigen, wie sich eine Datenänderung auf die nächste Antwort auswirkt — etwa indem er die Ernährungsform wechselt, eine Unverträglichkeit hinzufügt oder eine Zutat austrägt — und er kann zugleich beobachten, wie eine vom Modell ausgeführte Änderung sogleich in der Seitenleiste erscheint. Die Strenge, mit der nach jeder Änderung neu geladen wird, ist die Bedingung dafür, dass diese Vorführung verlässlich gelingt. Damit sind die Bestandteile der Hauptansicht vollständig betrachtet; das folgende und letzte Kapitel dieses Teils zieht die Summe.

Schluss

Die Einleitung dieses Teils hat die Hauptansicht als Beweisführung eingeführt: Das eine Bild zweier nebeneinandergestellter Antworten ersetzt die Behauptung über den Nutzen von MCP durch unmittelbare Wahrnehmung. Die leitende Beobachtung lautete dabei, dass die Überzeugungskraft dieser Gegenüberstellung weniger an der Logik der Antworten als an der Disziplin ihrer Darstellung hängt. Der Rundgang durch die konkreten Hürden hat diese Beobachtung eingelöst.

Sechs Hürden waren zu nehmen. Der frameworkfreie Betrieb legte das Fundament, indem er den Start von Hand verdrahtete und damit offenlegte, was ein Starter sonst verbirgt. Die nebenläufige Führung zweier Anfragen schuf die Gleichzeitigkeit und, durch die symmetrische Erzeugung beider Spalten, die Gleichheit der Bedingungen. Das Einschleusen eines zeichenweisen Stroms in den Komponentenbaum überwand die Trennung zwischen Hintergrund-Thread und serverseitiger Oberfläche, indem @Push und UI.access zusammenwirkten. Das fortlaufende Rendern als Markdown ließ die Antwort als strukturiertes Dokument vor den Augen des Betrachters entstehen. Die Sichtbarmachung des Protokolls verwandelte den verborgenen Werkzeugverkehr in eine lesbare, mitlaufende Zeitleiste. Und die Wahrung eines konsistenten Zustands stellte sicher, dass die Anzeige nach jeder Änderung den maßgeblichen Zustand abbildet, gleich ob der Vorführende oder das Modell ihn verändert hat.

So verschieden diese Hürden sind, verbindet sie ein gemeinsamer Gedanke. Eine Oberfläche für modellgesteuerte Werkzeugnutzung hat nicht nur Ergebnisse anzuzeigen, sondern den Vorgang ihrer Entstehung nachvollziehbar zu halten. Jede der besprochenen Entscheidungen dient eben dieser Nachvollziehbarkeit: die Gleichzeitigkeit, damit der Unterschied der Antworten unabweisbar wird; das fortlaufende Rendern, damit die Antwort lebendig entsteht; die Zeitleiste, damit der Verkehr nicht im Verborgenen bleibt; das Neuladen, damit die Anzeige nicht trügt. Gerade diese Nachvollziehbarkeit ist es, die aus einer bloßen Anzeige eine Beweisführung macht.

Es sei nicht verschwiegen, dass die Hauptansicht bei aller Offenlegung etwas verbirgt. Sie stellt das Ergebnis zweier Anfragen nebeneinander, doch den Mechanismus, der die rechte Antwort hervorbringt, lässt sie im automatischen Lauf verschwinden. Die mitlaufende Zeitleiste deutet ihn an, hält ihn aber nicht an. Eben hier setzt der zweite Teil an. Die Schritt-für-Schritt-Ansicht kehrt das Verhältnis um: Sie hält die Pipeline an, macht jede Phase einzeln auslösbar und verwandelt den ansonsten flüchtigen Ablauf in einen lesbaren, zeitlich gegliederten Gegenstand. Wo dieser Teil zeigte, dass der angebundene Server einen Unterschied bewirkt, wird der folgende zeigen, wie dieser Unterschied zustande kommt — der Übergang vom Was zum Wie.

Zusammenfassung in sechs Schritten von der Frage zur nachvollziehbaren Antwort: ohne Starter, zwei Anfragen, Streaming, Markdown, Protokoll, Zustand
Zusammenfassung in sechs Schritten von der Frage zur nachvollziehbaren Antwort: ohne Starter, zwei Anfragen, Streaming, Markdown, Protokoll, Zustand

Damit ist der erste Teil beschlossen. Die Hauptansicht ist kein Beiwerk des MCP-Servers, sondern das Mittel, mit dem sein Nutzen erfahrbar wird — und sie ist es gerade deshalb, weil sie die Disziplin ihrer Darstellung zur Sache macht.