diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs
index 9df5781..4d5987c 100644
--- a/LehrerApp.Desktop.Tests/Fakes.cs
+++ b/LehrerApp.Desktop.Tests/Fakes.cs
@@ -40,6 +40,9 @@ public static class TestSupport
return new McpSettingsService(tempPath);
}
+ /// Kein Konstruktorparameter - Pfade werden intern über Environment.SpecialFolder aufgelöst.
+ public static McpClientRegistrationService BuildMcpClientRegistrationService() => new();
+
/// Analog zu , eigenes Temp-Verzeichnis je Aufruf.
public static WebUntisSettingsService BuildWebUntisSettingsService()
{
diff --git a/LehrerApp.Desktop.Tests/LetterTemplateToolsTests.cs b/LehrerApp.Desktop.Tests/LetterTemplateToolsTests.cs
new file mode 100644
index 0000000..74a6bd1
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/LetterTemplateToolsTests.cs
@@ -0,0 +1,132 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Desktop.Services.Mcp.Tools;
+using LehrerApp.Templating;
+using Xunit;
+
+namespace LehrerApp.Desktop.Tests;
+
+public sealed class LetterTemplateToolsTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-lettertools-tests-{Guid.NewGuid():N}");
+ public LetterTemplateToolsTests() => Directory.CreateDirectory(_directory);
+ public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
+
+ private LetterTemplateTools BuildTool(TemplateStore store, FakeStudents? students = null, FakeGroups? groups = null) =>
+ new(store, new TemplateLoader(), new QuestTemplateRenderer(), students ?? new FakeStudents([]), groups ?? new FakeGroups([]));
+
+ private TemplateStore StoreWithTemplate(string name, params PlaceholderDefinition[] definitions)
+ {
+ var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
+ var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = name, Placeholders = [.. definitions] };
+ var lines = new List { "PAGE 210 297 mm" };
+ var y = 20;
+ foreach (var definition in definitions)
+ {
+ var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
+ lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
+ y += 20;
+ }
+ TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary());
+ var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
+ store.Import(source);
+ return store;
+ }
+
+ private static Student StudentWithContact(string? salutation = "Sehr geehrte Frau Muster,") => new()
+ {
+ FirstName = "Lena", LastName = "Beispiel",
+ Contacts = [new Contact
+ {
+ Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation,
+ Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt",
+ }],
+ };
+
+ [Fact]
+ public void ListLetterTemplates_ListetVorlageMitPlatzhaltern()
+ {
+ var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
+ var tool = BuildTool(store);
+
+ var result = Assert.Single(tool.ListLetterTemplates());
+
+ Assert.Equal("Elternbrief", result.Name);
+ Assert.Contains(result.Placeholders, p => p.Name == "Anrede" && p.Required);
+ }
+
+ [Fact]
+ public void RenderLetter_UnbekannteVorlage_LiefertFehler()
+ {
+ var student = StudentWithContact();
+ var tool = BuildTool(new TemplateStore(_directory), new FakeStudents([student]));
+
+ var result = tool.RenderLetter("nicht-vorhanden", student.Id, "Text", "Frau Lehrer");
+
+ Assert.False(result.Success);
+ Assert.Null(result.Base64Pdf);
+ }
+
+ [Fact]
+ public void RenderLetter_UnbekannterSchueler_LiefertFehler()
+ {
+ var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
+ var installed = Assert.Single(store.GetTemplates());
+ var tool = BuildTool(store);
+
+ var result = tool.RenderLetter(installed.Id, Guid.NewGuid(), "Text", "Frau Lehrer");
+
+ Assert.False(result.Success);
+ }
+
+ [Fact]
+ public void RenderLetter_PflichtplatzhalterFehlt_LiefertFehlerOhnePdf()
+ {
+ var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
+ var installed = Assert.Single(store.GetTemplates());
+ var student = StudentWithContact(salutation: null); // keine Anrede hinterlegt
+ var tool = BuildTool(store, new FakeStudents([student]));
+
+ var result = tool.RenderLetter(installed.Id, student.Id, "Text", "Frau Lehrer");
+
+ Assert.False(result.Success);
+ Assert.Contains("Anrede", result.Message);
+ Assert.Null(result.Base64Pdf);
+ }
+
+ [Fact]
+ public void RenderLetter_VollstaendigeDaten_LiefertBase64Pdf()
+ {
+ var store = StoreWithTemplate("Elternbrief",
+ new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
+ new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
+ new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
+ var installed = Assert.Single(store.GetTemplates());
+ var student = StudentWithContact();
+ var tool = BuildTool(store, new FakeStudents([student]));
+
+ var result = tool.RenderLetter(installed.Id, student.Id, "Dies ist der Inhalt.", "Frau Lehrer");
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Base64Pdf);
+ var bytes = Convert.FromBase64String(result.Base64Pdf!);
+ Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(bytes, 0, 4));
+ Assert.Equal("Elternbrief_Beispiel_Lena.pdf", result.SuggestedFileName);
+ }
+
+ [Fact]
+ public void RenderLetter_ExtraValues_FuelltZusaetzlichenPlatzhalter()
+ {
+ var store = StoreWithTemplate("Elternbrief",
+ new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
+ new PlaceholderDefinition("Betreff", PlaceholderType.Text, true));
+ var installed = Assert.Single(store.GetTemplates());
+ var student = StudentWithContact();
+ var tool = BuildTool(store, new FakeStudents([student]));
+
+ var result = tool.RenderLetter(installed.Id, student.Id, "Text", "Frau Lehrer",
+ extraValues: new Dictionary { ["Betreff"] = "Elternabend" });
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Base64Pdf);
+ }
+}
diff --git a/LehrerApp.Desktop.Tests/McpClientRegistrationServiceTests.cs b/LehrerApp.Desktop.Tests/McpClientRegistrationServiceTests.cs
new file mode 100644
index 0000000..194852f
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/McpClientRegistrationServiceTests.cs
@@ -0,0 +1,96 @@
+using System.Text.Json.Nodes;
+using LehrerApp.Desktop.Services.Mcp;
+using Xunit;
+
+namespace LehrerApp.Desktop.Tests;
+
+public sealed class McpClientRegistrationServiceTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-mcpreg-tests-{Guid.NewGuid():N}");
+ public McpClientRegistrationServiceTests() => Directory.CreateDirectory(_directory);
+ public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
+
+ private string ConfigPath => Path.Combine(_directory, "claude_desktop_config.json");
+
+ private string BuildFakeBridge()
+ {
+ var bridgePath = Path.Combine(_directory, "LehrerApp.McpBridge.exe");
+ File.WriteAllText(bridgePath, "fake");
+ return bridgePath;
+ }
+
+ [Fact]
+ public void Register_OhneBridgeDatei_LiefertFehlerUndSchreibtNichts()
+ {
+ var service = new McpClientRegistrationService(ConfigPath, Path.Combine(_directory, "fehlt.exe"));
+
+ var result = service.Register();
+
+ Assert.False(result.Success);
+ Assert.False(File.Exists(ConfigPath));
+ }
+
+ [Fact]
+ public void Register_OhneBestehendeKonfiguration_LegtDateiMitEintragAn()
+ {
+ var bridgePath = BuildFakeBridge();
+ var service = new McpClientRegistrationService(ConfigPath, bridgePath);
+
+ var result = service.Register();
+
+ Assert.True(result.Success);
+ Assert.True(service.IsRegistered());
+ var root = JsonNode.Parse(File.ReadAllText(ConfigPath))!;
+ Assert.Equal(bridgePath, root["mcpServers"]!["lehrerapp"]!["command"]!.GetValue());
+ }
+
+ [Fact]
+ public void Register_BestehendeKonfigurationMitAnderenServern_BleibtErhalten()
+ {
+ File.WriteAllText(ConfigPath, """{"mcpServers":{"andererServer":{"command":"foo"}},"globalShortcut":"Ctrl+X"}""");
+ var bridgePath = BuildFakeBridge();
+ var service = new McpClientRegistrationService(ConfigPath, bridgePath);
+
+ var result = service.Register();
+
+ Assert.True(result.Success);
+ var root = JsonNode.Parse(File.ReadAllText(ConfigPath))!;
+ Assert.Equal("foo", root["mcpServers"]!["andererServer"]!["command"]!.GetValue());
+ Assert.Equal("Ctrl+X", root["globalShortcut"]!.GetValue());
+ Assert.Equal(bridgePath, root["mcpServers"]!["lehrerapp"]!["command"]!.GetValue());
+ }
+
+ [Fact]
+ public void Register_KaputteBestehendeKonfiguration_WirdNichtUeberschrieben()
+ {
+ File.WriteAllText(ConfigPath, "{ das ist kein json");
+ var bridgePath = BuildFakeBridge();
+ var service = new McpClientRegistrationService(ConfigPath, bridgePath);
+
+ var result = service.Register();
+
+ Assert.False(result.Success);
+ Assert.Equal("{ das ist kein json", File.ReadAllText(ConfigPath));
+ }
+
+ [Fact]
+ public void Unregister_EntferntNurEigenenEintrag()
+ {
+ File.WriteAllText(ConfigPath, """{"mcpServers":{"andererServer":{"command":"foo"},"lehrerapp":{"command":"bar"}}}""");
+ var service = new McpClientRegistrationService(ConfigPath, BuildFakeBridge());
+
+ var result = service.Unregister();
+
+ Assert.True(result.Success);
+ Assert.False(service.IsRegistered());
+ Assert.Contains("andererServer", File.ReadAllText(ConfigPath));
+ }
+
+ [Fact]
+ public void IsRegistered_OhneDatei_IstFalse()
+ {
+ var service = new McpClientRegistrationService(ConfigPath, BuildFakeBridge());
+
+ Assert.False(service.IsRegistered());
+ }
+}
diff --git a/LehrerApp.Desktop.Tests/McpToolsTests.cs b/LehrerApp.Desktop.Tests/McpToolsTests.cs
index 7115736..bf8699a 100644
--- a/LehrerApp.Desktop.Tests/McpToolsTests.cs
+++ b/LehrerApp.Desktop.Tests/McpToolsTests.cs
@@ -16,7 +16,8 @@ public sealed class McpToolsTests
new[]
{
"download_lesson_attachment", "get_exams", "get_grades", "get_lesson_plans",
- "get_schedule", "get_students", "get_time_entries",
+ "get_schedule", "get_students", "get_time_entries", "list_letter_templates",
+ "render_letter",
},
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
}
diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs
index a0586f2..d4b7fce 100644
--- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs
+++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs
@@ -37,6 +37,7 @@ public sealed class SettingsViewModelTests
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
+ TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
@@ -343,6 +344,7 @@ public sealed class SettingsViewModelTests
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
+ TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
@@ -372,6 +374,7 @@ public sealed class SettingsViewModelTests
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
+ TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
@@ -405,6 +408,7 @@ public sealed class SettingsViewModelTests
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
+ TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs
index 4802d59..d8464c8 100644
--- a/LehrerApp.Desktop/AppBootstrapper.cs
+++ b/LehrerApp.Desktop/AppBootstrapper.cs
@@ -225,7 +225,9 @@ public static class AppBootstrapper
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);
diff --git a/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs b/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs
new file mode 100644
index 0000000..5887794
--- /dev/null
+++ b/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs
@@ -0,0 +1,41 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Templating;
+
+namespace LehrerApp.Desktop.Services;
+
+///
+/// Baut die Standard-Platzhalterwerte für Elternbrief-Vorlagen (Datum, Anrede, Kontaktadresse,
+/// Schülerdaten, Brieftext, ...). Ursprünglich Teil von
+/// , hierher extrahiert, damit
+/// LetterTemplateTools (MCP, siehe Planungsdokument) dieselbe Logik nutzt statt sie zu
+/// duplizieren — beide Aufrufer müssen exakt dieselben Platzhalternamen befüllen, sonst driften
+/// Dialog-generierte und KI-generierte Briefe unbemerkt auseinander.
+///
+public static class LetterPlaceholderBuilder
+{
+ public static Dictionary BuildStandardValues(
+ Student student, Contact? contact, LearningGroup? group, DateOnly date,
+ string letterText, string teacherName)
+ {
+ var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
+ var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
+ return new Dictionary(StringComparer.Ordinal)
+ {
+ ["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
+ ["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
+ ["Brieftext"] = new MultilineValue(letterText), ["LehrerName"] = new TextValue(teacherName),
+ ["Student.FirstName"] = new TextValue(student.FirstName), ["Student.LastName"] = new TextValue(student.LastName),
+ ["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
+ ["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
+ ["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
+ ["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
+ };
+ }
+
+ public static bool IsEmpty(PlaceholderValue value) => value switch
+ {
+ TextValue x => string.IsNullOrWhiteSpace(x.Value),
+ MultilineValue x => string.IsNullOrWhiteSpace(x.Value),
+ _ => false,
+ };
+}
diff --git a/LehrerApp.Desktop/Services/Mcp/McpClientRegistrationService.cs b/LehrerApp.Desktop/Services/Mcp/McpClientRegistrationService.cs
new file mode 100644
index 0000000..4d87483
--- /dev/null
+++ b/LehrerApp.Desktop/Services/Mcp/McpClientRegistrationService.cs
@@ -0,0 +1,157 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace LehrerApp.Desktop.Services.Mcp;
+
+public record McpRegistrationResult(bool Success, string Message);
+
+///
+/// Trägt LehrerApp.McpBridge in die MCP-Server-Konfiguration von Claude Desktop ein (siehe
+/// Planungsdokument, "Registrierungs-Workflow"). Bewusst nur für Claude Desktop: das ist der einzige
+/// KI-Client, den die Spec konkret benennt und der eine dokumentierte, stabile Config-Datei-Konvention
+/// hat (mcpServers-Objekt in claude_desktop_config.json) — andere lokale Clients
+/// (Ollama, LM Studio, ...) haben keine einheitliche Konvention, die sich hier ohne Rätselraten
+/// unterstützen ließe.
+///
+/// Läuft nur auf explizite Nutzeraktion (Button in den Einstellungen), nie automatisch beim
+/// App-Start — das Schreiben in die Konfigurationsdatei eines fremden Programms ist ein sichtbarer
+/// externer Seiteneffekt und gehört nicht in den normalen Startpfad.
+///
+/// Setzt eine gepackte Installation voraus (Bridge liegt neben der Hauptapp-Executable, siehe
+/// build-macos-app.sh) — bei einem lokalen "dotnet build/run" liegt die Bridge in ihrem eigenen
+/// separaten bin-Ordner, findet sie dann nicht.
+///
+public class McpClientRegistrationService
+{
+ private const string ServerName = "lehrerapp";
+ private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
+ private readonly string? _bridgePath;
+
+ public McpClientRegistrationService() : this(ResolveConfigPath(), ResolveBridgePath()) { }
+
+ /// Test-Seam: erlaubt, Konfigurations- und Bridge-Pfad ohne echte
+ /// Sonderordner/Installation vorzugeben (siehe /
+ /// für das produktive Verhalten).
+ public McpClientRegistrationService(string? configPath, string? bridgePath)
+ {
+ ConfigPath = configPath;
+ _bridgePath = bridgePath;
+ }
+
+ public string? ConfigPath { get; }
+
+ /// Ob überhaupt ein Claude-Desktop-Konfigurationsordner existiert — ein Hinweis, ob die
+ /// Anwendung installiert ist, unabhängig davon, ob LehrerApp dort schon eingetragen ist.
+ public bool IsClaudeDesktopDetected => ConfigPath is not null && File.Exists(ConfigPath);
+
+ public bool IsRegistered() =>
+ TryLoadRoot(out var root, out _) && root["mcpServers"]?[ServerName] is not null;
+
+ public McpRegistrationResult Register()
+ {
+ if (ConfigPath is null)
+ return new(false, "Claude Desktop wird auf diesem Betriebssystem nicht unterstützt.");
+
+ var bridgePath = _bridgePath;
+ if (bridgePath is null || !File.Exists(bridgePath))
+ return new(false,
+ $"Bridge-Programm nicht gefunden ({bridgePath ?? "unbekannter Pfad"}). " +
+ "Diese Funktion setzt eine gepackte Installation voraus, nicht einen lokalen Entwicklungs-Build.");
+
+ if (!TryLoadRoot(out var root, out var error))
+ return new(false, error!);
+
+ if (root["mcpServers"] is not JsonObject servers)
+ {
+ servers = new JsonObject();
+ root["mcpServers"] = servers;
+ }
+ servers[ServerName] = new JsonObject
+ {
+ ["command"] = bridgePath,
+ ["args"] = new JsonArray(),
+ };
+
+ var detectedBefore = IsClaudeDesktopDetected;
+ var result = WriteRoot(root, "In Claude Desktop eingetragen. Claude Desktop neu starten, damit die Änderung wirkt.");
+ if (result.Success && !detectedBefore)
+ return result with
+ {
+ Message = result.Message +
+ " Hinweis: Es wurde keine bestehende Claude-Desktop-Installation erkannt — die Konfiguration " +
+ "greift erst, sobald Claude Desktop installiert ist.",
+ };
+ return result;
+ }
+
+ public McpRegistrationResult Unregister()
+ {
+ if (ConfigPath is null || !File.Exists(ConfigPath))
+ return new(true, "Nichts einzutragen gefunden.");
+ if (!TryLoadRoot(out var root, out var error))
+ return new(false, error!);
+ if (root["mcpServers"] is not JsonObject servers || !servers.Remove(ServerName))
+ return new(true, "War nicht eingetragen.");
+ return WriteRoot(root, "Eintrag entfernt.");
+ }
+
+ private bool TryLoadRoot(out JsonObject root, out string? error)
+ {
+ error = null;
+ if (ConfigPath is null || !File.Exists(ConfigPath)) { root = new JsonObject(); return true; }
+ try
+ {
+ root = JsonNode.Parse(File.ReadAllText(ConfigPath)) as JsonObject ?? new JsonObject();
+ return true;
+ }
+ catch (JsonException ex)
+ {
+ // Nicht raten und überschreiben - eine kaputte fremde Konfigurationsdatei bleibt unangetastet,
+ // der Nutzer bekommt stattdessen eine klare Fehlermeldung mit Pfad.
+ root = new JsonObject();
+ error = $"Bestehende Claude-Desktop-Konfiguration ist kein gültiges JSON ({ex.Message}). " +
+ $"Bitte manuell prüfen: {ConfigPath}";
+ return false;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Z.B. gesperrt, weil Claude Desktop selbst gerade schreibt - ebenfalls nicht überschreiben.
+ root = new JsonObject();
+ error = $"Bestehende Claude-Desktop-Konfiguration konnte nicht gelesen werden: {ex.Message}";
+ return false;
+ }
+ }
+
+ private McpRegistrationResult WriteRoot(JsonObject root, string successMessage)
+ {
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath!)!);
+ File.WriteAllText(ConfigPath!, root.ToJsonString(WriteOptions));
+ return new(true, successMessage);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return new(false, $"Konfiguration konnte nicht geschrieben werden: {ex.Message}");
+ }
+ }
+
+ private static string? ResolveBridgePath()
+ {
+ var bridgeFile = OperatingSystem.IsWindows() ? "LehrerApp.McpBridge.exe" : "LehrerApp.McpBridge";
+ return Path.Combine(AppContext.BaseDirectory, bridgeFile);
+ }
+
+ private static string? ResolveConfigPath()
+ {
+ if (OperatingSystem.IsWindows())
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "Claude", "claude_desktop_config.json");
+ if (OperatingSystem.IsMacOS())
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "Library", "Application Support", "Claude", "claude_desktop_config.json");
+ return null; // Linux: kein offizieller Claude-Desktop-Client bekannt.
+ }
+}
diff --git a/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs b/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs
index 1ea9ab8..aad1b81 100644
--- a/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs
+++ b/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs
@@ -31,13 +31,13 @@ public sealed class McpServerHostedService : IAsyncDisposable
McpSettingsService settings, AppLogger logger,
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
- GroupMembershipTools groupMembershipTools)
+ GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools)
{
_settings = settings;
_logger = logger;
_serverOptions = BuildServerOptions(
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
- groupMembershipTools);
+ groupMembershipTools, letterTemplateTools);
}
/// Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls
@@ -113,7 +113,7 @@ public sealed class McpServerHostedService : IAsyncDisposable
private static McpServerOptions BuildServerOptions(
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
- GroupMembershipTools groupMembershipTools)
+ GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools)
{
var toolCollection = new McpServerPrimitiveCollection();
@@ -155,6 +155,10 @@ public sealed class McpServerHostedService : IAsyncDisposable
"Listet Unterrichtseinheiten und -stunden einer Lerngruppe in einem Zeitraum.");
AddReadTool(lessonPlanTools.DownloadLessonAttachment, "download_lesson_attachment",
"Lädt den Inhalt eines an eine Einzelstunde angehängten Materials Base64-kodiert herunter.");
+ AddReadTool(letterTemplateTools.ListLetterTemplates, "list_letter_templates",
+ "Listet importierte Elternbrief-Vorlagen mit ihren Platzhaltern.");
+ AddReadTool(letterTemplateTools.RenderLetter, "render_letter",
+ "Erzeugt einen Elternbrief aus einer Vorlage für einen Schüler als Base64-PDF.");
AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry",
"Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig).");
diff --git a/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs b/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs
index 7db7614..68dc797 100644
--- a/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs
+++ b/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs
@@ -19,6 +19,8 @@ public static class McpToolScope
"get_time_entries",
"get_lesson_plans",
"download_lesson_attachment",
+ "list_letter_templates",
+ "render_letter",
];
/// Write-Tools (Phase 2+3) — jeder Aufruf läuft über ,
diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs
index b9cf75f..97829fb 100644
--- a/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs
+++ b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs
@@ -52,3 +52,12 @@ public record GroupMembershipDto(
/// Ergebnis eines Write-Tools (Phase 2, siehe Planungsdokument): ist
/// nur dann true, wenn der Nutzer die Änderung im Bestätigungsdialog angenommen hat.
public record WriteResultDto(bool Applied, Guid? Id, string Message);
+
+public record PlaceholderInfoDto(string Name, string Type, bool Required, bool IsConstant);
+
+/// "Worksheets" aus der ursprünglichen Spec entsprechen im tatsächlichen Datenmodell den
+/// importierten Elternbrief-Vorlagen (.lavorlage, LehrerApp.Templating) — es gibt kein
+/// separates "Arbeitsblatt"-Konzept mit Fach/Klassenstufe-Metadaten. Siehe LetterTemplateTools.
+public record LetterTemplateDto(string Id, string Name, string Description, List Placeholders);
+
+public record LetterRenderResultDto(bool Success, string Message, string? Base64Pdf, string? SuggestedFileName);
diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs
new file mode 100644
index 0000000..e031329
--- /dev/null
+++ b/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs
@@ -0,0 +1,135 @@
+using System.ComponentModel;
+using System.Globalization;
+using LehrerApp.Core.Interfaces;
+using LehrerApp.Desktop.Services;
+using LehrerApp.Templating;
+
+namespace LehrerApp.Desktop.Services.Mcp.Tools;
+
+/// MCP-Tools "list_letter_templates"/"render_letter" (siehe Planungsdokument, Abschnitt
+/// "Worksheets"). Die ursprüngliche Spec sah "Arbeitsblätter" mit Fach-/Klassenstufe-Metadaten vor —
+/// das existiert im Datenmodell nicht. Was tatsächlich existiert: importierte
+/// .lavorlage-Elternbrief-Vorlagen (), an Schüler+Kontakt
+/// gebunden, exakt wie im bestehenden "Elternbrief"-Dialog
+/// (CreateLetterDialogViewModel). Bewusst NICHT umgesetzt: "upload_worksheet"/
+/// "update_worksheet" im Sinne von "eine neue Vorlage per KI entwerfen" — das Seitenlayout ist eine
+/// eigene, positionsbasierte DSL (siehe LehrerApp.Templating/LayoutParser.cs), für die es absichtlich
+/// einen eigenen visuellen Editor (LehrerApp.TemplateDesigner) gibt; ein LLM würde diese DSL blind
+/// erzeugen müssen, mit hohem Risiko für unbrauchbare/kaputte Layouts. "render_letter" deckt den
+/// tatsächlich nützlichen Fall ab: eine bestehende, vom Nutzer gestaltete Vorlage mit Werten füllen.
+public class LetterTemplateTools(
+ TemplateStore templates, ITemplateLoader loader, ITemplateRenderer renderer,
+ IStudentRepository students, IGroupRepository groups)
+{
+ [Description("Listet importierte Elternbrief-Vorlagen mit ihren deklarierten Platzhaltern (Name, Typ, Pflichtfeld, konstant).")]
+ public List ListLetterTemplates()
+ {
+ var result = new List();
+ foreach (var installed in templates.GetTemplates())
+ {
+ try
+ {
+ var loaded = templates.Load(installed);
+ result.Add(new LetterTemplateDto(installed.Id, installed.Name, installed.Description,
+ loaded.Manifest.Placeholders
+ .Select(p => new PlaceholderInfoDto(p.Name, p.Type.ToString(), p.Required, p.IsConstant))
+ .ToList()));
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
+ {
+ // Beschädigtes/ungültiges Paket - wie TemplateStore.GetTemplates() selbst überspringt
+ // auch diese Auflistung es kommentarlos, statt die ganze Liste scheitern zu lassen.
+ }
+ }
+ return result;
+ }
+
+ [Description("Erzeugt einen Elternbrief aus einer importierten Vorlage für einen Schüler und liefert ihn als Base64-PDF. Reiner Lesezugriff (kein Datenbank-Schreibzugriff), deshalb ohne Bestätigungsdialog.")]
+ public LetterRenderResultDto RenderLetter(
+ [Description("Vorlagen-ID, aus list_letter_templates.")] string templateId,
+ [Description("Schüler-ID.")] Guid studentId,
+ [Description("Brieftext (Fließtext).")] string letterText,
+ [Description("Name der unterschreibenden Lehrkraft.")] string teacherName,
+ [Description("Optionale Kontakt-ID des Schülers. Ohne Angabe wird der erste gültige Kontakt verwendet.")] Guid? contactId = null,
+ [Description("Optionale Lerngruppen-ID für Group.Name/SchoolYear-Platzhalter.")] Guid? groupId = null,
+ [Description("Briefdatum, Format YYYY-MM-DD. Standard: heute.")] DateOnly? letterDate = null,
+ [Description("Zusätzliche, von der Vorlage deklarierte Platzhalterwerte über die Standardfelder hinaus (Name -> Text/Zahl/Datum als Text).")]
+ Dictionary? extraValues = null)
+ {
+ var student = students.GetById(studentId);
+ if (student is null) return new LetterRenderResultDto(false, "Unbekannte Schüler-ID.", null, null);
+
+ var installed = templates.GetTemplates().FirstOrDefault(t => t.Id == templateId);
+ if (installed is null) return new LetterRenderResultDto(false, "Unbekannte Vorlagen-ID.", null, null);
+
+ LoadedTemplate loaded;
+ try { loaded = templates.Load(installed); }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
+ { return new LetterRenderResultDto(false, $"Vorlage ist ungültig: {ex.Message}", null, null); }
+
+ var contact = contactId is { } cid
+ ? student.Contacts.FirstOrDefault(c => c.Id == cid)
+ : student.Contacts.FirstOrDefault(c => !c.InvalidSince.HasValue);
+ var group = groupId is { } gid ? groups.GetById(gid) : null;
+ var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today);
+
+ var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText, teacherName);
+ foreach (var (name, raw) in extraValues ?? [])
+ {
+ var definition = loaded.Manifest.Placeholders.FirstOrDefault(p => p.Name == name);
+ if (definition is null || definition.IsConstant) continue;
+ if (TryConvert(definition.Type, raw) is { } converted) values[name] = converted;
+ }
+ var resolved = TemplateDataResolver.Resolve(loaded.Manifest, values);
+
+ var validation = loader.Validate(loaded, resolved.ToDictionary(x => x.Key, x => x.Value.Type));
+ var missing = loaded.Manifest.Placeholders
+ .Where(p => !p.IsConstant && p.Required &&
+ (!resolved.TryGetValue(p.Name, out var v) || LetterPlaceholderBuilder.IsEmpty(v)))
+ .Select(p => p.Name).ToList();
+ if (!validation.IsValid || missing.Count > 0)
+ {
+ var issues = validation.Issues.Select(i => i.Message)
+ .Concat(missing.Select(m => $"Pflichtfeld „{m}“ fehlt."));
+ return new LetterRenderResultDto(false,
+ "Vorlage kann mit diesen Werten nicht gefüllt werden: " + string.Join(" ", issues), null, null);
+ }
+
+ byte[] pdf;
+ try { pdf = renderer.RenderToPdf(loaded, new DictionaryDataProvider(resolved)); }
+ catch (Exception ex) when (ex is InvalidDataException or TemplateValidationException)
+ { return new LetterRenderResultDto(false, $"Brief konnte nicht erzeugt werden: {ex.Message}", null, null); }
+
+ var fileName = SanitizeFileName($"{installed.Name}_{student.LastName}_{student.FirstName}.pdf");
+ return new LetterRenderResultDto(true, "Brief erzeugt.", Convert.ToBase64String(pdf), fileName);
+ }
+
+ private static PlaceholderValue? TryConvert(PlaceholderType type, string raw) => type switch
+ {
+ PlaceholderType.Text => new TextValue(raw),
+ PlaceholderType.Multiline => new MultilineValue(raw),
+ PlaceholderType.Date => TryParseDate(raw) is { } date ? new DateValue(date) : null,
+ PlaceholderType.Number => TryParseNumber(raw) is { } number ? new NumberValue(number) : null,
+ // Image/Table/Chart/Drawing sind über MCP-Textparameter nicht sinnvoll setzbar.
+ _ => null,
+ };
+
+ private static DateOnly? TryParseDate(string raw) =>
+ DateOnly.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ||
+ DateOnly.TryParse(raw, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out d) ? d : null;
+
+ private static decimal? TryParseNumber(string raw) =>
+ decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out var n) ||
+ decimal.TryParse(raw, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out n) ? n : null;
+
+ private static string SanitizeFileName(string value)
+ {
+ foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_');
+ return value;
+ }
+
+ private sealed class DictionaryDataProvider(IReadOnlyDictionary values) : ITemplateDataProvider
+ {
+ public IReadOnlyDictionary GetValues() => values;
+ }
+}
diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.McpSettings.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.McpSettings.cs
index f7bbc69..293ab1a 100644
--- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.McpSettings.cs
+++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.McpSettings.cs
@@ -1,5 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
using LehrerApp.Desktop.Services;
+using LehrerApp.Desktop.Services.Mcp;
namespace LehrerApp.Desktop.ViewModels.Settings;
@@ -14,4 +16,27 @@ public partial class SettingsViewModel
// 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);
+
+ // ── MCP: Registrierung bei Claude Desktop ─────────────────────────────────
+
+ [ObservableProperty] private string _mcpRegistrationStatus = "";
+ [ObservableProperty] private bool _mcpIsRegisteredWithClaude;
+
+ private void LoadMcpRegistrationStatus() => McpIsRegisteredWithClaude = _mcpRegistration.IsRegistered();
+
+ [RelayCommand]
+ private void RegisterWithClaudeDesktop()
+ {
+ var result = _mcpRegistration.Register();
+ McpRegistrationStatus = result.Message;
+ McpIsRegisteredWithClaude = _mcpRegistration.IsRegistered();
+ }
+
+ [RelayCommand]
+ private void UnregisterFromClaudeDesktop()
+ {
+ var result = _mcpRegistration.Unregister();
+ McpRegistrationStatus = result.Message;
+ McpIsRegisteredWithClaude = _mcpRegistration.IsRegistered();
+ }
}
diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
index f35e007..73e666b 100644
--- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs
@@ -75,6 +75,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly AiSettingsService _aiSettings;
private readonly AiPlanningService _aiPlanning;
private readonly McpSettingsService _mcpSettings;
+ private readonly Services.Mcp.McpClientRegistrationService _mcpRegistration;
private readonly WebUntisSettingsService _untisSettings;
private readonly WebUntisIntegrationService? _untisIntegration;
private readonly UntisSyncService? _untisSync;
@@ -102,6 +103,7 @@ public partial class SettingsViewModel : ObservableObject
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning, McpSettingsService mcpSettings,
+ Services.Mcp.McpClientRegistrationService mcpRegistration,
WebUntisSettingsService untisSettings,
AnnualPlanSettingsService annualPlanSettings,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
@@ -140,6 +142,7 @@ public partial class SettingsViewModel : ObservableObject
_aiSettings = aiSettings;
_aiPlanning = aiPlanning;
_mcpSettings = mcpSettings;
+ _mcpRegistration = mcpRegistration;
_untisSettings = untisSettings;
_untisIntegration = untisIntegration;
_untisSync = untisSync;
@@ -168,6 +171,7 @@ public partial class SettingsViewModel : ObservableObject
LoadLetterTemplates();
LoadAiSettings();
LoadMcpSettings();
+ LoadMcpRegistrationStatus();
LoadUntisSettings();
LoadAnnualPlanSettings();
LoadSyncSettings();
diff --git a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs
index b4723cf..f305dc6 100644
--- a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs
@@ -1,6 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
+using LehrerApp.Desktop.Services;
using LehrerApp.Templating;
using System.Collections.ObjectModel;
@@ -88,27 +89,11 @@ public partial class CreateLetterDialogViewModel : ObservableObject
OnPropertyChanged(nameof(HasIssues));
}
- private IReadOnlyDictionary BuildValues()
- {
- var contact = SelectedContact?.Model; var group = SelectedGroup?.Model;
- var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
- var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
- var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime);
- return new Dictionary(StringComparer.Ordinal)
- {
- ["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
- ["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
- ["Brieftext"] = new MultilineValue(LetterText), ["LehrerName"] = new TextValue(TeacherName),
- ["Student.FirstName"] = new TextValue(_student.FirstName), ["Student.LastName"] = new TextValue(_student.LastName),
- ["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
- ["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
- ["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
- ["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
- };
- }
+ private IReadOnlyDictionary BuildValues() => LetterPlaceholderBuilder.BuildStandardValues(
+ _student, SelectedContact?.Model, SelectedGroup?.Model,
+ DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName);
- private static bool IsEmpty(PlaceholderValue value) => value switch
- { TextValue x => string.IsNullOrWhiteSpace(x.Value), MultilineValue x => string.IsNullOrWhiteSpace(x.Value), _ => false };
+ private static bool IsEmpty(PlaceholderValue value) => LetterPlaceholderBuilder.IsEmpty(value);
private static string SanitizeFileName(string value)
{ foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; }
}
diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
index 9230507..0f318ae 100644
--- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
+++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
@@ -1143,17 +1143,33 @@
-
+
+ Text="Erlaubt einem lokalen KI-Client (z.B. Claude Desktop) strukturierten Zugriff auf Schüler, Klausuren, Noten, Stundenplan, Zeiterfassung, Unterrichtsplanung und Elternbrief-Vorlagen 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. Schreibende Aktionen zeigen immer erst einen Bestätigungsdialog, bevor etwas gespeichert wird. Eine Änderung hier wirkt erst nach einem Neustart der App."/>
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TODO.md b/TODO.md
index b275ca4..adb54e2 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2589,6 +2589,53 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
vertrauen, dass `update_lesson`/`update_lesson_phase` wirklich nur die angegebenen Felder
ändern, und dass ein zu großer Anhang beim Download einen Fehler statt einer Antwort liefert.
+- [x] **4.5.29** Lokaler MCP-Server, Phase 4 (Elternbrief-Vorlagen + Claude-Desktop-Registrierung),
+ 2026-09-11 — schließt die Spec ab (Rest siehe "Bewusst nicht umgesetzt" unten).
+ - **"Worksheets" umbenannt zu Elternbrief-Vorlagen:** die Spec sah `list_worksheets`/
+ `download_worksheet`/`upload_worksheet`/`update_worksheet` mit Fach-/Klassenstufe-Metadaten
+ vor — das existiert im Datenmodell nicht. Was tatsächlich existiert, ist der
+ `.lavorlage`-Vorlagenmechanismus (`LehrerApp.Templating`), der ausschließlich für
+ Elternbriefe genutzt wird (an Schüler+Kontakt gebunden, siehe
+ `CreateLetterDialogViewModel`). Neue Tools `list_letter_templates` (Read) und
+ `render_letter` (Read — schreibt nichts in die Datenbank, deshalb ohne Bestätigungsdialog,
+ liefert ein Base64-PDF).
+ - **`upload_worksheet`/`update_worksheet` bewusst NICHT umgesetzt:** das Seitenlayout ist
+ eine eigene, positionsbasierte DSL (`LayoutParser`/`.tpl`-Dateien mit absoluten
+ Koordinaten), für die es einen eigenen visuellen Editor gibt (`LehrerApp.TemplateDesigner`)
+ — ein LLM müsste diese DSL blind erzeugen, mit hohem Risiko für unbrauchbare oder defekte
+ Layouts. `render_letter` deckt den tatsächlich nützlichen Fall ab: eine bestehende,
+ von Hand gestaltete Vorlage mit Werten füllen.
+ - **Platzhalter-Logik aus `CreateLetterDialogViewModel` extrahiert** nach
+ [Services/LetterPlaceholderBuilder.cs](LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs),
+ damit Dialog und MCP-Tool exakt dieselben Standard-Platzhalternamen (Datum, Anrede,
+ Contact.Address, ...) befüllen, statt still auseinanderzudriften. Bestehende
+ `CreateLetterDialogViewModelTests` liefen nach dem Refactor unverändert grün.
+ - **Registrierungs-Workflow:** neuer
+ [McpClientRegistrationService.cs](LehrerApp.Desktop/Services/Mcp/McpClientRegistrationService.cs)
+ trägt den Bridge-Pfad in `claude_desktop_config.json` ein (Windows: `%APPDATA%\Claude\...`,
+ macOS: `~/Library/Application Support/Claude/...`) — bewusst nur für Claude Desktop, der
+ einzige in der Spec konkret genannte Client mit dokumentierter Config-Konvention. Button
+ "Bei Claude Desktop eintragen"/"Eintrag entfernen" im MCP-Einstellungen-Tab, bewusst nur
+ auf explizite Nutzeraktion, nie automatisch beim App-Start (Schreiben in die
+ Konfigurationsdatei eines fremden Programms ist ein sichtbarer externer Seiteneffekt).
+ Bestehende Config-Inhalte (andere MCP-Server, sonstige Claude-Desktop-Einstellungen)
+ bleiben beim Eintragen erhalten; eine nicht als JSON lesbare bestehende Datei wird nicht
+ angefasst, sondern liefert einen Fehler mit Pfadangabe statt sie zu überschreiben.
+ Funktioniert nur bei einer gepackten Installation (Bridge liegt neben der
+ Hauptapp-Executable) — bei einem lokalen `dotnet build/run` liegt die Bridge in ihrem
+ eigenen separaten bin-Ordner und wird nicht gefunden.
+ - `McpToolScope`/`McpServerHostedService` um `list_letter_templates`/`render_letter`
+ erweitert (9 Read-, 10 Write-Tools). 6 neue Tests in
+ [LetterTemplateToolsTests.cs](LehrerApp.Desktop.Tests/LetterTemplateToolsTests.cs)
+ (rendert echte PDFs über `QuestTemplateRenderer`, kein Fake) und 6 in
+ [McpClientRegistrationServiceTests.cs](LehrerApp.Desktop.Tests/McpClientRegistrationServiceTests.cs)
+ (u.a. bestehende Fremdeinträge bleiben erhalten, kaputtes JSON wird nicht überschrieben).
+ - **Bewusst nicht umgesetzt (verworfen, nicht nur zurückgestellt):** `upload_worksheet`/
+ `update_worksheet` (siehe oben), Mehrbenutzer-/Remote-Zugriff, Lösch-Tools,
+ Dokumentationstypen-Zugriff, Zugriff über den Sync-Server — alle laut Spec explizit
+ "Out of Scope (v1)". Windows-Installer-Anpassungen und die Runtime-Dedup-Optimierung für
+ macOS (4.5.27) bleiben offen, sind aber nicht MCP-spezifisch.
+
**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`,