feat: lokaler MCP-Server, Phase 1 (Infrastruktur + Read-Tools)

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 19:59:47 +02:00
co-authored by Claude Sonnet 5
parent 455c61c946
commit 98f5573999
24 changed files with 743 additions and 41 deletions
+3
View File
@@ -25,6 +25,9 @@
<!-- API --> <!-- API -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<!-- MCP-Server (lokal, siehe TODO.md) -->
<PackageVersion Include="ModelContextProtocol.Core" Version="2.2.0" />
<!-- Tests --> <!-- Tests -->
<PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
+12
View File
@@ -0,0 +1,12 @@
namespace LehrerApp.Core.Mcp;
/// <summary>
/// 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).
/// </summary>
public static class McpPipeConstants
{
public const string PipeName = "LehrerApp.Mcp";
}
+8
View File
@@ -31,6 +31,14 @@ public static class TestSupport
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
/// Analog zu <see cref="BuildAiSettingsService"/>, 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 <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf. /// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static WebUntisSettingsService BuildWebUntisSettingsService() public static WebUntisSettingsService BuildWebUntisSettingsService()
{ {
+138
View File
@@ -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);
}
}
@@ -36,6 +36,7 @@ public sealed class SettingsViewModelTests
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
@@ -341,6 +342,7 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
@@ -369,6 +371,7 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
@@ -401,6 +404,7 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
+5
View File
@@ -94,6 +94,11 @@ public class App : Application
_exitHandlerAttached = true; _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<Services.Mcp.McpServerHostedService>().Start();
var mainVm = Services.GetRequiredService<MainWindowViewModel>(); var mainVm = Services.GetRequiredService<MainWindowViewModel>();
WireCallbacks(mainVm); WireCallbacks(mainVm);
+11
View File
@@ -4,6 +4,8 @@ using LehrerApp.Core.Services;
using LehrerApp.Data; using LehrerApp.Data;
using LehrerApp.Data.Repositories; using LehrerApp.Data.Repositories;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.Services.Mcp;
using LehrerApp.Desktop.Services.Mcp.Tools;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.ClassTeacher; using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Exams; using LehrerApp.Desktop.ViewModels.Exams;
@@ -213,6 +215,15 @@ public static class AppBootstrapper
}); });
services.AddSingleton<AiPlanningService>(); services.AddSingleton<AiPlanningService>();
// ── MCP-Server (lokal, Phase 1 siehe Planungsdokument, optional per Opt-in) ─────────
services.AddSingleton(_ => new McpSettingsService(appData));
services.AddSingleton<StudentTools>();
services.AddSingleton<ExamTools>();
services.AddSingleton<GradeTools>();
services.AddSingleton<ScheduleTools>();
services.AddSingleton<TimeEntryTools>();
services.AddSingleton<McpServerHostedService>();
// ── WebUntis-iCal-Abgleich (optional nur wenn URL hinterlegt und aktiviert) ───────── // ── WebUntis-iCal-Abgleich (optional nur wenn URL hinterlegt und aktiviert) ─────────
var untisSettings = new WebUntisSettingsService(appData); var untisSettings = new WebUntisSettingsService(appData);
services.AddSingleton(untisSettings); services.AddSingleton(untisSettings);
@@ -21,6 +21,7 @@
<PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="QuestPDF" /> <PackageReference Include="QuestPDF" />
<PackageReference Include="ModelContextProtocol.Core" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="Assets\**" />
@@ -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;
/// <summary>
/// In-Process-MCP-Server (Phase 1, siehe Planungsdokument). Lauscht auf der Named Pipe
/// <see cref="McpPipeConstants.PipeName"/> und bedient jede eingehende Verbindung (eine je
/// LehrerApp.McpBridge-Instanz) als eigene MCP-Session über <see cref="StreamServerTransport"/> —
/// ein <see cref="NamedPipeServerStream"/> ist ein normaler <see cref="Stream"/> und kann direkt
/// als Ein-/Ausgabe der Session übergeben werden, ohne eigenes JSON-RPC-Parsing.
///
/// Nur aktiv, wenn <see cref="McpSettingsService.Enabled"/> — sonst tut <see cref="Start"/> nichts.
/// Repositories sind im DI-Container Singletons (siehe AppBootstrapper), deshalb reicht es, die
/// Tool-Instanzen und die daraus gebaute <see cref="McpServerOptions"/> einmalig zu bauen und für
/// alle Sessions zu teilen.
/// </summary>
public sealed class McpServerHostedService : IAsyncDisposable
{
private readonly McpSettingsService _settings;
private readonly AppLogger _logger;
private readonly McpServerOptions _serverOptions;
private CancellationTokenSource? _cts;
private Task? _acceptLoop;
public McpServerHostedService(
McpSettingsService settings, AppLogger logger,
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools)
{
_settings = settings;
_logger = logger;
_serverOptions = BuildServerOptions(studentTools, examTools, gradeTools, scheduleTools, timeEntryTools);
}
/// <summary>Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls
/// bereits gestartet oder in den Einstellungen deaktiviert (dann bleibt keine Pipe offen).</summary>
public void Start()
{
if (!_settings.Enabled || _cts is not null) return;
_cts = new CancellationTokenSource();
_acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token));
_logger.Info("MCP-Server gestartet, lauscht auf Pipe '" + McpPipeConstants.PipeName + "'.");
}
private async Task AcceptLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var pipe = new NamedPipeServerStream(
McpPipeConstants.PipeName, PipeDirection.InOut,
NamedPipeServerStream.MaxAllowedServerInstances,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
try
{
await pipe.WaitForConnectionAsync(ct);
}
catch (OperationCanceledException)
{
await pipe.DisposeAsync();
break;
}
catch (Exception ex)
{
_logger.Error("MCP: Fehler beim Warten auf eine Bridge-Verbindung.", ex);
await pipe.DisposeAsync();
continue;
}
// Nicht awaiten: die Accept-Loop muss sofort weiterlaufen, damit mehrere gleichzeitige
// Bridge-Instanzen (mehrere KI-Client-Sitzungen) unabhängig bedient werden.
_ = RunSessionAsync(pipe, ct);
}
}
private async Task RunSessionAsync(NamedPipeServerStream pipe, CancellationToken ct)
{
try
{
await using var transport = new StreamServerTransport(pipe, pipe, "LehrerApp");
await using var server = McpServer.Create(transport, _serverOptions, loggerFactory: null, serviceProvider: null);
await server.RunAsync(ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.Warn($"MCP: Session beendet ({ex.Message}).");
}
finally
{
await pipe.DisposeAsync();
}
}
public async ValueTask DisposeAsync()
{
if (_cts is null) return;
await _cts.CancelAsync();
if (_acceptLoop is not null)
{
try { await _acceptLoop; }
catch { /* Beenden über Cancellation ist der Normalfall hier */ }
}
_cts.Dispose();
}
private static McpServerOptions BuildServerOptions(
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools)
{
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
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,
};
}
}
@@ -0,0 +1,21 @@
namespace LehrerApp.Desktop.Services.Mcp;
/// <summary>
/// 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. <see cref="McpServerHostedService"/> registriert nur exakt diese Namen.
/// </summary>
public static class McpToolScope
{
public static readonly IReadOnlyCollection<string> AllowedReadTools =
[
"get_students",
"get_exams",
"get_grades",
"get_schedule",
"get_time_entries",
];
}
@@ -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<ExamResultDto>? 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);
@@ -0,0 +1,23 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Read-Tool "get_exams" (Phase 1, siehe Planungsdokument).</summary>
public class ExamTools(IExamRepository exams, IExamResultRepository examResults)
{
[Description("Listet Klausuren, optional gefiltert nach Lerngruppe.")]
public List<ExamDto> 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();
}
}
@@ -0,0 +1,18 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Read-Tool "get_grades" (Phase 1, siehe Planungsdokument).</summary>
public class GradeTools(IGradeRepository grades)
{
[Description("Listet Noten einer Lerngruppe, optional gefiltert auf einen einzelnen Schüler.")]
public List<GradeDto> 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();
}
}
@@ -0,0 +1,16 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Read-Tool "get_schedule" (Phase 1, siehe Planungsdokument).</summary>
public class ScheduleTools(ITimetableSlotRepository slots)
{
[Description("Listet Stundenplan-Einträge (Wochenraster), optional gefiltert nach Lerngruppe.")]
public List<TimetableSlotDto> 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();
}
}
@@ -0,0 +1,20 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Read-Tool "get_students" (Phase 1, siehe Planungsdokument). Reine Lesezugriffe auf
/// die bestehenden Repositories, keine eigene Datenzugriffslogik.</summary>
public class StudentTools(IStudentRepository students)
{
[Description("Listet Schüler, optional gefiltert nach Lerngruppe. Enthält standardmäßig nur aktive Schüler.")]
public List<StudentDto> 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();
}
}
@@ -0,0 +1,18 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>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.</summary>
public class TimeEntryTools(ITimeEntryRepository timeEntries)
{
[Description("Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.")]
public List<TimeEntryDto> 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();
}
@@ -0,0 +1,48 @@
using System.Text.Json;
namespace LehrerApp.Desktop.Services;
internal class McpSettingsConfig
{
public bool Enabled { get; set; }
}
/// <summary>
/// Opt-in-Schalter für den lokalen MCP-Server (siehe Planungsdokument, Phase 1). Anders als
/// <see cref="AiSettingsService"/> 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.
/// </summary>
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<McpSettingsConfig>(File.ReadAllText(_configPath))
?? new McpSettingsConfig();
}
catch { /* beschädigte Konfiguration -> Standardwert */ }
return new McpSettingsConfig();
}
}
@@ -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);
}
@@ -40,6 +40,7 @@ public enum SettingsTab
WebUntis = 13, WebUntis = 13,
Appearance = 14, Appearance = 14,
Trash = 15, Trash = 15,
Mcp = 16,
} }
// ── Haupt-ViewModel ─────────────────────────────────────────────────────────── // ── Haupt-ViewModel ───────────────────────────────────────────────────────────
@@ -73,6 +74,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly ISupervisionDutyRepository _supervisionDuties; private readonly ISupervisionDutyRepository _supervisionDuties;
private readonly AiSettingsService _aiSettings; private readonly AiSettingsService _aiSettings;
private readonly AiPlanningService _aiPlanning; private readonly AiPlanningService _aiPlanning;
private readonly McpSettingsService _mcpSettings;
private readonly WebUntisSettingsService _untisSettings; private readonly WebUntisSettingsService _untisSettings;
private readonly WebUntisIntegrationService? _untisIntegration; private readonly WebUntisIntegrationService? _untisIntegration;
private readonly UntisSyncService? _untisSync; private readonly UntisSyncService? _untisSync;
@@ -99,7 +101,7 @@ public partial class SettingsViewModel : ObservableObject
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays, IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates, ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning, AiSettingsService aiSettings, AiPlanningService aiPlanning, McpSettingsService mcpSettings,
WebUntisSettingsService untisSettings, WebUntisSettingsService untisSettings,
AnnualPlanSettingsService annualPlanSettings, AnnualPlanSettingsService annualPlanSettings,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue, SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
@@ -137,6 +139,7 @@ public partial class SettingsViewModel : ObservableObject
_letterTemplates = letterTemplates; _letterTemplates = letterTemplates;
_aiSettings = aiSettings; _aiSettings = aiSettings;
_aiPlanning = aiPlanning; _aiPlanning = aiPlanning;
_mcpSettings = mcpSettings;
_untisSettings = untisSettings; _untisSettings = untisSettings;
_untisIntegration = untisIntegration; _untisIntegration = untisIntegration;
_untisSync = untisSync; _untisSync = untisSync;
@@ -164,6 +167,7 @@ public partial class SettingsViewModel : ObservableObject
LoadSupervisionDuties(); LoadSupervisionDuties();
LoadLetterTemplates(); LoadLetterTemplates();
LoadAiSettings(); LoadAiSettings();
LoadMcpSettings();
LoadUntisSettings(); LoadUntisSettings();
LoadAnnualPlanSettings(); LoadAnnualPlanSettings();
LoadSyncSettings(); LoadSyncSettings();
@@ -1142,6 +1142,21 @@
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: MCP-Server (lokal, Phase 1) -->
<ContentPage Header="MCP-Server">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="460">
<TextBlock Text="MCP-Server" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Erlaubt einem lokalen KI-Client (z.B. Claude Desktop) strukturierten Lesezugriff auf Schüler, Klausuren, Noten, Stundenplan und Zeiterfassung dieses Geräts über eine lokale Named Pipe. Es verlassen keine Daten das Gerät; Gesprächsnotizen, Vorfälle und Förderpläne sind nicht erreichbar. Eine Änderung wirkt erst nach einem Neustart der App."/>
<CheckBox Content="MCP-Server aktivieren" IsChecked="{Binding McpEnabled}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
</TabbedPage> </TabbedPage>
</Grid> </Grid>
</UserControl> </UserControl>
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
</ItemGroup>
</Project>
+84
View File
@@ -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();
}
+54 -40
View File
@@ -32,6 +32,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Templating.Tests"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.TemplateDesigner.Tests", "LehrerApp.TemplateDesigner.Tests\LehrerApp.TemplateDesigner.Tests.csproj", "{B2000004-0000-0000-0000-000000000004}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.TemplateDesigner.Tests", "LehrerApp.TemplateDesigner.Tests\LehrerApp.TemplateDesigner.Tests.csproj", "{B2000004-0000-0000-0000-000000000004}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.McpBridge", "LehrerApp.McpBridge\LehrerApp.McpBridge.csproj", "{4468C960-EE77-4116-A881-B0BDA0ABDC39}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -42,22 +44,6 @@ Global
Release|x86 = Release|x86 Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution 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.ActiveCfg = Debug|Any CPU
{A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = 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 {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|x64.Build.0 = Release|Any CPU
{A1000003-0000-0000-0000-000000000003}.Release|x86.ActiveCfg = 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 {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.ActiveCfg = Debug|Any CPU
{A1000004-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = 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 {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|x64.Build.0 = Release|Any CPU
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x86.ActiveCfg = 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 {E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x86.Build.0 = Release|Any CPU
{A1000007-0000-0000-0000-000000000007}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1000007-0000-0000-0000-000000000007}.Debug|Any CPU.Build.0 = Debug|Any CPU {B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000007-0000-0000-0000-000000000007}.Debug|x64.ActiveCfg = Debug|Any CPU {B2000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1000007-0000-0000-0000-000000000007}.Debug|x64.Build.0 = Debug|Any CPU {B2000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
{A1000007-0000-0000-0000-000000000007}.Debug|x86.ActiveCfg = Debug|Any CPU {B2000002-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1000007-0000-0000-0000-000000000007}.Debug|x86.Build.0 = Debug|Any CPU {B2000002-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000007-0000-0000-0000-000000000007}.Release|Any CPU.ActiveCfg = Release|Any CPU {B2000002-0000-0000-0000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1000007-0000-0000-0000-000000000007}.Release|Any CPU.Build.0 = Release|Any CPU {B2000002-0000-0000-0000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU
{A1000007-0000-0000-0000-000000000007}.Release|x64.ActiveCfg = Release|Any CPU {B2000003-0000-0000-0000-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1000007-0000-0000-0000-000000000007}.Release|x64.Build.0 = Release|Any CPU {B2000003-0000-0000-0000-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000007-0000-0000-0000-000000000007}.Release|x86.ActiveCfg = Release|Any CPU {B2000003-0000-0000-0000-000000000003}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1000007-0000-0000-0000-000000000007}.Release|x86.Build.0 = Release|Any CPU {B2000003-0000-0000-0000-000000000003}.Release|Any CPU.Build.0 = Release|Any CPU
{A1000008-0000-0000-0000-000000000008}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B2000004-0000-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1000008-0000-0000-0000-000000000008}.Debug|Any CPU.Build.0 = Debug|Any CPU {B2000004-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000008-0000-0000-0000-000000000008}.Debug|x64.ActiveCfg = Debug|Any CPU {B2000004-0000-0000-0000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1000008-0000-0000-0000-000000000008}.Debug|x64.Build.0 = Debug|Any CPU {B2000004-0000-0000-0000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU
{A1000008-0000-0000-0000-000000000008}.Debug|x86.ActiveCfg = Debug|Any CPU {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1000008-0000-0000-0000-000000000008}.Debug|x86.Build.0 = Debug|Any CPU {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000008-0000-0000-0000-000000000008}.Release|Any CPU.ActiveCfg = Release|Any CPU {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1000008-0000-0000-0000-000000000008}.Release|Any CPU.Build.0 = Release|Any CPU {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|x64.Build.0 = Debug|Any CPU
{A1000008-0000-0000-0000-000000000008}.Release|x64.ActiveCfg = Release|Any CPU {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1000008-0000-0000-0000-000000000008}.Release|x64.Build.0 = Release|Any CPU {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|x86.Build.0 = Debug|Any CPU
{A1000008-0000-0000-0000-000000000008}.Release|x86.ActiveCfg = Release|Any CPU {4468C960-EE77-4116-A881-B0BDA0ABDC39}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1000008-0000-0000-0000-000000000008}.Release|x86.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
+39
View File
@@ -2461,6 +2461,45 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
`WeekCellItem.Date` — letzteres ist nur bei Kopfzeilen gesetzt, nicht bei `WeekCellItem.Date` — letzteres ist nur bei Kopfzeilen gesetzt, nicht bei
Stunden-Kacheln (führte im ersten Testlauf zu einem Bug: die Automatik griff nie). 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, **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 dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem
bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`, bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`,