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
+5
View File
@@ -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<Services.Mcp.McpServerHostedService>().Start();
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
WireCallbacks(mainVm);
+11
View File
@@ -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<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) ─────────
var untisSettings = new WebUntisSettingsService(appData);
services.AddSingleton(untisSettings);
@@ -21,6 +21,7 @@
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="QuestPDF" />
<PackageReference Include="ModelContextProtocol.Core" />
</ItemGroup>
<ItemGroup>
<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,
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();
@@ -1142,6 +1142,21 @@
</StackPanel>
</ScrollViewer>
</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>
</Grid>
</UserControl>