feat: LehrerApp.McpBridge zu resilientem MCP-Proxy umgebaut
Claude Desktop deaktivierte den ganzen MCP-Server, sobald der Initialize-Handshake beim Bridge-Start fehlschlug - der Regelfall, wenn LehrerApp.Desktop (z.B. nach Windows-Autostart von Claude Desktop) noch nicht läuft. Die Bridge ist jetzt selbst ein MCP-Server gegenüber Claude Desktop und gleichzeitig ein MCP-Client gegenüber dem echten Server in LehrerApp.Desktop: der Handshake gelingt dadurch immer. Verbunden spiegelt sie die echte Werkzeugliste 1:1; ohne Verbindung bietet sie nur ein lehrerapp_status-Werkzeug an, das den Grund erklärt und erneut verbindet. Ein Hintergrund-Loop versucht unabhängig davon alle 5s zu reconnecten und schaltet per notifications/tools/list_changed automatisch auf die echten Werkzeuge um, sobald LehrerApp erreichbar ist. Bricht die Verbindung während eines laufenden Aufrufs ab, kommt ein normales Tool-Fehlerergebnis statt eines Prozessabsturzes zurück. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user