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(); } }