feat: lokaler MCP-Server, Phase 4 (Elternbrief-Vorlagen + Claude-Desktop-Registrierung)

Schließt die MCP-Server-Spec ab. "Worksheets" aus der Spec entsprechen
im tatsächlichen Datenmodell den .lavorlage-Elternbrief-Vorlagen
(LehrerApp.Templating) - es gibt kein separates Arbeitsblatt-Konzept mit
Fach/Klassenstufe-Metadaten. Neue Tools list_letter_templates (Read) und
render_letter (Read, liefert Base64-PDF, kein DB-Schreibzugriff).

upload_worksheet/update_worksheet bewusst nicht umgesetzt: das
Seitenlayout ist eine eigene positionsbasierte DSL mit eigenem
visuellen Editor (LehrerApp.TemplateDesigner) - ein LLM müsste sie
blind erzeugen, mit hohem Risiko für kaputte Layouts. Platzhalter-Logik
aus CreateLetterDialogViewModel nach LetterPlaceholderBuilder extrahiert,
damit Dialog und MCP-Tool nicht auseinanderdriften.

Neuer McpClientRegistrationService trägt den Bridge-Pfad in Claude
Desktops claude_desktop_config.json ein (Button in den Einstellungen,
nie automatisch), ohne bestehende Fremdeinträge zu verlieren und ohne
eine nicht lesbare Konfigurationsdatei zu überschreiben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 22:53:46 +02:00
co-authored by Claude Sonnet 5
parent dd2e1e7c61
commit 55fba2cadb
17 changed files with 689 additions and 26 deletions
+3
View File
@@ -40,6 +40,9 @@ public static class TestSupport
return new McpSettingsService(tempPath); return new McpSettingsService(tempPath);
} }
/// Kein Konstruktorparameter - Pfade werden intern über Environment.SpecialFolder aufgelöst.
public static McpClientRegistrationService BuildMcpClientRegistrationService() => new();
/// 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()
{ {
@@ -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<string> { "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<string, byte[]>());
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<string, string> { ["Betreff"] = "Elternabend" });
Assert.True(result.Success);
Assert.NotNull(result.Base64Pdf);
}
}
@@ -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<string>());
}
[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<string>());
Assert.Equal("Ctrl+X", root["globalShortcut"]!.GetValue<string>());
Assert.Equal(bridgePath, root["mcpServers"]!["lehrerapp"]!["command"]!.GetValue<string>());
}
[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());
}
}
+2 -1
View File
@@ -16,7 +16,8 @@ public sealed class McpToolsTests
new[] new[]
{ {
"download_lesson_attachment", "get_exams", "get_grades", "get_lesson_plans", "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)); McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
} }
@@ -37,6 +37,7 @@ public sealed class SettingsViewModelTests
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.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
@@ -343,6 +344,7 @@ public sealed class SettingsViewModelTests
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.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
@@ -372,6 +374,7 @@ public sealed class SettingsViewModelTests
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.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
@@ -405,6 +408,7 @@ public sealed class SettingsViewModelTests
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.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
+2
View File
@@ -225,7 +225,9 @@ public static class AppBootstrapper
services.AddSingleton<TimeEntryTools>(); services.AddSingleton<TimeEntryTools>();
services.AddSingleton<LessonPlanTools>(); services.AddSingleton<LessonPlanTools>();
services.AddSingleton<GroupMembershipTools>(); services.AddSingleton<GroupMembershipTools>();
services.AddSingleton<LetterTemplateTools>();
services.AddSingleton<McpServerHostedService>(); services.AddSingleton<McpServerHostedService>();
services.AddSingleton<McpClientRegistrationService>();
// ── 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);
@@ -0,0 +1,41 @@
using LehrerApp.Core.Models;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Services;
/// <summary>
/// Baut die Standard-Platzhalterwerte für Elternbrief-Vorlagen (Datum, Anrede, Kontaktadresse,
/// Schülerdaten, Brieftext, ...). Ursprünglich Teil von
/// <see cref="ViewModels.Students.CreateLetterDialogViewModel"/>, hierher extrahiert, damit
/// <c>LetterTemplateTools</c> (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.
/// </summary>
public static class LetterPlaceholderBuilder
{
public static Dictionary<string, PlaceholderValue> 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<string, PlaceholderValue>(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,
};
}
@@ -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);
/// <summary>
/// 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 (<c>mcpServers</c>-Objekt in <c>claude_desktop_config.json</c>) — 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, <see cref="ResolveBridgePath"/> findet sie dann nicht.
/// </summary>
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()) { }
/// <summary>Test-Seam: erlaubt, Konfigurations- und Bridge-Pfad ohne echte
/// Sonderordner/Installation vorzugeben (siehe <see cref="ResolveConfigPath"/>/
/// <see cref="ResolveBridgePath"/> für das produktive Verhalten).</summary>
public McpClientRegistrationService(string? configPath, string? bridgePath)
{
ConfigPath = configPath;
_bridgePath = bridgePath;
}
public string? ConfigPath { get; }
/// <summary>Ob überhaupt ein Claude-Desktop-Konfigurationsordner existiert — ein Hinweis, ob die
/// Anwendung installiert ist, unabhängig davon, ob LehrerApp dort schon eingetragen ist.</summary>
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.
}
}
@@ -31,13 +31,13 @@ public sealed class McpServerHostedService : IAsyncDisposable
McpSettingsService settings, AppLogger logger, McpSettingsService settings, AppLogger logger,
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools, StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools, ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
GroupMembershipTools groupMembershipTools) GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools)
{ {
_settings = settings; _settings = settings;
_logger = logger; _logger = logger;
_serverOptions = BuildServerOptions( _serverOptions = BuildServerOptions(
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools, studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
groupMembershipTools); groupMembershipTools, letterTemplateTools);
} }
/// <summary>Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls /// <summary>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( private static McpServerOptions BuildServerOptions(
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools, StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools, ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
GroupMembershipTools groupMembershipTools) GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools)
{ {
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>(); var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
@@ -155,6 +155,10 @@ public sealed class McpServerHostedService : IAsyncDisposable
"Listet Unterrichtseinheiten und -stunden einer Lerngruppe in einem Zeitraum."); "Listet Unterrichtseinheiten und -stunden einer Lerngruppe in einem Zeitraum.");
AddReadTool(lessonPlanTools.DownloadLessonAttachment, "download_lesson_attachment", AddReadTool(lessonPlanTools.DownloadLessonAttachment, "download_lesson_attachment",
"Lädt den Inhalt eines an eine Einzelstunde angehängten Materials Base64-kodiert herunter."); "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", AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry",
"Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig)."); "Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig).");
@@ -19,6 +19,8 @@ public static class McpToolScope
"get_time_entries", "get_time_entries",
"get_lesson_plans", "get_lesson_plans",
"download_lesson_attachment", "download_lesson_attachment",
"list_letter_templates",
"render_letter",
]; ];
/// <summary>Write-Tools (Phase 2+3) — jeder Aufruf läuft über <see cref="IMcpConfirmationService"/>, /// <summary>Write-Tools (Phase 2+3) — jeder Aufruf läuft über <see cref="IMcpConfirmationService"/>,
@@ -52,3 +52,12 @@ public record GroupMembershipDto(
/// <summary>Ergebnis eines Write-Tools (Phase 2, siehe Planungsdokument): <see cref="Applied"/> ist /// <summary>Ergebnis eines Write-Tools (Phase 2, siehe Planungsdokument): <see cref="Applied"/> ist
/// nur dann true, wenn der Nutzer die Änderung im Bestätigungsdialog angenommen hat.</summary> /// nur dann true, wenn der Nutzer die Änderung im Bestätigungsdialog angenommen hat.</summary>
public record WriteResultDto(bool Applied, Guid? Id, string Message); public record WriteResultDto(bool Applied, Guid? Id, string Message);
public record PlaceholderInfoDto(string Name, string Type, bool Required, bool IsConstant);
/// <summary>"Worksheets" aus der ursprünglichen Spec entsprechen im tatsächlichen Datenmodell den
/// importierten Elternbrief-Vorlagen (<c>.lavorlage</c>, <c>LehrerApp.Templating</c>) — es gibt kein
/// separates "Arbeitsblatt"-Konzept mit Fach/Klassenstufe-Metadaten. Siehe LetterTemplateTools.</summary>
public record LetterTemplateDto(string Id, string Name, string Description, List<PlaceholderInfoDto> Placeholders);
public record LetterRenderResultDto(bool Success, string Message, string? Base64Pdf, string? SuggestedFileName);
@@ -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;
/// <summary>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
/// <c>.lavorlage</c>-Elternbrief-Vorlagen (<see cref="TemplateStore"/>), an Schüler+Kontakt
/// gebunden, exakt wie im bestehenden "Elternbrief"-Dialog
/// (<c>CreateLetterDialogViewModel</c>). 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.</summary>
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<LetterTemplateDto> ListLetterTemplates()
{
var result = new List<LetterTemplateDto>();
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<string, string>? 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<string, PlaceholderValue> values) : ITemplateDataProvider
{
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values;
}
}
@@ -1,5 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.Services.Mcp;
namespace LehrerApp.Desktop.ViewModels.Settings; namespace LehrerApp.Desktop.ViewModels.Settings;
@@ -14,4 +16,27 @@ public partial class SettingsViewModel
// Kein Live-Reload (siehe McpServerHostedService/Planungsdokument) - der Pipe-Listener wird // Kein Live-Reload (siehe McpServerHostedService/Planungsdokument) - der Pipe-Listener wird
// nur beim App-Start eingerichtet, deshalb wirkt eine Änderung hier erst nach Neustart. // nur beim App-Start eingerichtet, deshalb wirkt eine Änderung hier erst nach Neustart.
partial void OnMcpEnabledChanged(bool value) => _mcpSettings.SetEnabled(value); 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();
}
} }
@@ -75,6 +75,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly AiSettingsService _aiSettings; private readonly AiSettingsService _aiSettings;
private readonly AiPlanningService _aiPlanning; private readonly AiPlanningService _aiPlanning;
private readonly McpSettingsService _mcpSettings; private readonly McpSettingsService _mcpSettings;
private readonly Services.Mcp.McpClientRegistrationService _mcpRegistration;
private readonly WebUntisSettingsService _untisSettings; private readonly WebUntisSettingsService _untisSettings;
private readonly WebUntisIntegrationService? _untisIntegration; private readonly WebUntisIntegrationService? _untisIntegration;
private readonly UntisSyncService? _untisSync; private readonly UntisSyncService? _untisSync;
@@ -102,6 +103,7 @@ public partial class SettingsViewModel : ObservableObject
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates, ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning, McpSettingsService mcpSettings, AiSettingsService aiSettings, AiPlanningService aiPlanning, McpSettingsService mcpSettings,
Services.Mcp.McpClientRegistrationService mcpRegistration,
WebUntisSettingsService untisSettings, WebUntisSettingsService untisSettings,
AnnualPlanSettingsService annualPlanSettings, AnnualPlanSettingsService annualPlanSettings,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue, SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
@@ -140,6 +142,7 @@ public partial class SettingsViewModel : ObservableObject
_aiSettings = aiSettings; _aiSettings = aiSettings;
_aiPlanning = aiPlanning; _aiPlanning = aiPlanning;
_mcpSettings = mcpSettings; _mcpSettings = mcpSettings;
_mcpRegistration = mcpRegistration;
_untisSettings = untisSettings; _untisSettings = untisSettings;
_untisIntegration = untisIntegration; _untisIntegration = untisIntegration;
_untisSync = untisSync; _untisSync = untisSync;
@@ -168,6 +171,7 @@ public partial class SettingsViewModel : ObservableObject
LoadLetterTemplates(); LoadLetterTemplates();
LoadAiSettings(); LoadAiSettings();
LoadMcpSettings(); LoadMcpSettings();
LoadMcpRegistrationStatus();
LoadUntisSettings(); LoadUntisSettings();
LoadAnnualPlanSettings(); LoadAnnualPlanSettings();
LoadSyncSettings(); LoadSyncSettings();
@@ -1,6 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Templating; using LehrerApp.Templating;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
@@ -88,27 +89,11 @@ public partial class CreateLetterDialogViewModel : ObservableObject
OnPropertyChanged(nameof(HasIssues)); OnPropertyChanged(nameof(HasIssues));
} }
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues() private IReadOnlyDictionary<string, PlaceholderValue> BuildValues() => LetterPlaceholderBuilder.BuildStandardValues(
{ _student, SelectedContact?.Model, SelectedGroup?.Model,
var contact = SelectedContact?.Model; var group = SelectedGroup?.Model; DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, 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)));
var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime);
return new Dictionary<string, PlaceholderValue>(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 static bool IsEmpty(PlaceholderValue value) => value switch private static bool IsEmpty(PlaceholderValue value) => LetterPlaceholderBuilder.IsEmpty(value);
{ TextValue x => string.IsNullOrWhiteSpace(x.Value), MultilineValue x => string.IsNullOrWhiteSpace(x.Value), _ => false };
private static string SanitizeFileName(string value) private static string SanitizeFileName(string value)
{ foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; } { foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; }
} }
@@ -1143,17 +1143,33 @@
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: MCP-Server (lokal, Phase 1) --> <!-- Tab: MCP-Server (lokal) -->
<ContentPage Header="MCP-Server"> <ContentPage Header="MCP-Server">
<ScrollViewer> <ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="460"> <StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="460">
<TextBlock Text="MCP-Server" FontSize="16" FontWeight="SemiBold"/> <TextBlock Text="MCP-Server" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap" <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."/> 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."/>
<CheckBox Content="MCP-Server aktivieren" IsChecked="{Binding McpEnabled}"/> <CheckBox Content="MCP-Server aktivieren" IsChecked="{Binding McpEnabled}"/>
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderThickness="0,1,0,0" Margin="0,6,0,0" Padding="0,14,0,0">
<StackPanel Spacing="8">
<TextBlock Text="Claude Desktop" FontSize="14" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Trägt den Pfad zur MCP-Bridge dieser Installation in die Claude-Desktop-Konfiguration ein, damit Claude Desktop LehrerApp automatisch als MCP-Server findet. Funktioniert nur bei einer gepackten Installation, nicht bei einem lokalen Entwicklungs-Build."/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Bei Claude Desktop eintragen" Command="{Binding RegisterWithClaudeDesktopCommand}"
IsVisible="{Binding !McpIsRegisteredWithClaude}"/>
<Button Content="Eintrag entfernen" Command="{Binding UnregisterFromClaudeDesktopCommand}"
IsVisible="{Binding McpIsRegisteredWithClaude}"/>
</StackPanel>
<TextBlock Text="{Binding McpRegistrationStatus}" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding McpRegistrationStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Border>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
+47
View File
@@ -2589,6 +2589,53 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
vertrauen, dass `update_lesson`/`update_lesson_phase` wirklich nur die angegebenen Felder 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. ä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, **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`,