Neue Write-Tools create_time_entry, create_grade_entry und update_student_group_assignment schreiben nie direkt: jeder Aufruf zeigt zuerst einen menschenlesbaren Bestätigungsdialog (bestehender ConfirmDialog, über Dispatcher.UIThread aus dem Pipe-Session-Thread angezeigt) und schreibt erst nach Bestätigung, mit 2-Minuten-Timeout gegen eine hängende Session. Zusätzliches Read-Tool get_lesson_plans. create_note bewusst nicht umgesetzt (kollidiert mit dem bestehenden Dokumentations-Ausschluss aus Phase 1), create_lesson_plan/ update_lesson_plan wegen der Modellkomplexität von Lesson zurückgestellt (siehe TODO.md 4.5.26). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
177 lines
7.6 KiB
C#
177 lines
7.6 KiB
C#
using System.IO.Pipes;
|
|
using LehrerApp.Core.Mcp;
|
|
using LehrerApp.Core.Services;
|
|
using LehrerApp.Desktop.Services.Mcp.Tools;
|
|
using ModelContextProtocol.Protocol;
|
|
using ModelContextProtocol.Server;
|
|
|
|
namespace LehrerApp.Desktop.Services.Mcp;
|
|
|
|
/// <summary>
|
|
/// In-Process-MCP-Server (Phase 1+2, siehe Planungsdokument). Lauscht auf der Named Pipe
|
|
/// <see cref="McpPipeConstants.PipeName"/> und bedient jede eingehende Verbindung (eine je
|
|
/// LehrerApp.McpBridge-Instanz) als eigene MCP-Session über <see cref="StreamServerTransport"/> —
|
|
/// ein <see cref="NamedPipeServerStream"/> ist ein normaler <see cref="Stream"/> und kann direkt
|
|
/// als Ein-/Ausgabe der Session übergeben werden, ohne eigenes JSON-RPC-Parsing.
|
|
///
|
|
/// Nur aktiv, wenn <see cref="McpSettingsService.Enabled"/> — sonst tut <see cref="Start"/> nichts.
|
|
/// Repositories sind im DI-Container Singletons (siehe AppBootstrapper), deshalb reicht es, die
|
|
/// Tool-Instanzen und die daraus gebaute <see cref="McpServerOptions"/> einmalig zu bauen und für
|
|
/// alle Sessions zu teilen.
|
|
/// </summary>
|
|
public sealed class McpServerHostedService : IAsyncDisposable
|
|
{
|
|
private readonly McpSettingsService _settings;
|
|
private readonly AppLogger _logger;
|
|
private readonly McpServerOptions _serverOptions;
|
|
private CancellationTokenSource? _cts;
|
|
private Task? _acceptLoop;
|
|
|
|
public McpServerHostedService(
|
|
McpSettingsService settings, AppLogger logger,
|
|
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
|
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
|
GroupMembershipTools groupMembershipTools)
|
|
{
|
|
_settings = settings;
|
|
_logger = logger;
|
|
_serverOptions = BuildServerOptions(
|
|
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
|
|
groupMembershipTools);
|
|
}
|
|
|
|
/// <summary>Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls
|
|
/// bereits gestartet oder in den Einstellungen deaktiviert (dann bleibt keine Pipe offen).</summary>
|
|
public void Start()
|
|
{
|
|
if (!_settings.Enabled || _cts is not null) return;
|
|
_cts = new CancellationTokenSource();
|
|
_acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token));
|
|
_logger.Info("MCP-Server gestartet, lauscht auf Pipe '" + McpPipeConstants.PipeName + "'.");
|
|
}
|
|
|
|
private async Task AcceptLoopAsync(CancellationToken ct)
|
|
{
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
var pipe = new NamedPipeServerStream(
|
|
McpPipeConstants.PipeName, PipeDirection.InOut,
|
|
NamedPipeServerStream.MaxAllowedServerInstances,
|
|
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
|
|
try
|
|
{
|
|
await pipe.WaitForConnectionAsync(ct);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
await pipe.DisposeAsync();
|
|
break;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Error("MCP: Fehler beim Warten auf eine Bridge-Verbindung.", ex);
|
|
await pipe.DisposeAsync();
|
|
continue;
|
|
}
|
|
|
|
// Nicht awaiten: die Accept-Loop muss sofort weiterlaufen, damit mehrere gleichzeitige
|
|
// Bridge-Instanzen (mehrere KI-Client-Sitzungen) unabhängig bedient werden.
|
|
_ = RunSessionAsync(pipe, ct);
|
|
}
|
|
}
|
|
|
|
private async Task RunSessionAsync(NamedPipeServerStream pipe, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await using var transport = new StreamServerTransport(pipe, pipe, "LehrerApp");
|
|
await using var server = McpServer.Create(transport, _serverOptions, loggerFactory: null, serviceProvider: null);
|
|
await server.RunAsync(ct);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
_logger.Warn($"MCP: Session beendet ({ex.Message}).");
|
|
}
|
|
finally
|
|
{
|
|
await pipe.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_cts is null) return;
|
|
await _cts.CancelAsync();
|
|
if (_acceptLoop is not null)
|
|
{
|
|
try { await _acceptLoop; }
|
|
catch { /* Beenden über Cancellation ist der Normalfall hier */ }
|
|
}
|
|
_cts.Dispose();
|
|
}
|
|
|
|
private static McpServerOptions BuildServerOptions(
|
|
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
|
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
|
GroupMembershipTools groupMembershipTools)
|
|
{
|
|
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
|
|
|
|
void AddReadTool(Delegate handler, string name, string description)
|
|
{
|
|
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
|
{
|
|
Name = name,
|
|
Description = description,
|
|
ReadOnly = true,
|
|
}));
|
|
}
|
|
|
|
// Write-Tools schreiben nie direkt - jede Handler-Methode ruft selbst erst
|
|
// IMcpConfirmationService auf (siehe die jeweilige Tool-Klasse). ReadOnly bewusst false,
|
|
// Destructive bewusst false (keine der Phase-2-Schreiboperationen löscht etwas).
|
|
void AddWriteTool(Delegate handler, string name, string description)
|
|
{
|
|
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
|
{
|
|
Name = name,
|
|
Description = description,
|
|
ReadOnly = false,
|
|
Destructive = false,
|
|
}));
|
|
}
|
|
|
|
AddReadTool(studentTools.GetStudents, "get_students",
|
|
"Listet Schüler, optional gefiltert nach Lerngruppe.");
|
|
AddReadTool(examTools.GetExams, "get_exams",
|
|
"Listet Klausuren, optional gefiltert nach Lerngruppe.");
|
|
AddReadTool(gradeTools.GetGrades, "get_grades",
|
|
"Listet Noten einer Lerngruppe, optional gefiltert auf einen Schüler.");
|
|
AddReadTool(scheduleTools.GetSchedule, "get_schedule",
|
|
"Listet Stundenplan-Einträge, optional gefiltert nach Lerngruppe.");
|
|
AddReadTool(timeEntryTools.GetTimeEntries, "get_time_entries",
|
|
"Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.");
|
|
AddReadTool(lessonPlanTools.GetLessonPlans, "get_lesson_plans",
|
|
"Listet Unterrichtseinheiten und -stunden einer Lerngruppe in einem Zeitraum.");
|
|
|
|
AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry",
|
|
"Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(gradeTools.CreateGradeEntry, "create_grade_entry",
|
|
"Schlägt eine neue Note für einen Schüler vor (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(groupMembershipTools.UpdateStudentGroupAssignment, "update_student_group_assignment",
|
|
"Legt eine Gruppenmitgliedschaft an oder ändert Niveau/Zeitraum (Bestätigung durch den Nutzer nötig).");
|
|
|
|
System.Diagnostics.Debug.Assert(
|
|
toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n)
|
|
.SequenceEqual(McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools).OrderBy(n => n)),
|
|
"Registrierte MCP-Tools weichen von McpToolScope ab.");
|
|
|
|
return new McpServerOptions
|
|
{
|
|
ServerInfo = new Implementation { Name = "LehrerApp", Version = "1.0.0" },
|
|
Capabilities = new ServerCapabilities { Tools = new ToolsCapability() },
|
|
ToolCollection = toolCollection,
|
|
};
|
|
}
|
|
}
|