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:

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.

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.

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 naheliegendereWebAppContext. 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
@Push später überhaupt arbeitenkann. 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 selbsterzeugte; 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.

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 MethoderunWithMcp; 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 alsAtomicReference 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.

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.

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 MethodechatPlain, 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ässtchatPlain 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 zentralenAppShell 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 imfinally-Block, dass die linke Spalte fertig ist, und ruft die im vorigen Kapitel
betrachtete Koordinationsmethode auf; der Fehlerpfad setzt, ebenfalls überUI.access, eine Fehlermeldung.
Der gesamte Weg eines Fragments lässt sich als Folge weniger Schritte lesen.

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.

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 einChatProgress-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.

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

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: Imfinally-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 legtcreate 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.


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.

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.