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
@@ -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();
}
}
@@ -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();
@@ -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<string, PlaceholderValue> 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<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 IReadOnlyDictionary<string, PlaceholderValue> 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; }
}