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`,