CI / build-and-test (push) Canceled after 0s
- UntisHubService.RecordRun: ein abgeschlossener Langzeit-Fehlzeitenabgleich schliesst die kurzfristige Kadenz derselben Gruppe automatisch mit ab (nicht umgekehrt). - Fehlzeitenabgleich-Dialog: neue "Uebernahme als"-ComboBox statt starrem Zielstatus, vorbelegt mit dem berechneten Vorschlag, aber frei aenderbar. - Neuer ai-backend-Endpunkt untis-status.php + AiPlanningService.RequestUntisStatusSuggestionsAsync: gebuendelter, anonymisierter KI-Statusvorschlag (nur Positions-Id + Rohsignale, nie Name/Klasse/ Datum), mit hartem Id-Mengen-Abgleich gegen Verwechslung. - Neue MCP-Tools (UntisComparisonTools): get_untis_hub_status, get_untis_absence_rows/ apply_untis_absence_status (anonymer Weg ueber ENr-Zuordnung) sowie get_named_untis_absence_pattern als bewusste, eng begrenzte Ausnahme (Name+Fehlzeiten fuer explizit angegebene Schueler-IDs, mit Bestaetigung ohne Sitzungsfreigabe - dafuer IMcpConfirmationService.ConfirmAsync um allowSessionTrust erweitert). - MapStatus/ENr-Zuordnung aus dem ViewModel in das neue, geteilte UntisLessonAbsenceHelper gezogen, damit Dialog und MCP-Tool nie unterschiedliche Statusvorschlaege berechnen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
253 lines
14 KiB
C#
253 lines
14 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 (siehe Planungsdokument und TODO.md 4.5.25ff.). 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, LetterTemplateTools letterTemplateTools,
|
|
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools)
|
|
{
|
|
_settings = settings;
|
|
_logger = logger;
|
|
_serverOptions = BuildServerOptions(
|
|
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
|
|
groupMembershipTools, letterTemplateTools, competencyTools, untisComparisonTools);
|
|
}
|
|
|
|
/// <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, LetterTemplateTools letterTemplateTools,
|
|
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools)
|
|
{
|
|
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 dieser Schreiboperationen löscht etwas) - für die
|
|
// Ausnahme siehe AddDestructiveWriteTool/McpToolScope.AllowedDestructiveWriteTools.
|
|
void AddWriteTool(Delegate handler, string name, string description)
|
|
{
|
|
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
|
{
|
|
Name = name,
|
|
Description = description,
|
|
ReadOnly = false,
|
|
Destructive = false,
|
|
}));
|
|
}
|
|
|
|
void AddDestructiveWriteTool(Delegate handler, string name, string description)
|
|
{
|
|
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
|
{
|
|
Name = name,
|
|
Description = description,
|
|
ReadOnly = false,
|
|
Destructive = true,
|
|
}));
|
|
}
|
|
|
|
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.");
|
|
AddReadTool(lessonPlanTools.DownloadLessonAttachment, "download_lesson_attachment",
|
|
"Lädt den Inhalt eines an eine Einzelstunde angehängten Materials Base64-kodiert herunter.");
|
|
AddReadTool(letterTemplateTools.ListLetterTemplates, "list_letter_templates",
|
|
"Listet importierte Elternbrief-Vorlagen mit ihren Platzhaltern.");
|
|
AddReadTool(letterTemplateTools.RenderLetter, "render_letter",
|
|
"Erzeugt einen Elternbrief aus einer Vorlage für einen Schüler als Base64-PDF.");
|
|
AddReadTool(competencyTools.GetSubjects, "get_subjects",
|
|
"Listet alle Fächer.");
|
|
AddReadTool(competencyTools.GetCompetencyCatalog, "get_competency_catalog",
|
|
"Listet den Kompetenzkatalog eines Fachs, optional gefiltert auf eine Klassenstufe.");
|
|
AddReadTool(untisComparisonTools.GetUntisHubStatus, "get_untis_hub_status",
|
|
"Listet die Fälligkeit der Untis-Hub-Abgleiche, ohne selbst WebUntis anzufragen.");
|
|
AddReadTool(untisComparisonTools.GetUntisAbsenceRows, "get_untis_absence_rows",
|
|
"Listet anonymisierte Fehlzeiten-Diskrepanzen einer Lerngruppe gegenüber WebUntis (keine Schülernamen, nur eine row-id je Zeile).");
|
|
AddReadTool(untisComparisonTools.GetNamedUntisAbsencePattern, "get_named_untis_absence_pattern",
|
|
"Liefert Fehlzeiten MIT Schülername für explizit angegebene Schüler-IDs - Ausnahme von der sonstigen Anonymisierung, erfordert jedes Mal eine gesonderte Nutzerbestätigung.");
|
|
|
|
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).");
|
|
AddWriteTool(lessonPlanTools.CreateUnit, "create_unit",
|
|
"Legt eine neue Unterrichtseinheit an (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(lessonPlanTools.UpdateUnit, "update_unit",
|
|
"Ändert Titel/Zeitraum/Status einer Unterrichtseinheit (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(lessonPlanTools.CreateLesson, "create_lesson",
|
|
"Legt eine neue Einzelstunde ohne Verlaufsplan an (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(lessonPlanTools.UpdateLesson, "update_lesson",
|
|
"Ändert Metadaten einer Einzelstunde, ohne den Verlaufsplan anzufassen (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(lessonPlanTools.AddLessonPhase, "add_lesson_phase",
|
|
"Fügt einer Einzelstunde eine Verlaufsplan-Phase hinzu (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(lessonPlanTools.UpdateLessonPhase, "update_lesson_phase",
|
|
"Ändert eine Verlaufsplan-Phase einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(lessonPlanTools.RemoveLessonPhase, "remove_lesson_phase",
|
|
"Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(lessonPlanTools.AddLessonAttachment, "add_lesson_attachment",
|
|
"Fügt einer Einzelstunde ein neues Material als Base64-kodierten Anhang hinzu (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(lessonPlanTools.MoveLesson, "move_lesson",
|
|
"Verschiebt eine Einzelstunde auf ein neues Datum, optional mit Mitverschieben späterer Stunden derselben Einheit (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(lessonPlanTools.AddLessonCompetency, "add_lesson_competency",
|
|
"Ordnet einer Einzelstunde einen Kompetenzcode zu (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(lessonPlanTools.RemoveLessonCompetency, "remove_lesson_competency",
|
|
"Entfernt einen Kompetenzcode von einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(competencyTools.CreateSubject, "create_subject",
|
|
"Legt ein neues Fach an (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(competencyTools.UpdateSubject, "update_subject",
|
|
"Ändert Name/Kurzform eines Fachs (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(competencyTools.CreateCompetencyDomain, "create_competency_domain",
|
|
"Legt einen neuen Kompetenzbereich für ein Fach/eine Klassenstufe an (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(competencyTools.UpdateCompetencyDomain, "update_competency_domain",
|
|
"Ändert Name/Kürzel eines Kompetenzbereichs (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(competencyTools.AddCompetencyItem, "add_competency_item",
|
|
"Fügt einem Kompetenzbereich eine Einzelkompetenz hinzu (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(competencyTools.UpdateCompetencyItem, "update_competency_item",
|
|
"Ändert Code/Beschreibung einer Einzelkompetenz (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(competencyTools.RemoveCompetencyItem, "remove_competency_item",
|
|
"Entfernt eine Einzelkompetenz aus einem Kompetenzbereich (Bestätigung durch den Nutzer nötig).");
|
|
AddWriteTool(untisComparisonTools.ApplyUntisAbsenceStatus, "apply_untis_absence_status",
|
|
"Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id (Bestätigung durch den Nutzer nötig, nennt keinen Schülernamen).");
|
|
|
|
AddDestructiveWriteTool(lessonPlanTools.DeleteLesson, "delete_lesson",
|
|
"Löscht eine Einzelstunde endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
|
|
AddDestructiveWriteTool(competencyTools.DeleteSubject, "delete_subject",
|
|
"Löscht ein Fach endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
|
|
AddDestructiveWriteTool(competencyTools.DeleteCompetencyDomain, "delete_competency_domain",
|
|
"Löscht einen Kompetenzbereich endgültig samt Einzelkompetenzen, ohne Papierkorb (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)
|
|
.Concat(McpToolScope.AllowedDestructiveWriteTools).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,
|
|
};
|
|
}
|
|
}
|