Files
LehrerApp/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs
T
adminandClaude Sonnet 5 bfe214ecf2
CI / build-and-test (push) Canceled after 0s
feat: Untis-Hub-Kadenzkopplung, anpassbarer Statusvorschlag + KI-/MCP-Unterstuetzung beim Fehlzeitenabgleich (Nutzer-Feedback)
- 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>
2026-09-12 22:55:33 +02:00

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,
};
}
}