From 98f5573999def03672b2315fe9dc852020cfc10c Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Fri, 11 Sep 2026 19:59:47 +0200 Subject: [PATCH] feat: lokaler MCP-Server, Phase 1 (Infrastruktur + Read-Tools) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Erlaubt einem lokalen KI-Client (z.B. Claude Desktop) strukturierten Lesezugriff auf Schüler, Klausuren, Noten, Stundenplan und Zeiterfassung. Neuer LehrerApp.McpBridge-Prozess reicht stdio-JSON-RPC über eine Named Pipe an einen In-Process-MCP-Server im Avalonia-Hauptprozess durch (ModelContextProtocol.Core, StreamServerTransport direkt auf der Pipe). Standardmäßig deaktiviert, Opt-in über neuen Einstellungen-Tab. Dokumentationstypen (Gesprächsnotizen/Vorfälle/Förderpläne) sind auf Code-Ebene nie erreichbar (McpToolScope, analog PlainEventStore.Allowed). Write-Tools, Bestätigungsdialog-UI, Worksheets/Lesson-Plans-Tools und macOS-Packaging folgen in späteren Phasen (siehe TODO.md 4.5.25). Co-Authored-By: Claude Sonnet 5 --- Directory.Packages.props | 3 + LehrerApp.Core/Mcp/McpPipeConstants.cs | 12 ++ LehrerApp.Desktop.Tests/Fakes.cs | 8 + LehrerApp.Desktop.Tests/McpToolsTests.cs | 138 ++++++++++++++++ .../SettingsViewModelTests.cs | 4 + LehrerApp.Desktop/App.axaml.cs | 5 + LehrerApp.Desktop/AppBootstrapper.cs | 11 ++ LehrerApp.Desktop/LehrerApp.Desktop.csproj | 1 + .../Services/Mcp/McpServerHostedService.cs | 149 ++++++++++++++++++ .../Services/Mcp/McpToolScope.cs | 21 +++ LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs | 25 +++ .../Services/Mcp/Tools/ExamTools.cs | 23 +++ .../Services/Mcp/Tools/GradeTools.cs | 18 +++ .../Services/Mcp/Tools/ScheduleTools.cs | 16 ++ .../Services/Mcp/Tools/StudentTools.cs | 20 +++ .../Services/Mcp/Tools/TimeEntryTools.cs | 18 +++ .../Services/McpSettingsService.cs | 48 ++++++ .../Settings/SettingsViewModel.McpSettings.cs | 17 ++ .../ViewModels/Settings/SettingsViewModel.cs | 6 +- .../Views/Settings/SettingsView.axaml | 15 ++ .../LehrerApp.McpBridge.csproj | 9 ++ LehrerApp.McpBridge/Program.cs | 84 ++++++++++ LehrerApp.sln | 94 ++++++----- TODO.md | 39 +++++ 24 files changed, 743 insertions(+), 41 deletions(-) create mode 100644 LehrerApp.Core/Mcp/McpPipeConstants.cs create mode 100644 LehrerApp.Desktop.Tests/McpToolsTests.cs create mode 100644 LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs create mode 100644 LehrerApp.Desktop/Services/Mcp/McpToolScope.cs create mode 100644 LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs create mode 100644 LehrerApp.Desktop/Services/Mcp/Tools/ExamTools.cs create mode 100644 LehrerApp.Desktop/Services/Mcp/Tools/GradeTools.cs create mode 100644 LehrerApp.Desktop/Services/Mcp/Tools/ScheduleTools.cs create mode 100644 LehrerApp.Desktop/Services/Mcp/Tools/StudentTools.cs create mode 100644 LehrerApp.Desktop/Services/Mcp/Tools/TimeEntryTools.cs create mode 100644 LehrerApp.Desktop/Services/McpSettingsService.cs create mode 100644 LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.McpSettings.cs create mode 100644 LehrerApp.McpBridge/LehrerApp.McpBridge.csproj create mode 100644 LehrerApp.McpBridge/Program.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 485b98b..b22bb12 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,6 +25,9 @@ + + + diff --git a/LehrerApp.Core/Mcp/McpPipeConstants.cs b/LehrerApp.Core/Mcp/McpPipeConstants.cs new file mode 100644 index 0000000..ddb01ba --- /dev/null +++ b/LehrerApp.Core/Mcp/McpPipeConstants.cs @@ -0,0 +1,12 @@ +namespace LehrerApp.Core.Mcp; + +/// +/// Named-Pipe-Konvention zwischen dem Avalonia-Hauptprozess (Pipe-Server, siehe +/// LehrerApp.Desktop/Services/Mcp) und LehrerApp.McpBridge (Pipe-Client). Bewusst als einzelne +/// geteilte Konstante statt eigener Bibliothek — die Bridge reicht JSON-RPC-Nachrichten unverändert +/// durch und braucht sonst keine gemeinsamen Typen mit der App (siehe Planungsdokument). +/// +public static class McpPipeConstants +{ + public const string PipeName = "LehrerApp.Mcp"; +} diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index b9fbf8e..9b7c537 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -31,6 +31,14 @@ public static class TestSupport new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + /// Analog zu , eigenes Temp-Verzeichnis je Aufruf. + public static McpSettingsService BuildMcpSettingsService() + { + var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-mcpsettings-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + return new McpSettingsService(tempPath); + } + /// Analog zu , eigenes Temp-Verzeichnis je Aufruf. public static WebUntisSettingsService BuildWebUntisSettingsService() { diff --git a/LehrerApp.Desktop.Tests/McpToolsTests.cs b/LehrerApp.Desktop.Tests/McpToolsTests.cs new file mode 100644 index 0000000..bb3eabe --- /dev/null +++ b/LehrerApp.Desktop.Tests/McpToolsTests.cs @@ -0,0 +1,138 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services.Mcp; +using LehrerApp.Desktop.Services.Mcp.Tools; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class McpToolsTests +{ + // ── McpToolScope ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void AllowedReadTools_EnthaeltGenauDieFuenfPhase1Tools() + { + Assert.Equal( + new[] { "get_exams", "get_grades", "get_schedule", "get_students", "get_time_entries" }, + McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal)); + } + + [Fact] + public void AllowedReadTools_EnthaeltKeineDokumentationstypen() + { + // Gesprächsnotizen/Vorfälle/Förderpläne dürfen technisch nie über MCP erreichbar sein + // (siehe Planungsdokument) - die Namenskonvention "documentation"/"vorgang" darf nie auftauchen. + Assert.DoesNotContain(McpToolScope.AllowedReadTools, n => + n.Contains("documentation", StringComparison.OrdinalIgnoreCase) || + n.Contains("vorgang", StringComparison.OrdinalIgnoreCase)); + } + + // ── StudentTools ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void GetStudents_OhneFilter_LiefertNurAktiveSchueler() + { + var active = new Student { FirstName = "Anna", LastName = "Aktiv", IsActive = true }; + var inactive = new Student { FirstName = "Ida", LastName = "Inaktiv", IsActive = false }; + var tool = new StudentTools(new FakeStudents([active, inactive])); + + var result = tool.GetStudents(); + + Assert.Single(result); + Assert.Equal(active.Id, result[0].Id); + } + + [Fact] + public void GetStudents_IncludeInactive_LiefertAuchInaktive() + { + var active = new Student { FirstName = "Anna", LastName = "Aktiv", IsActive = true }; + var inactive = new Student { FirstName = "Ida", LastName = "Inaktiv", IsActive = false }; + var tool = new StudentTools(new FakeStudents([active, inactive])); + + var result = tool.GetStudents(includeInactive: true); + + Assert.Equal(2, result.Count); + } + + // ── ExamTools ──────────────────────────────────────────────────────────────────────────── + + [Fact] + public void GetExams_OhneIncludeResults_LiefertKeineErgebnisse() + { + var groupId = Guid.NewGuid(); + var exam = new Exam { GroupId = groupId, Title = "Klausur 1" }; + var results = new FakeResults(); + results.Add(new ExamResult { ExamId = exam.Id, StudentId = Guid.NewGuid(), TotalPoints = 10 }); + var tool = new ExamTools(new FakeExams([exam]), results); + + var dto = Assert.Single(tool.GetExams(groupId)); + + Assert.Null(dto.Results); + } + + [Fact] + public void GetExams_MitIncludeResults_LiefertErgebnisseJeSchueler() + { + var groupId = Guid.NewGuid(); + var studentId = Guid.NewGuid(); + var exam = new Exam { GroupId = groupId, Title = "Klausur 1" }; + var results = new FakeResults(); + results.Add(new ExamResult { ExamId = exam.Id, StudentId = studentId, TotalPoints = 12.5, Grade = "2+" }); + var tool = new ExamTools(new FakeExams([exam]), results); + + var dto = Assert.Single(tool.GetExams(groupId, includeResults: true)); + + var result = Assert.Single(dto.Results!); + Assert.Equal(studentId, result.StudentId); + Assert.Equal("2+", result.Grade); + } + + // ── GradeTools ─────────────────────────────────────────────────────────────────────────── + + [Fact] + public void GetGrades_MitStudentId_FiltertAufEinenSchueler() + { + var groupId = Guid.NewGuid(); + var studentA = Guid.NewGuid(); + var studentB = Guid.NewGuid(); + var grades = new FakeGrades(); + grades.Add(new Grade { GroupId = groupId, StudentId = studentA, Value = "2" }); + grades.Add(new Grade { GroupId = groupId, StudentId = studentB, Value = "3" }); + var tool = new GradeTools(grades); + + var dto = Assert.Single(tool.GetGrades(groupId, studentA)); + + Assert.Equal(studentA, dto.StudentId); + } + + // ── ScheduleTools ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void GetSchedule_MitGroupId_FiltertNachGruppe() + { + var groupId = Guid.NewGuid(); + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }); + slots.Add(new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 2 }); + var tool = new ScheduleTools(slots); + + var dto = Assert.Single(tool.GetSchedule(groupId)); + + Assert.Equal(groupId, dto.GroupId); + } + + // ── TimeEntryTools ─────────────────────────────────────────────────────────────────────── + + [Fact] + public void GetTimeEntries_FiltertAufDenAngegebenenZeitraum() + { + var entries = new FakeTimeEntries(); + entries.Add(new TimeEntry { Date = new DateOnly(2026, 1, 5), DurationMinutes = 30 }); + entries.Add(new TimeEntry { Date = new DateOnly(2026, 2, 1), DurationMinutes = 45 }); + var tool = new TimeEntryTools(entries); + + var dto = Assert.Single(tool.GetTimeEntries(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31))); + + Assert.Equal(30, dto.DurationMinutes); + } +} diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index daa4da2..a0586f2 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -36,6 +36,7 @@ public sealed class SettingsViewModelTests holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), + TestSupport.BuildMcpSettingsService(), TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), @@ -341,6 +342,7 @@ public sealed class SettingsViewModelTests new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), + TestSupport.BuildMcpSettingsService(), TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), @@ -369,6 +371,7 @@ public sealed class SettingsViewModelTests new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), + TestSupport.BuildMcpSettingsService(), TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), @@ -401,6 +404,7 @@ public sealed class SettingsViewModelTests new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), + TestSupport.BuildMcpSettingsService(), TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs index 90b2e08..a5d40f2 100644 --- a/LehrerApp.Desktop/App.axaml.cs +++ b/LehrerApp.Desktop/App.axaml.cs @@ -94,6 +94,11 @@ public class App : Application _exitHandlerAttached = true; } + // MCP-Server (lokal, Phase 1): Start ist ohne Wirkung, falls in den Einstellungen nicht + // aktiviert (siehe McpServerHostedService.Start). Kein Live-Reload beim Umschalten des + // Opt-in - ein Neustart der App richtet den Pipe-Listener neu ein. + Services.GetRequiredService().Start(); + var mainVm = Services.GetRequiredService(); WireCallbacks(mainVm); diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 35b4e74..e95d250 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -4,6 +4,8 @@ using LehrerApp.Core.Services; using LehrerApp.Data; using LehrerApp.Data.Repositories; using LehrerApp.Desktop.Services; +using LehrerApp.Desktop.Services.Mcp; +using LehrerApp.Desktop.Services.Mcp.Tools; using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels.ClassTeacher; using LehrerApp.Desktop.ViewModels.Exams; @@ -213,6 +215,15 @@ public static class AppBootstrapper }); services.AddSingleton(); + // ── MCP-Server (lokal, Phase 1 – siehe Planungsdokument, optional per Opt-in) ───────── + services.AddSingleton(_ => new McpSettingsService(appData)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + // ── WebUntis-iCal-Abgleich (optional – nur wenn URL hinterlegt und aktiviert) ───────── var untisSettings = new WebUntisSettingsService(appData); services.AddSingleton(untisSettings); diff --git a/LehrerApp.Desktop/LehrerApp.Desktop.csproj b/LehrerApp.Desktop/LehrerApp.Desktop.csproj index 1e080d4..2465d89 100644 --- a/LehrerApp.Desktop/LehrerApp.Desktop.csproj +++ b/LehrerApp.Desktop/LehrerApp.Desktop.csproj @@ -21,6 +21,7 @@ + diff --git a/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs b/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs new file mode 100644 index 0000000..2de0c34 --- /dev/null +++ b/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs @@ -0,0 +1,149 @@ +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 (Phase 1, siehe Planungsdokument). 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) + { + _settings = settings; + _logger = logger; + _serverOptions = BuildServerOptions(studentTools, examTools, gradeTools, scheduleTools, timeEntryTools); + } + + /// 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) + { + var toolCollection = new McpServerPrimitiveCollection(); + + void AddTool(Delegate handler, string name, string description) + { + toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions + { + Name = name, + Description = description, + ReadOnly = true, + })); + } + + AddTool(studentTools.GetStudents, "get_students", + "Listet Schüler, optional gefiltert nach Lerngruppe."); + AddTool(examTools.GetExams, "get_exams", + "Listet Klausuren, optional gefiltert nach Lerngruppe."); + AddTool(gradeTools.GetGrades, "get_grades", + "Listet Noten einer Lerngruppe, optional gefiltert auf einen Schüler."); + AddTool(scheduleTools.GetSchedule, "get_schedule", + "Listet Stundenplan-Einträge, optional gefiltert nach Lerngruppe."); + AddTool(timeEntryTools.GetTimeEntries, "get_time_entries", + "Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich."); + + System.Diagnostics.Debug.Assert( + toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n) + .SequenceEqual(McpToolScope.AllowedReadTools.OrderBy(n => n)), + "Registrierte MCP-Tools weichen von McpToolScope.AllowedReadTools ab."); + + return new McpServerOptions + { + ServerInfo = new Implementation { Name = "LehrerApp", Version = "1.0.0" }, + Capabilities = new ServerCapabilities { Tools = new ToolsCapability() }, + ToolCollection = toolCollection, + }; + } +} diff --git a/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs b/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs new file mode 100644 index 0000000..f6ddb3d --- /dev/null +++ b/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs @@ -0,0 +1,21 @@ +namespace LehrerApp.Desktop.Services.Mcp; + +/// +/// Allowlist der über MCP exponierten Tool-Namen. Dieselbe Absicherung wie +/// LehrerApp.Api/PlainEventStore.cs (dort für den Klartext-Sync-Kanal): Gesprächsnotizen, Vorfälle +/// und Förderpläne (Documentation/Vorgang) sind hier bewusst nie aufgeführt und werden von keiner +/// Tool-Klasse referenziert — ein KI-Client kann diese Daten technisch nicht erreichen, unabhängig +/// davon, wie vertrauenswürdig der lokale Modell-Client erscheint oder wie die Tool-Liste künftig +/// wächst. registriert nur exakt diese Namen. +/// +public static class McpToolScope +{ + public static readonly IReadOnlyCollection AllowedReadTools = + [ + "get_students", + "get_exams", + "get_grades", + "get_schedule", + "get_time_entries", + ]; +} diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs new file mode 100644 index 0000000..1db9f02 --- /dev/null +++ b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs @@ -0,0 +1,25 @@ +using LehrerApp.Core.Models; + +namespace LehrerApp.Desktop.Services.Mcp.Tools; + +// Schlanke, bewusst nicht 1:1 zu den LiteDB-Entities gehaltene Rückgabetypen: verhindert, dass ein +// später zum Modell hinzugefügtes Feld (z.B. ein neues personenbezogenes Attribut) unbeabsichtigt +// über ein MCP-Tool nach außen dringt, nur weil es Teil der Entity-Klasse ist. + +public record StudentDto(Guid Id, string FirstName, string LastName, bool IsActive); + +public record ExamResultDto(Guid StudentId, double TotalPoints, string? Grade, bool Absent); + +public record ExamDto( + Guid Id, Guid GroupId, string Title, DateOnly Date, ExamStatus Status, Niveau? Niveau, + List? Results); + +public record GradeDto( + Guid Id, Guid StudentId, Guid GroupId, GradeCategory Category, string Value, DateOnly Date, + double Weight, string? Note); + +public record TimetableSlotDto(Guid Id, Guid GroupId, DayOfWeek Weekday, int PeriodNumber, string? Room); + +public record TimeEntryDto( + Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date, + TimeOnly? StartTime, TimeOnly? EndTime, int DurationMinutes, string? Description); diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/ExamTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/ExamTools.cs new file mode 100644 index 0000000..423f3ff --- /dev/null +++ b/LehrerApp.Desktop/Services/Mcp/Tools/ExamTools.cs @@ -0,0 +1,23 @@ +using System.ComponentModel; +using LehrerApp.Core.Interfaces; + +namespace LehrerApp.Desktop.Services.Mcp.Tools; + +/// MCP-Read-Tool "get_exams" (Phase 1, siehe Planungsdokument). +public class ExamTools(IExamRepository exams, IExamResultRepository examResults) +{ + [Description("Listet Klausuren, optional gefiltert nach Lerngruppe.")] + public List GetExams( + [Description("Optionale Lerngruppen-ID zum Filtern.")] Guid? groupId = null, + [Description("Ergebnisse je Schüler mitliefern (Standard: nein, hält die Antwort klein).")] bool includeResults = false) + { + var list = groupId is { } id ? exams.GetByGroup(id) : exams.GetAll(); + return list.Select(e => new ExamDto( + e.Id, e.GroupId, e.Title, e.Date, e.Status, e.Niveau, + includeResults + ? examResults.GetByExam(e.Id) + .Select(r => new ExamResultDto(r.StudentId, r.TotalPoints, r.Grade, r.Absent)) + .ToList() + : null)).ToList(); + } +} diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/GradeTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/GradeTools.cs new file mode 100644 index 0000000..b42389a --- /dev/null +++ b/LehrerApp.Desktop/Services/Mcp/Tools/GradeTools.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using LehrerApp.Core.Interfaces; + +namespace LehrerApp.Desktop.Services.Mcp.Tools; + +/// MCP-Read-Tool "get_grades" (Phase 1, siehe Planungsdokument). +public class GradeTools(IGradeRepository grades) +{ + [Description("Listet Noten einer Lerngruppe, optional gefiltert auf einen einzelnen Schüler.")] + public List GetGrades( + [Description("Lerngruppen-ID.")] Guid groupId, + [Description("Optionale Schüler-ID zum Filtern auf einen einzelnen Schüler.")] Guid? studentId = null) + { + var list = studentId is { } sid ? grades.GetByStudentAndGroup(sid, groupId) : grades.GetByGroup(groupId); + return list.Select(g => new GradeDto( + g.Id, g.StudentId, g.GroupId, g.Category, g.Value, g.Date, g.Weight, g.Note)).ToList(); + } +} diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/ScheduleTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/ScheduleTools.cs new file mode 100644 index 0000000..551c5ec --- /dev/null +++ b/LehrerApp.Desktop/Services/Mcp/Tools/ScheduleTools.cs @@ -0,0 +1,16 @@ +using System.ComponentModel; +using LehrerApp.Core.Interfaces; + +namespace LehrerApp.Desktop.Services.Mcp.Tools; + +/// MCP-Read-Tool "get_schedule" (Phase 1, siehe Planungsdokument). +public class ScheduleTools(ITimetableSlotRepository slots) +{ + [Description("Listet Stundenplan-Einträge (Wochenraster), optional gefiltert nach Lerngruppe.")] + public List GetSchedule( + [Description("Optionale Lerngruppen-ID zum Filtern.")] Guid? groupId = null) + { + var list = groupId is { } id ? slots.GetByGroup(id) : slots.GetAll(); + return list.Select(s => new TimetableSlotDto(s.Id, s.GroupId, s.Weekday, s.PeriodNumber, s.Room)).ToList(); + } +} diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/StudentTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/StudentTools.cs new file mode 100644 index 0000000..d64bcd9 --- /dev/null +++ b/LehrerApp.Desktop/Services/Mcp/Tools/StudentTools.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; +using LehrerApp.Core.Interfaces; + +namespace LehrerApp.Desktop.Services.Mcp.Tools; + +/// MCP-Read-Tool "get_students" (Phase 1, siehe Planungsdokument). Reine Lesezugriffe auf +/// die bestehenden Repositories, keine eigene Datenzugriffslogik. +public class StudentTools(IStudentRepository students) +{ + [Description("Listet Schüler, optional gefiltert nach Lerngruppe. Enthält standardmäßig nur aktive Schüler.")] + public List GetStudents( + [Description("Optionale Lerngruppen-ID zum Filtern.")] Guid? groupId = null, + [Description("Auch inaktive/ausgeschiedene Schüler einbeziehen.")] bool includeInactive = false) + { + var list = groupId is { } id ? students.GetByGroup(id) : students.GetAll(includeInactive); + if (groupId is not null && !includeInactive) + list = list.Where(s => s.IsActive).ToList(); + return list.Select(s => new StudentDto(s.Id, s.FirstName, s.LastName, s.IsActive)).ToList(); + } +} diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/TimeEntryTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/TimeEntryTools.cs new file mode 100644 index 0000000..f909d14 --- /dev/null +++ b/LehrerApp.Desktop/Services/Mcp/Tools/TimeEntryTools.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using LehrerApp.Core.Interfaces; + +namespace LehrerApp.Desktop.Services.Mcp.Tools; + +/// MCP-Read-Tool "get_time_entries" (Phase 1, siehe Planungsdokument). Der Zeitraum ist +/// Pflicht (nicht optional), damit eine unbedachte Anfrage nicht die gesamte Zeiterfassungshistorie +/// zurückgibt. +public class TimeEntryTools(ITimeEntryRepository timeEntries) +{ + [Description("Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.")] + public List GetTimeEntries( + [Description("Startdatum (einschließlich), Format YYYY-MM-DD.")] DateOnly from, + [Description("Enddatum (einschließlich), Format YYYY-MM-DD.")] DateOnly to) => + timeEntries.GetByDateRange(from, to).Select(t => new TimeEntryDto( + t.Id, t.TaskId, t.Category, t.GroupId, t.Date, t.StartTime, t.EndTime, + t.DurationMinutes, t.Description)).ToList(); +} diff --git a/LehrerApp.Desktop/Services/McpSettingsService.cs b/LehrerApp.Desktop/Services/McpSettingsService.cs new file mode 100644 index 0000000..c97f5b9 --- /dev/null +++ b/LehrerApp.Desktop/Services/McpSettingsService.cs @@ -0,0 +1,48 @@ +using System.Text.Json; + +namespace LehrerApp.Desktop.Services; + +internal class McpSettingsConfig +{ + public bool Enabled { get; set; } +} + +/// +/// Opt-in-Schalter für den lokalen MCP-Server (siehe Planungsdokument, Phase 1). Anders als +/// braucht Phase 1 kein Login/Token — die Named Pipe selbst ist die +/// Vertrauensgrenze (lokaler Prozess, gleiche Windows-Session bzw. Unix-Dateirechte), siehe +/// Begründung im Planungsdokument. +/// +public class McpSettingsService +{ + private readonly string _configPath; + private McpSettingsConfig _config; + + public bool Enabled => _config.Enabled; + + public McpSettingsService(string appDataPath) + { + _configPath = Path.Combine(appDataPath, "mcp-settings.json"); + _config = Load(); + } + + public void SetEnabled(bool enabled) + { + _config.Enabled = enabled; + Save(); + } + + private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config)); + + private McpSettingsConfig Load() + { + try + { + if (File.Exists(_configPath)) + return JsonSerializer.Deserialize(File.ReadAllText(_configPath)) + ?? new McpSettingsConfig(); + } + catch { /* beschädigte Konfiguration -> Standardwert */ } + return new McpSettingsConfig(); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.McpSettings.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.McpSettings.cs new file mode 100644 index 0000000..f7bbc69 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.McpSettings.cs @@ -0,0 +1,17 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using LehrerApp.Desktop.Services; + +namespace LehrerApp.Desktop.ViewModels.Settings; + +public partial class SettingsViewModel +{ + // ── MCP-Server (lokal, Phase 1) ────────────────────────────────────────── + + [ObservableProperty] private bool _mcpEnabled; + + private void LoadMcpSettings() => McpEnabled = _mcpSettings.Enabled; + + // Kein Live-Reload (siehe McpServerHostedService/Planungsdokument) - der Pipe-Listener wird + // nur beim App-Start eingerichtet, deshalb wirkt eine Änderung hier erst nach Neustart. + partial void OnMcpEnabledChanged(bool value) => _mcpSettings.SetEnabled(value); +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index a55bea3..f35e007 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -40,6 +40,7 @@ public enum SettingsTab WebUntis = 13, Appearance = 14, Trash = 15, + Mcp = 16, } // ── Haupt-ViewModel ─────────────────────────────────────────────────────────── @@ -73,6 +74,7 @@ public partial class SettingsViewModel : ObservableObject private readonly ISupervisionDutyRepository _supervisionDuties; private readonly AiSettingsService _aiSettings; private readonly AiPlanningService _aiPlanning; + private readonly McpSettingsService _mcpSettings; private readonly WebUntisSettingsService _untisSettings; private readonly WebUntisIntegrationService? _untisIntegration; private readonly UntisSyncService? _untisSync; @@ -99,7 +101,7 @@ public partial class SettingsViewModel : ObservableObject IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates, - AiSettingsService aiSettings, AiPlanningService aiPlanning, + AiSettingsService aiSettings, AiPlanningService aiPlanning, McpSettingsService mcpSettings, WebUntisSettingsService untisSettings, AnnualPlanSettingsService annualPlanSettings, SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue, @@ -137,6 +139,7 @@ public partial class SettingsViewModel : ObservableObject _letterTemplates = letterTemplates; _aiSettings = aiSettings; _aiPlanning = aiPlanning; + _mcpSettings = mcpSettings; _untisSettings = untisSettings; _untisIntegration = untisIntegration; _untisSync = untisSync; @@ -164,6 +167,7 @@ public partial class SettingsViewModel : ObservableObject LoadSupervisionDuties(); LoadLetterTemplates(); LoadAiSettings(); + LoadMcpSettings(); LoadUntisSettings(); LoadAnnualPlanSettings(); LoadSyncSettings(); diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml index a455f35..9230507 100644 --- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml +++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml @@ -1142,6 +1142,21 @@ + + + + + + + + + + + + + + diff --git a/LehrerApp.McpBridge/LehrerApp.McpBridge.csproj b/LehrerApp.McpBridge/LehrerApp.McpBridge.csproj new file mode 100644 index 0000000..e2567f9 --- /dev/null +++ b/LehrerApp.McpBridge/LehrerApp.McpBridge.csproj @@ -0,0 +1,9 @@ + + + Exe + net10.0 + + + + + diff --git a/LehrerApp.McpBridge/Program.cs b/LehrerApp.McpBridge/Program.cs new file mode 100644 index 0000000..6ec4f47 --- /dev/null +++ b/LehrerApp.McpBridge/Program.cs @@ -0,0 +1,84 @@ +using System.IO.Pipes; +using System.Text.Json; +using System.Text.Json.Nodes; +using LehrerApp.Core.Mcp; + +// 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. + +const int ConnectTimeoutMs = 3000; + +await using var pipe = new NamedPipeClientStream( + ".", McpPipeConstants.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + +try +{ + 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."); + +await using var stdin = Console.OpenStandardInput(); +await using var stdout = Console.OpenStandardOutput(); + +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() +{ + 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(); +} diff --git a/LehrerApp.sln b/LehrerApp.sln index 2f321ae..989b1b6 100644 --- a/LehrerApp.sln +++ b/LehrerApp.sln @@ -32,6 +32,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Templating.Tests" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.TemplateDesigner.Tests", "LehrerApp.TemplateDesigner.Tests\LehrerApp.TemplateDesigner.Tests.csproj", "{B2000004-0000-0000-0000-000000000004}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.McpBridge", "LehrerApp.McpBridge\LehrerApp.McpBridge.csproj", "{4468C960-EE77-4116-A881-B0BDA0ABDC39}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -42,22 +44,6 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B2000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B2000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU - {B2000002-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B2000002-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B2000002-0000-0000-0000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B2000002-0000-0000-0000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU - {B2000003-0000-0000-0000-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B2000003-0000-0000-0000-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B2000003-0000-0000-0000-000000000003}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B2000003-0000-0000-0000-000000000003}.Release|Any CPU.Build.0 = Release|Any CPU - {B2000004-0000-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B2000004-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B2000004-0000-0000-0000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B2000004-0000-0000-0000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU {A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -94,6 +80,30 @@ Global {A1000003-0000-0000-0000-000000000003}.Release|x64.Build.0 = Release|Any CPU {A1000003-0000-0000-0000-000000000003}.Release|x86.ActiveCfg = Release|Any CPU {A1000003-0000-0000-0000-000000000003}.Release|x86.Build.0 = Release|Any CPU + {A1000007-0000-0000-0000-000000000007}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1000007-0000-0000-0000-000000000007}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1000007-0000-0000-0000-000000000007}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1000007-0000-0000-0000-000000000007}.Debug|x64.Build.0 = Debug|Any CPU + {A1000007-0000-0000-0000-000000000007}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1000007-0000-0000-0000-000000000007}.Debug|x86.Build.0 = Debug|Any CPU + {A1000007-0000-0000-0000-000000000007}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1000007-0000-0000-0000-000000000007}.Release|Any CPU.Build.0 = Release|Any CPU + {A1000007-0000-0000-0000-000000000007}.Release|x64.ActiveCfg = Release|Any CPU + {A1000007-0000-0000-0000-000000000007}.Release|x64.Build.0 = Release|Any CPU + {A1000007-0000-0000-0000-000000000007}.Release|x86.ActiveCfg = Release|Any CPU + {A1000007-0000-0000-0000-000000000007}.Release|x86.Build.0 = Release|Any CPU + {A1000008-0000-0000-0000-000000000008}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1000008-0000-0000-0000-000000000008}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1000008-0000-0000-0000-000000000008}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1000008-0000-0000-0000-000000000008}.Debug|x64.Build.0 = Debug|Any CPU + {A1000008-0000-0000-0000-000000000008}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1000008-0000-0000-0000-000000000008}.Debug|x86.Build.0 = Debug|Any CPU + {A1000008-0000-0000-0000-000000000008}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1000008-0000-0000-0000-000000000008}.Release|Any CPU.Build.0 = Release|Any CPU + {A1000008-0000-0000-0000-000000000008}.Release|x64.ActiveCfg = Release|Any CPU + {A1000008-0000-0000-0000-000000000008}.Release|x64.Build.0 = Release|Any CPU + {A1000008-0000-0000-0000-000000000008}.Release|x86.ActiveCfg = Release|Any CPU + {A1000008-0000-0000-0000-000000000008}.Release|x86.Build.0 = Release|Any CPU {A1000004-0000-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1000004-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1000004-0000-0000-0000-000000000004}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -178,30 +188,34 @@ Global {E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x64.Build.0 = Release|Any CPU {E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x86.ActiveCfg = Release|Any CPU {E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x86.Build.0 = Release|Any CPU - {A1000007-0000-0000-0000-000000000007}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A1000007-0000-0000-0000-000000000007}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A1000007-0000-0000-0000-000000000007}.Debug|x64.ActiveCfg = Debug|Any CPU - {A1000007-0000-0000-0000-000000000007}.Debug|x64.Build.0 = Debug|Any CPU - {A1000007-0000-0000-0000-000000000007}.Debug|x86.ActiveCfg = Debug|Any CPU - {A1000007-0000-0000-0000-000000000007}.Debug|x86.Build.0 = Debug|Any CPU - {A1000007-0000-0000-0000-000000000007}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A1000007-0000-0000-0000-000000000007}.Release|Any CPU.Build.0 = Release|Any CPU - {A1000007-0000-0000-0000-000000000007}.Release|x64.ActiveCfg = Release|Any CPU - {A1000007-0000-0000-0000-000000000007}.Release|x64.Build.0 = Release|Any CPU - {A1000007-0000-0000-0000-000000000007}.Release|x86.ActiveCfg = Release|Any CPU - {A1000007-0000-0000-0000-000000000007}.Release|x86.Build.0 = Release|Any CPU - {A1000008-0000-0000-0000-000000000008}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A1000008-0000-0000-0000-000000000008}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A1000008-0000-0000-0000-000000000008}.Debug|x64.ActiveCfg = Debug|Any CPU - {A1000008-0000-0000-0000-000000000008}.Debug|x64.Build.0 = Debug|Any CPU - {A1000008-0000-0000-0000-000000000008}.Debug|x86.ActiveCfg = Debug|Any CPU - {A1000008-0000-0000-0000-000000000008}.Debug|x86.Build.0 = Debug|Any CPU - {A1000008-0000-0000-0000-000000000008}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A1000008-0000-0000-0000-000000000008}.Release|Any CPU.Build.0 = Release|Any CPU - {A1000008-0000-0000-0000-000000000008}.Release|x64.ActiveCfg = Release|Any CPU - {A1000008-0000-0000-0000-000000000008}.Release|x64.Build.0 = Release|Any CPU - {A1000008-0000-0000-0000-000000000008}.Release|x86.ActiveCfg = Release|Any CPU - {A1000008-0000-0000-0000-000000000008}.Release|x86.Build.0 = Release|Any CPU + {B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B2000002-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2000002-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2000002-0000-0000-0000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2000002-0000-0000-0000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU + {B2000003-0000-0000-0000-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2000003-0000-0000-0000-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2000003-0000-0000-0000-000000000003}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2000003-0000-0000-0000-000000000003}.Release|Any CPU.Build.0 = Release|Any CPU + {B2000004-0000-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2000004-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2000004-0000-0000-0000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2000004-0000-0000-0000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU + {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|x64.ActiveCfg = Debug|Any CPU + {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|x64.Build.0 = Debug|Any CPU + {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|x86.ActiveCfg = Debug|Any CPU + {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|x86.Build.0 = Debug|Any CPU + {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Release|Any CPU.Build.0 = Release|Any CPU + {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Release|x64.ActiveCfg = Release|Any CPU + {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Release|x64.Build.0 = Release|Any CPU + {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Release|x86.ActiveCfg = Release|Any CPU + {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/TODO.md b/TODO.md index 1600265..89d7282 100644 --- a/TODO.md +++ b/TODO.md @@ -2461,6 +2461,45 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`: `WeekCellItem.Date` — letzteres ist nur bei Kopfzeilen gesetzt, nicht bei Stunden-Kacheln (führte im ersten Testlauf zu einem Bug: die Automatik griff nie). +- [x] **4.5.25** Lokaler MCP-Server, Phase 1 (Infrastruktur + Read-Tools), 2026-09-11: erlaubt einem + lokalen KI-Client (z.B. Claude Desktop) strukturierten Lesezugriff auf Schüler, Klausuren, + Noten, Stundenplan und Zeiterfassung — analog zur bestehenden Regel "LLM nur für Intent, nie + für Geometrie" gilt hier "LLM nur für Anfrage/Absicht, nie für direkten Datenbankzugriff": + alle Zugriffe laufen über typisierte Tools, kein freier Query-Zugriff. + - **Architektur:** neues, eigenständiges `LehrerApp.McpBridge`-Projekt (Konsolenprozess, vom + KI-Client per stdio gestartet) reicht JSON-RPC-Nachrichten zeilenweise unverändert über + eine Named Pipe (`LehrerApp.Core/Mcp/McpPipeConstants.cs`) an einen In-Process-MCP-Server im + laufenden Avalonia-Hauptprozess durch + ([McpServerHostedService.cs](LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs)). + Grund: LiteDB ist Single-Writer/Embedded ohne Cross-Prozess-Benachrichtigung — ein + separater Prozess mit eigener DB-Verbindung hätte Stale-Reads gegenüber der laufenden GUI + riskiert. Nutzt `ModelContextProtocol.Core` (`StreamServerTransport` direkt auf dem + `NamedPipeServerStream`, kein eigenes JSON-RPC-Parsing nötig) statt eines + selbstgebauten Protokolls. + - **Tools (5, alle Read-only):** `get_students`, `get_exams`, `get_grades`, `get_schedule`, + `get_time_entries` — je eine schlanke Tool-Klasse unter + [Services/Mcp/Tools/](LehrerApp.Desktop/Services/Mcp/Tools/), DTOs statt direkt + serialisierter LiteDB-Entities. `McpToolScope.cs` dokumentiert die Allowlist der + exponierten Tool-Namen nach demselben Muster wie `PlainEventStore.Allowed` (Klartext- + Sync-Kanal) — Gesprächsnotizen/Vorfälle/Förderpläne (`Documentation`/`Vorgang`) werden von + keiner Tool-Klasse referenziert und sind damit technisch nie erreichbar, nicht nur per + Konvention. + - **Opt-in:** standardmäßig deaktiviert, `McpSettingsService` + Checkbox im neuen + Einstellungen-Tab "MCP-Server" (`SettingsViewModel.McpSettings.cs`). Kein Token/Login nötig + (anders als bei 4.5.9) — die Named Pipe selbst ist die Vertrauensgrenze (lokaler Prozess, + gleiche Windows-Session bzw. Unix-Dateirechte). Wirkt erst nach Neustart der App (kein + Live-Reload des Pipe-Listeners). + - **Verifiziert:** End-to-End-Smoke-Test (Bridge-Prozess als echter Kindprozess, `initialize` + → `tools/list` → `tools/call get_students` über die reale Named Pipe) bestätigt die volle + Kette; 9 Unit-Tests für Tool-Filterlogik und die Scope-Allowlist in + [McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs). + - **Bewusst zurückgestellt (spätere Phasen):** Write-Tools samt Bestätigungsdialog-UI, + `get_lesson_plans`, Worksheets-Tools (`list_worksheets`/`download_worksheet`/...), + macOS-Bundle-Signierung der Bridge-Binary, Named-Pipe-basierte + Einzelinstanz-Absicherung (die Spec-Idee dazu funktioniert nicht, da die Pipe nur bei + aktiviertem Opt-in existiert — LehrerApp hat ohnehin noch keinen + Single-Instance-Mechanismus, unabhängig von MCP). + **Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich, dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`,