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; /// /// In-Process-MCP-Server (siehe Planungsdokument und TODO.md 4.5.25ff.). Lauscht auf der Named Pipe /// und bedient jede eingehende Verbindung (eine je /// LehrerApp.McpBridge-Instanz) als eigene MCP-Session über — /// ein ist ein normaler und kann direkt /// als Ein-/Ausgabe der Session übergeben werden, ohne eigenes JSON-RPC-Parsing. /// /// Nur aktiv, wenn — sonst tut nichts. /// Repositories sind im DI-Container Singletons (siehe AppBootstrapper), deshalb reicht es, die /// Tool-Instanzen und die daraus gebaute einmalig zu bauen und für /// alle Sessions zu teilen. /// 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) { _settings = settings; _logger = logger; _serverOptions = BuildServerOptions( studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools, groupMembershipTools, letterTemplateTools, competencyTools); } /// Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls /// bereits gestartet oder in den Einstellungen deaktiviert (dann bleibt keine Pipe offen). 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) { var toolCollection = new McpServerPrimitiveCollection(); 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."); 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)."); 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, }; } }