diff --git a/LehrerApp.McpBridge/ConnectionDiagnostics.cs b/LehrerApp.McpBridge/ConnectionDiagnostics.cs new file mode 100644 index 0000000..0328e48 --- /dev/null +++ b/LehrerApp.McpBridge/ConnectionDiagnostics.cs @@ -0,0 +1,36 @@ +using LehrerApp.Core.Mcp; + +namespace LehrerApp.McpBridge; + +/// +/// Unterscheidet an jeder Stelle, an der die Bridge versucht, sich mit LehrerApp.Desktop zu +/// verbinden, zwischen dem harmlosen Regelfall "LehrerApp läuft gerade nicht" und einem echten +/// Verbindungsfehler. wirft bei einer lokalen +/// Named Pipe (kein Netzwerk, kein Handshake) genau dann eine , wenn +/// innerhalb der Frist kein Prozess auf den Pipe-Namen lauschte - praktisch immer, weil +/// LehrerApp.Desktop nicht läuft oder der MCP-Server dort in den Einstellungen nicht aktiviert ist. +/// Jede andere Exception (z.B. bei einem Rechte-/ +/// ACL-Mismatch zwischen den Prozessen) deutet auf ein echtes Konfigurationsproblem hin. +/// +internal static class ConnectionDiagnostics +{ + public const int NotRunningErrorCode = -32001; + public const int UnexpectedErrorCode = -32002; + + public static (int Code, string ClientMessage, string StderrLine) Describe(Exception ex) => ex switch + { + TimeoutException => ( + NotRunningErrorCode, + "LehrerApp läuft nicht oder der MCP-Server ist in den Einstellungen nicht aktiviert.", + "LehrerApp.McpBridge: Kein Kontakt zu LehrerApp (Pipe „" + McpPipeConstants.PipeName + + "“ nicht erreichbar). Das ist normal, wenn LehrerApp.Desktop gerade nicht läuft oder der " + + "MCP-Server dort in den Einstellungen nicht aktiviert ist - kein Hinweis auf eine " + + "fehlerhafte Bridge-/Client-Konfiguration."), + _ => ( + UnexpectedErrorCode, + $"Unerwarteter Verbindungsfehler ({ex.GetType().Name}): {ex.Message}", + $"LehrerApp.McpBridge: Unerwarteter Fehler beim Verbindungsaufbau zu LehrerApp " + + $"({ex.GetType().Name}: {ex.Message}). Das ist vermutlich KEIN einfaches \"LehrerApp " + + "läuft noch nicht\", sondern deutet auf ein Konfigurations- oder Rechteproblem hin."), + }; +} diff --git a/LehrerApp.McpBridge/LehrerApp.McpBridge.csproj b/LehrerApp.McpBridge/LehrerApp.McpBridge.csproj index e2567f9..b49096c 100644 --- a/LehrerApp.McpBridge/LehrerApp.McpBridge.csproj +++ b/LehrerApp.McpBridge/LehrerApp.McpBridge.csproj @@ -5,5 +5,6 @@ + diff --git a/LehrerApp.McpBridge/Program.cs b/LehrerApp.McpBridge/Program.cs index 6ec4f47..7348f57 100644 --- a/LehrerApp.McpBridge/Program.cs +++ b/LehrerApp.McpBridge/Program.cs @@ -1,84 +1,45 @@ -using System.IO.Pipes; -using System.Text.Json; -using System.Text.Json.Nodes; -using LehrerApp.Core.Mcp; +using LehrerApp.McpBridge; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; -// Zustandsloser Bridge-Prozess: reicht die vom KI-Client über stdin/stdout gesprochene -// JSON-RPC-Verbindung unverändert an die Named Pipe des laufenden LehrerApp-Hauptprozesses durch. -// Kein eigenes JSON-RPC-Verständnis nötig, außer im Fehlerfall (siehe unten) — stdout ist -// ausschließlich für den durchgereichten Protokollstrom reserviert, jede Diagnose geht nach stderr. +// Resilienter Bridge-Prozess (Nutzer-Feedback): meldet sich gegenüber dem KI-Client (z.B. Claude +// Desktop) IMMER erfolgreich als MCP-Server, unabhängig davon, ob LehrerApp.Desktop beim Start +// dieses Prozesses bereits läuft - Claude Desktop startet oft per Windows-Autostart, bevor der +// Nutzer LehrerApp geöffnet hat. Die eigentliche Weiterleitung an den echten Server sowie das +// Nachladen der echten Werkzeugliste, sobald LehrerApp erreichbar ist, übernimmt +// RealServerBridge - Details und Begründung siehe dort. -const int ConnectTimeoutMs = 3000; +var bridge = new RealServerBridge(); +await bridge.TryReconnectAsync(CancellationToken.None); +bridge.EnsureFallbackTool(); -await using var pipe = new NamedPipeClientStream( - ".", McpPipeConstants.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous); +using var cts = new CancellationTokenSource(); +var reconnectLoop = bridge.RunBackgroundReconnectLoopAsync(TimeSpan.FromSeconds(5), cts.Token); -try +var serverOptions = new McpServerOptions { - await pipe.ConnectAsync(ConnectTimeoutMs); -} -catch (Exception ex) -{ - await Console.Error.WriteLineAsync( - $"LehrerApp.McpBridge: Verbindung zu LehrerApp fehlgeschlagen ({ex.Message}). " + - "Läuft die App und ist der MCP-Server in den Einstellungen aktiviert?"); - await RespondWithConnectionErrorAsync(); - Environment.Exit(1); - return; -} - -await Console.Error.WriteLineAsync("LehrerApp.McpBridge: verbunden."); + ServerInfo = new Implementation { Name = "LehrerApp", Version = "1.0.0" }, + // ListChanged = true: Voraussetzung dafür, dass Claude Desktop reagiert, wenn RealServerBridge + // das Status-Werkzeug nachträglich durch die echte Werkzeugliste ersetzt (oder umgekehrt, falls + // LehrerApp während der Sitzung beendet wird). + Capabilities = new ServerCapabilities { Tools = new ToolsCapability { ListChanged = true } }, + ToolCollection = bridge.Tools, +}; await using var stdin = Console.OpenStandardInput(); await using var stdout = Console.OpenStandardOutput(); +await using var transport = new StreamServerTransport(stdin, stdout, "LehrerApp.McpBridge"); +await using var server = McpServer.Create(transport, serverOptions, loggerFactory: null, serviceProvider: null); -var toApp = stdin.CopyToAsync(pipe); -var toClient = pipe.CopyToAsync(stdout); - -// Sobald eine Richtung endet (App beendet die Verbindung, oder der KI-Client schließt stdin), -// ist die Session vorbei — die andere Kopie hängt sonst an einem offenen Handle. -await Task.WhenAny(toApp, toClient); - -// Der Verbindungsaufbau ist fehlgeschlagen, bevor irgendetwas an die App durchgereicht wurde. Der -// MCP-Client wartet zu diesem Zeitpunkt bereits auf eine Antwort auf seine erste Anfrage -// ("initialize") — eine korrekte JSON-RPC-Fehlerantwort braucht deren "id", also wird diese eine -// Zeile noch selbst gelesen und beantwortet, statt den Prozess kommentarlos zu beenden. -static async Task RespondWithConnectionErrorAsync() +try { - string? line; - try - { - line = await Console.In.ReadLineAsync(); - } - catch - { - return; - } - if (string.IsNullOrWhiteSpace(line)) return; - - JsonNode? requestId = null; - try - { - requestId = JsonNode.Parse(line)?["id"]; - } - catch (JsonException) - { - // Keine gültige JSON-RPC-Nachricht - ohne "id" ist keine korrekte Fehlerantwort möglich, - // der Prozess beendet sich dann einfach mit einem Fehler-Exitcode. - return; - } - if (requestId is null) return; - - var response = new JsonObject - { - ["jsonrpc"] = "2.0", - ["id"] = requestId.DeepClone(), - ["error"] = new JsonObject - { - ["code"] = -32001, - ["message"] = "LehrerApp läuft nicht oder der MCP-Server ist in den Einstellungen nicht aktiviert.", - }, - }; - await Console.Out.WriteLineAsync(response.ToJsonString()); - await Console.Out.FlushAsync(); + // Läuft, bis der KI-Client stdin schließt (Sitzungsende) - danach ist nichts mehr zu tun. + await server.RunAsync(cts.Token); +} +finally +{ + await cts.CancelAsync(); + try { await reconnectLoop; } + catch { /* Beenden über Cancellation ist der Normalfall hier */ } + await bridge.DisposeAsync(); } diff --git a/LehrerApp.McpBridge/ProxyMcpServerTool.cs b/LehrerApp.McpBridge/ProxyMcpServerTool.cs new file mode 100644 index 0000000..88f8a9e --- /dev/null +++ b/LehrerApp.McpBridge/ProxyMcpServerTool.cs @@ -0,0 +1,21 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace LehrerApp.McpBridge; + +/// +/// Reicht einen von LehrerApp.Desktop tatsächlich angebotenen MCP-Tool-Aufruf 1:1 an den echten +/// Server weiter - Name, Beschreibung und Eingabeschema () stammen +/// unverändert von dort (siehe ). Die Bridge muss dadurch kein +/// eigenes Wissen über einzelne LehrerApp-Werkzeuge/-Parameter pflegen; ändert sich ein Tool in +/// LehrerApp.Desktop, übernimmt die Bridge das beim nächsten Verbindungsaufbau automatisch. +/// +internal sealed class ProxyMcpServerTool(Tool protocolTool, RealServerBridge bridge) : McpServerTool +{ + public override Tool ProtocolTool { get; } = protocolTool; + public override IReadOnlyList Metadata { get; } = []; + + public override ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken) => + new(bridge.CallRealToolAsync(request.Params, cancellationToken)); +} diff --git a/LehrerApp.McpBridge/RealServerBridge.cs b/LehrerApp.McpBridge/RealServerBridge.cs new file mode 100644 index 0000000..3df5df9 --- /dev/null +++ b/LehrerApp.McpBridge/RealServerBridge.cs @@ -0,0 +1,177 @@ +using System.IO.Pipes; +using LehrerApp.Core.Mcp; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace LehrerApp.McpBridge; + +/// +/// Hält die eigentliche Verbindung zu LehrerApp.Desktop als echter MCP-Client über die Named Pipe +/// (dasselbe ModelContextProtocol-SDK, das der Server in LehrerApp.Desktop nutzt, siehe +/// McpServerHostedService) und spiegelt dessen aktuelle Werkzeugliste 1:1 in - +/// der Werkzeugsammlung, die der Bridge-eigene MCP-Server gegenüber Claude Desktop anbietet. +/// +/// Kernidee (Nutzer-Feedback): ein MCP-Client wie Claude Desktop führt den Initialize-Handshake nur +/// einmal beim Start des Bridge-Prozesses durch und markiert den ganzen Server als fehlgeschlagen +/// (und deaktiviert ihn für die Sitzung), wenn dieser Handshake nicht klappt - typischerweise, weil +/// LehrerApp.Desktop beim Windows-Systemstart noch nicht läuft (Claude Desktop startet oft per +/// Autostart, LehrerApp nicht). Diese Klasse sorgt dafür, dass der Handshake IMMER klappt: ist +/// LehrerApp nicht erreichbar, bietet die Bridge statt der echten Werkzeuge nur ein einzelnes +/// Status-Werkzeug () an, das auf Wunsch erneut verbindet und den +/// aktuellen Stand erklärt - der Assistent kann dem Nutzer dadurch mitteilen, dass LehrerApp +/// gestartet werden muss, statt dass der ganze MCP-Server kommentarlos verschwindet. Ein +/// Hintergrund-Loop () versucht unabhängig davon +/// regelmäßig, die echte Verbindung wiederherzustellen - sobald das klappt, ersetzt +/// das Status-Werkzeug durch die echte, tagesaktuelle Werkzeugliste, und das SDK benachrichtigt +/// Claude Desktop automatisch per "notifications/tools/list_changed" +/// (). +/// +internal sealed class RealServerBridge : IAsyncDisposable +{ + private const int ConnectTimeoutMs = 3000; + + public McpServerPrimitiveCollection Tools { get; } = new(); + + private readonly SemaphoreSlim _gate = new(1, 1); + private McpClient? _client; + private NamedPipeClientStream? _pipe; + + /// Reicht einen Tool-Aufruf an die echte Verbindung weiter (aufgerufen aus + /// ). Bricht die Verbindung erst hier ab, statt sie proaktiv vor + /// jedem Aufruf zu prüfen - ein existiert per Konstruktion nur, + /// solange zuletzt verbunden war, ein zusätzlicher Verbindungsversuch davor + /// wäre unnötige Latenz im Erfolgsfall (dem weit überwiegenden Normalfall). + public async Task CallRealToolAsync(CallToolRequestParams requestParams, CancellationToken ct) + { + var client = _client; + if (client is null) + { + EnsureFallbackTool(); + return ErrorResult("LehrerApp läuft nicht oder der MCP-Server ist in den Einstellungen nicht aktiviert."); + } + + try + { + return await client.CallToolAsync(requestParams, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + var (_, message, stderrLine) = ConnectionDiagnostics.Describe(ex); + await Console.Error.WriteLineAsync(stderrLine); + await DisconnectAsync(); + return ErrorResult("Verbindung zu LehrerApp während des Aufrufs verloren. " + message); + } + } + + /// Versucht, sofern noch nicht verbunden, jetzt sofort eine Verbindung aufzubauen - + /// aufgerufen sowohl beim Start der Bridge als auch aus + /// (expliziter Nutzer-/Assistenten-Wunsch, jetzt nachzusehen) und aus dem Hintergrund-Loop. + public async Task TryReconnectAsync(CancellationToken ct) + { + if (_client is not null) return true; + await _gate.WaitAsync(ct); + try + { + if (_client is not null) return true; // ein anderer Aufrufer war währenddessen schneller + return await ConnectCoreAsync(ct); + } + finally { _gate.Release(); } + } + + private async Task ConnectCoreAsync(CancellationToken ct) + { + var pipe = new NamedPipeClientStream( + ".", McpPipeConstants.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + try + { + await pipe.ConnectAsync(ConnectTimeoutMs, ct); + var transport = new StreamClientTransport(pipe, pipe, loggerFactory: null); + var client = await McpClient.CreateAsync( + transport, + new McpClientOptions { ClientInfo = new Implementation { Name = "LehrerApp.McpBridge", Version = "1.0.0" } }, + loggerFactory: null, ct); + var tools = await client.ListToolsAsync(cancellationToken: ct); + + using (Tools.DeferChangedEvents()) + { + Tools.Clear(); + foreach (var tool in tools) + Tools.Add(new ProxyMcpServerTool(tool.ProtocolTool, this)); + } + + _client = client; + _pipe = pipe; + await Console.Error.WriteLineAsync( + $"LehrerApp.McpBridge: mit LehrerApp verbunden, {tools.Count} Werkzeug(e) übernommen."); + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + await pipe.DisposeAsync(); + var (_, _, stderrLine) = ConnectionDiagnostics.Describe(ex); + await Console.Error.WriteLineAsync(stderrLine); + EnsureFallbackTool(); + return false; + } + } + + /// Stellt sicher, dass mindestens das Status-Werkzeug angeboten wird - ohne + /// anzufassen, wenn bereits etwas darin steht (egal ob Status- oder echte + /// Werkzeuge), um ein versehentliches Überschreiben bei überlappenden Aufrufen zu vermeiden. + public void EnsureFallbackTool() + { + if (Tools.Count > 0) return; + Tools.Add(new StatusMcpServerTool(this)); + } + + private async Task DisconnectAsync() + { + await _gate.WaitAsync(); + try + { + _client = null; + if (_pipe is not null) + { + await _pipe.DisposeAsync(); + _pipe = null; + } + using (Tools.DeferChangedEvents()) + { + Tools.Clear(); + Tools.Add(new StatusMcpServerTool(this)); + } + } + finally { _gate.Release(); } + } + + /// Läuft für die gesamte Prozesslaufzeit mit, unabhängig davon, ob der KI-Client + /// jemals von sich aus "lehrerapp_status" aufruft - sonst würden die echten Werkzeuge erst + /// wieder auftauchen, nachdem jemand aktiv danach gefragt hat. + public async Task RunBackgroundReconnectLoopAsync(TimeSpan interval, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try { await Task.Delay(interval, ct); } + catch (OperationCanceledException) { return; } + + if (_client is null) + { + try { await TryReconnectAsync(ct); } + catch (OperationCanceledException) { return; } + } + } + } + + private static CallToolResult ErrorResult(string message) => new() + { + IsError = true, + Content = [new TextContentBlock { Text = message }], + }; + + public async ValueTask DisposeAsync() + { + if (_pipe is not null) await _pipe.DisposeAsync(); + _gate.Dispose(); + } +} diff --git a/LehrerApp.McpBridge/StatusMcpServerTool.cs b/LehrerApp.McpBridge/StatusMcpServerTool.cs new file mode 100644 index 0000000..cd8668a --- /dev/null +++ b/LehrerApp.McpBridge/StatusMcpServerTool.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace LehrerApp.McpBridge; + +/// +/// Einziges Werkzeug, das die Bridge anbietet, solange keine Verbindung zu LehrerApp besteht (siehe +/// ). Ohne dieses Werkzeug hätte ein KI-Client keine Möglichkeit zu +/// erfahren, WARUM gerade keine LehrerApp-Werkzeuge da sind, sondern sähe nur eine leere +/// Werkzeugliste - der Assistent könnte dem Nutzer dann nicht erklären, dass LehrerApp erst +/// gestartet werden muss. Versucht bei jedem Aufruf aktiv erneut zu verbinden; klappt das, ersetzt +/// dieses Werkzeug automatisch durch die echte Werkzeugliste, und der +/// Client wird per "notifications/tools/list_changed" benachrichtigt (siehe +/// ). +/// +internal sealed class StatusMcpServerTool(RealServerBridge bridge) : McpServerTool +{ + public override Tool ProtocolTool { get; } = new() + { + Name = "lehrerapp_status", + Description = "Prüft, ob LehrerApp erreichbar ist, und versucht bei Bedarf erneut zu " + + "verbinden. Solange keine Verbindung besteht, sind alle anderen LehrerApp-Werkzeuge " + + "(Schüler, Noten, Stundenplanung, Kompetenzen, ...) nicht verfügbar - sie erscheinen erst " + + "nach einer erfolgreichen Verbindung in der Werkzeugliste. Bei einer Fehlermeldung eines " + + "anderen LehrerApp-Werkzeugs, die auf eine fehlende Verbindung hindeutet, dieses Werkzeug " + + "aufrufen, um den Grund zu klären, statt den Nutzer ohne Erklärung zu vertrösten.", + InputSchema = JsonDocument.Parse("""{"type":"object","properties":{}}""").RootElement, + }; + public override IReadOnlyList Metadata { get; } = []; + + public override async ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken) + { + var connected = await bridge.TryReconnectAsync(cancellationToken); + return new CallToolResult + { + Content = + [ + new TextContentBlock + { + Text = connected + ? "Verbindung zu LehrerApp hergestellt. Die eigentlichen LehrerApp-Werkzeuge " + + "sind jetzt verfügbar." + : "LehrerApp läuft nicht oder der MCP-Server ist in den Einstellungen nicht " + + "aktiviert. Bitte den Nutzer bitten, LehrerApp zu starten und den " + + "MCP-Server in den Einstellungen zu aktivieren - dieses Werkzeug danach " + + "erneut aufrufen, um zu prüfen, ob die Verbindung jetzt klappt.", + }, + ], + }; + } +} diff --git a/TODO.md b/TODO.md index c0e6129..3b179ab 100644 --- a/TODO.md +++ b/TODO.md @@ -2756,6 +2756,62 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`: schrieb und ein reiner No-Op genügte. - 16 neue Tests in [McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs) (jetzt 57). +- [x] **4.5.34** `LehrerApp.McpBridge` zu einem resilienten MCP-Proxy umgebaut, statt bei + fehlendem Kontakt zu LehrerApp den ganzen Prozess fehlschlagen zu lassen (2026-09-12, + Nutzer-Feedback): Claude Desktop startet per Windows-Autostart oft, bevor LehrerApp.Desktop + läuft. Die Bridge führte den MCP-Initialize-Handshake bisher selbst nur einmal beim + Prozessstart durch und ließ ihn fehlschlagen, wenn die Pipe zu diesem Zeitpunkt nicht + erreichbar war — Claude Desktop markiert einen MCP-Server nach einem fehlgeschlagenen + Handshake als tot und deaktiviert ihn für die Sitzung, ohne erneut zu versuchen. Ein erster + Zwischenschritt (nur die Fehlermeldung nach Ursache unterscheiden, `TimeoutException` vs. + echter Fehler) hätte daran nichts geändert — auf Nachfrage entschieden, stattdessen das + eigentliche Problem zu lösen. + - **Neue Architektur:** die Bridge ist jetzt selbst ein vollwertiger MCP-Server (über + `ModelContextProtocol.Server`, dieselbe SDK-Bausteine wie `McpServerHostedService` in + LehrerApp.Desktop) UND gleichzeitig ein MCP-**Client** gegenüber dem echten Server in + LehrerApp.Desktop (`ModelContextProtocol.Client.McpClient` über + `ModelContextProtocol.Protocol.StreamClientTransport` auf derselben Named Pipe). Der + Initialize-Handshake gegenüber Claude Desktop gelingt dadurch **immer**, unabhängig davon, + ob LehrerApp erreichbar ist. + - Neues [RealServerBridge.cs](LehrerApp.McpBridge/RealServerBridge.cs): hält die Client- + Verbindung zu LehrerApp und spiegelt deren aktuelle Werkzeugliste 1:1 (Name, Beschreibung, + Eingabeschema unverändert übernommen) in die Werkzeugsammlung, die die Bridge Claude + Desktop anbietet — kein eigenes Wissen über einzelne LehrerApp-Werkzeuge nötig, ändert sich + eins in LehrerApp.Desktop, übernimmt die Bridge das automatisch beim nächsten + Verbindungsaufbau. Ist LehrerApp nicht erreichbar, bietet die Bridge statt der echten + Werkzeuge genau ein Werkzeug an, + [StatusMcpServerTool.cs](LehrerApp.McpBridge/StatusMcpServerTool.cs) + (`lehrerapp_status`) — es erklärt der KI, dass LehrerApp gestartet werden muss, und + versucht bei jedem Aufruf aktiv erneut zu verbinden. Ein Hintergrund-Loop + (`RunBackgroundReconnectLoopAsync`, alle 5s) versucht das unabhängig davon ebenfalls + laufend, damit die echten Werkzeuge auch ohne aktives Nachfragen zurückkommen, sobald der + Nutzer LehrerApp startet. Jeder Wechsel zwischen Status-Werkzeug und echter Werkzeugliste + löst automatisch `notifications/tools/list_changed` aus + (`McpServerPrimitiveCollection.DeferChangedEvents`), worauf ein MCP-konformer Client + seine Werkzeugliste neu abruft. + - [ProxyMcpServerTool.cs](LehrerApp.McpBridge/ProxyMcpServerTool.cs) reicht einen Tool-Aufruf + unverändert an den echten Client weiter; bricht die Verbindung während eines Aufrufs ab + (LehrerApp wurde beendet), liefert `RealServerBridge.CallRealToolAsync` ein reguläres + `CallToolResult` mit `IsError = true` und einer erklärenden Nachricht zurück — kein + Transport-/Prozessabbruch, die Sitzung mit Claude Desktop bleibt bestehen, nur dieser eine + Aufruf schlägt sichtbar (für den Assistenten erklärbar) fehl. + - [ConnectionDiagnostics.cs](LehrerApp.McpBridge/ConnectionDiagnostics.cs): dieselbe + Unterscheidung wie im verworfenen Zwischenschritt (`TimeoutException` = LehrerApp läuft + nicht/MCP-Server nicht aktiviert, alles andere = echtes Konfigurations-/Rechteproblem, z.B. + ein ACL-Mismatch zwischen den Prozessen) — jetzt als gemeinsam genutzter Baustein an allen + drei Verbindungsversuchsstellen (initialer Connect, Hintergrund-Loop, Status-Werkzeug) + statt nur im alten Einzel-Connect-Pfad. + - End-to-End mit einer echten, laufenden LehrerApp.Desktop-Instanz verifiziert (manuelles + JSON-RPC über die Bridge): erfolgreicher Connect übernimmt alle 35 registrierten Werkzeuge + mit vollem Schema; `get_students` liefert echte Daten; LehrerApp.Desktop während einer + laufenden Sitzung beendet lässt einen in Arbeit befindlichen Aufruf mit lesbarer + Fehlermeldung statt Absturz fehlschlagen und schaltet auf das Status-Werkzeug um; + LehrerApp.Desktop neu gestartet stellt die volle Werkzeugliste innerhalb des + Hintergrund-Loop-Intervalls automatisch wieder her. + - Kein zusätzliches Feature wie Windows-Autostart für LehrerApp.Desktop selbst oder ein + Tray-Icon (auf Nachfrage bewusst nicht Teil dieser Änderung) — die Bridge löst das Problem + rein auf Protokollebene, unabhängig davon, wann der Nutzer LehrerApp tatsächlich startet. + **Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich, dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`,