192 lines
8.4 KiB
C#
192 lines
8.4 KiB
C#
using System.IO.Pipes;
|
|
using LehrerApp.Core.Mcp;
|
|
using ModelContextProtocol.Client;
|
|
using ModelContextProtocol.Protocol;
|
|
using ModelContextProtocol.Server;
|
|
|
|
namespace LehrerApp.McpBridge;
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="Tools"/> -
|
|
/// 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 (<see cref="StatusMcpServerTool"/>) 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 (<see cref="RunBackgroundReconnectLoopAsync"/>) versucht unabhängig davon
|
|
/// regelmäßig, die echte Verbindung wiederherzustellen - sobald das klappt, ersetzt <see cref="Tools"/>
|
|
/// das Status-Werkzeug durch die echte, tagesaktuelle Werkzeugliste, und das SDK benachrichtigt
|
|
/// Claude Desktop automatisch per "notifications/tools/list_changed"
|
|
/// (<see cref="McpServerPrimitiveCollection{T}.DeferChangedEvents"/>).
|
|
/// </summary>
|
|
internal sealed class RealServerBridge : IAsyncDisposable
|
|
{
|
|
private const int ConnectTimeoutMs = 3000;
|
|
private static readonly TimeSpan ReconnectCooldown = TimeSpan.FromSeconds(3);
|
|
|
|
public McpServerPrimitiveCollection<McpServerTool> Tools { get; } = new();
|
|
|
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
|
private McpClient? _client;
|
|
private NamedPipeClientStream? _pipe;
|
|
private DateTime? _lastFailedConnectAttemptUtc;
|
|
|
|
/// <summary>Reicht einen Tool-Aufruf an die echte Verbindung weiter (aufgerufen aus
|
|
/// <see cref="ProxyMcpServerTool"/>). Bricht die Verbindung erst hier ab, statt sie proaktiv vor
|
|
/// jedem Aufruf zu prüfen - ein <see cref="ProxyMcpServerTool"/> existiert per Konstruktion nur,
|
|
/// solange <see cref="_client"/> zuletzt verbunden war, ein zusätzlicher Verbindungsversuch davor
|
|
/// wäre unnötige Latenz im Erfolgsfall (dem weit überwiegenden Normalfall).</summary>
|
|
public async Task<CallToolResult> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Versucht, sofern noch nicht verbunden, jetzt sofort eine Verbindung aufzubauen -
|
|
/// aufgerufen sowohl beim Start der Bridge als auch aus <see cref="StatusMcpServerTool"/>
|
|
/// (expliziter Nutzer-/Assistenten-Wunsch, jetzt nachzusehen) und aus dem Hintergrund-Loop.
|
|
/// Nach einem fehlgeschlagenen Versuch schlägt ein weiterer Aufruf innerhalb von
|
|
/// <see cref="ReconnectCooldown"/> sofort fehl, statt erneut den vollen Pipe-Connect-Timeout
|
|
/// abzuwarten - sonst kostet ein versehentlicher Doppelaufruf (z.B. weil ein KI-Client
|
|
/// "lehrerapp_status" trotz gegenteiligem Hinweis im selben Zug erneut aufruft) unnötig weitere
|
|
/// mehrere Sekunden.</summary>
|
|
public async Task<bool> TryReconnectAsync(CancellationToken ct)
|
|
{
|
|
if (_client is not null) return true;
|
|
if (IsInCooldown()) return false;
|
|
await _gate.WaitAsync(ct);
|
|
try
|
|
{
|
|
if (_client is not null) return true; // ein anderer Aufrufer war währenddessen schneller
|
|
if (IsInCooldown()) return false;
|
|
return await ConnectCoreAsync(ct);
|
|
}
|
|
finally { _gate.Release(); }
|
|
}
|
|
|
|
private bool IsInCooldown() =>
|
|
_lastFailedConnectAttemptUtc is { } last && DateTime.UtcNow - last < ReconnectCooldown;
|
|
|
|
private async Task<bool> 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;
|
|
_lastFailedConnectAttemptUtc = null;
|
|
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);
|
|
_lastFailedConnectAttemptUtc = DateTime.UtcNow;
|
|
EnsureFallbackTool();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Stellt sicher, dass mindestens das Status-Werkzeug angeboten wird - ohne
|
|
/// <see cref="Tools"/> anzufassen, wenn bereits etwas darin steht (egal ob Status- oder echte
|
|
/// Werkzeuge), um ein versehentliches Überschreiben bei überlappenden Aufrufen zu vermeiden.</summary>
|
|
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(); }
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
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();
|
|
}
|
|
}
|