feat: lokaler MCP-Server, Phase 3 (kleinteilige Unit/Lesson-Tools)

Statt eines "update_lesson", das die ganze Stunde inkl. Verlaufsplan als
ein großes JSON-Objekt tauscht, gezielte kleine Tools je Teiloperation
(Nutzervorschlag): create/update_unit, create/update_lesson (Metadaten
ohne Phasen), add/update/remove_lesson_phase (je eine Phase),
download_lesson_attachment (Base64, auf 3 MB gedeckelt). Verkürzt das
Lesen-Schreiben-Zeitfenster je Operation und liefert lesbare Diffs für
den Bestätigungsdialog statt eines Objekt-Dumps.

ILessonRepository um GetById ergänzt (fehlte bisher, war aber
Voraussetzung für jedes der neuen Tools). get_lesson_plans liefert jetzt
zusätzlich Phase-IDs und Attachment-Metadaten, damit ein Client sie
gezielt referenzieren kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 21:52:48 +02:00
co-authored by Claude Sonnet 5
parent 0c60a54c4d
commit dd2e1e7c61
9 changed files with 496 additions and 16 deletions
+11 -2
View File
@@ -24,11 +24,20 @@ public record TimeEntryDto(
Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date,
TimeOnly? StartTime, TimeOnly? EndTime, int DurationMinutes, string? Description);
public record LessonPhaseDto(string Name, int DurationMinutes, string Activity, string Material, string Shorthand);
public record LessonPhaseDto(Guid Id, string Name, int DurationMinutes, string Activity, string Material, string Shorthand);
public record LessonAttachmentDto(string StorageId, string FileName, long SizeBytes);
public record LessonDto(
Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic,
string? Homework, LessonStatus Status, List<LessonPhaseDto> Phases);
string? Homework, LessonStatus Status, List<LessonPhaseDto> Phases,
List<LessonAttachmentDto> Attachments);
/// <summary>Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten
/// als JSON/Text übertragen werden. Bewusst kein Ressourcen-URI-Mechanismus (siehe Planungsdokument) -
/// dafür müsste der Server MCP-Resources anbieten, was über den Rahmen dieses Tools hinausgeht;
/// stattdessen deckelt <see cref="LessonPlanTools.MaxInlineAttachmentBytes"/> die Größe.</summary>
public record AttachmentContentDto(string FileName, long SizeBytes, string Base64Content);
public record UnitDto(
Guid Id, Guid GroupId, string Title, DateOnly? StartDate, DateOnly? EndDate,
@@ -1,12 +1,34 @@
using System.ComponentModel;
using System.Text;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Read-Tool "get_lesson_plans" (Phase 2, siehe Planungsdokument). Liefert
/// Unterrichtseinheiten einer Lerngruppe sowie deren Einzelstunden in einem Zeitraum.</summary>
public class LessonPlanTools(IUnitRepository units, ILessonRepository lessons)
/// <summary>MCP-Tools rund um Unterrichtseinheiten (<see cref="Unit"/>) und Einzelstunden
/// (<see cref="Lesson"/>) — Phase 1 (get_lesson_plans) und Phase 3 (siehe Planungsdokument).
///
/// Bewusst kleinteilig statt eines einzelnen "update_lesson", das die komplette Stunde inkl.
/// Verlaufsplan als ein großes JSON-Objekt tauscht: <see cref="Lesson.Phases"/> ist zwar technisch
/// eine eingebettete Liste im selben LiteDB-Dokument (keine echte Sub-Collection, also kein
/// Zeilen-Locking auf DB-Ebene) — kleinteilige Tools verkürzen aber das Zeitfenster zwischen Lesen
/// und Schreiben je Operation drastisch (ein Tool-Aufruf ändert nur eine Phase, nicht die ganze
/// Stunde) und liefern einen für den Bestätigungsdialog tatsächlich lesbaren Diff statt eines
/// kompletten Objekt-Dumps. Ein Tool, das die ganze Stunde überschreibt, ist bewusst NICHT
/// vorgesehen; wo es fehlt, ist die Kombination aus update_lesson (Metadaten) +
/// add/update/remove_lesson_phase (je eine Phase) der vorgesehene Weg.</summary>
public class LessonPlanTools(
IUnitRepository units, ILessonRepository lessons, IGroupRepository groups,
IAttachmentStorage attachments, IMcpConfirmationService confirmation)
{
/// <summary>Deckelt die Antwortgröße von "download_lesson_attachment" (Base64 bläht ca. um
/// Faktor 1,33 auf). Kleiner als <see cref="IAttachmentStorage.MaxSizeBytes"/> (App-weites
/// Limit), damit ein einzelner MCP-Tool-Aufruf nicht unnötig groß wird — siehe Planungsdokument
/// zum offenen Punkt "Ressourcen statt Inline-Base64 für große Dateien".</summary>
public const long MaxInlineAttachmentBytes = 3 * 1024 * 1024;
// ── Lesen ────────────────────────────────────────────────────────────────────────────────
[Description("Listet Unterrichtseinheiten und -stunden einer Lerngruppe; die Einzelstunden werden auf den angegebenen Zeitraum gefiltert.")]
public LessonPlanResultDto GetLessonPlans(
[Description("Lerngruppen-ID.")] Guid groupId,
@@ -17,10 +39,227 @@ public class LessonPlanTools(IUnitRepository units, ILessonRepository lessons)
.Select(u => new UnitDto(u.Id, u.GroupId, u.Title, u.StartDate, u.EndDate, u.Status, u.Competencies))
.ToList();
var lessonDtos = lessons.GetByGroupAndRange(groupId, from, to)
.Select(l => new LessonDto(
l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.Status,
l.Phases.Select(p => new LessonPhaseDto(p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand)).ToList()))
.Select(ToDto)
.ToList();
return new LessonPlanResultDto(unitDtos, lessonDtos);
}
[Description("Lädt den Inhalt eines an eine Einzelstunde angehängten Materials (z.B. Arbeitsblatt) Base64-kodiert herunter. Für die storageId siehe get_lesson_plans.")]
public AttachmentContentDto DownloadLessonAttachment(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Speicher-ID des Anhangs, aus get_lesson_plans.")] string storageId)
{
var lesson = lessons.GetById(lessonId) ?? throw new InvalidOperationException("Unbekannte Stunden-ID.");
var attachment = lesson.Attachments.FirstOrDefault(a => a.StorageId == storageId)
?? throw new InvalidOperationException("Kein Anhang mit dieser Speicher-ID an dieser Stunde.");
if (attachment.SizeBytes > MaxInlineAttachmentBytes)
throw new InvalidOperationException(
$"Anhang ist mit {attachment.SizeBytes / 1024 / 1024} MB zu groß für eine Inline-Antwort (Limit {MaxInlineAttachmentBytes / 1024 / 1024} MB).");
using var stream = attachments.OpenRead(storageId)
?? throw new InvalidOperationException("Anhang-Inhalt nicht auffindbar (Speicher inkonsistent).");
using var buffer = new MemoryStream();
stream.CopyTo(buffer);
return new AttachmentContentDto(attachment.FileName, attachment.SizeBytes, Convert.ToBase64String(buffer.ToArray()));
}
// ── Unterrichtseinheiten (Unit) ──────────────────────────────────────────────────────────
[Description("Legt eine neue Unterrichtseinheit an. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen.")]
public async Task<WriteResultDto> CreateUnit(
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Titel der Einheit.")] string title,
[Description("Optionales Startdatum, Format YYYY-MM-DD.")] DateOnly? startDate = null,
[Description("Optionales Enddatum, Format YYYY-MM-DD.")] DateOnly? endDate = null,
CancellationToken ct = default)
{
var group = groups.GetById(groupId);
if (group is null) return new WriteResultDto(false, null, "Unbekannte Gruppen-ID.");
var message = $"Neue Unterrichtseinheit „{title}“ für {group.Name} anlegen?" +
(startDate is not null || endDate is not null
? $"\nZeitraum: {startDate:dd.MM.yyyy} {endDate:dd.MM.yyyy}" : "");
if (!await confirmation.ConfirmAsync("Unterrichtseinheit anlegen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var unit = new Unit { GroupId = groupId, Title = title, StartDate = startDate, EndDate = endDate };
units.Save(unit);
return new WriteResultDto(true, unit.Id, "Unterrichtseinheit gespeichert.");
}
[Description("Ändert Titel/Zeitraum/Status einer bestehenden Unterrichtseinheit. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateUnit(
[Description("ID der Unterrichtseinheit.")] Guid unitId,
[Description("Neuer Titel. Unverändert lassen: weglassen.")] string? title = null,
[Description("Neues Startdatum, Format YYYY-MM-DD. Unverändert lassen: weglassen.")] DateOnly? startDate = null,
[Description("Neues Enddatum, Format YYYY-MM-DD. Unverändert lassen: weglassen.")] DateOnly? endDate = null,
[Description("Neuer Status: Planned, Active oder Completed. Unverändert lassen: weglassen.")] UnitStatus? status = null,
CancellationToken ct = default)
{
var unit = units.GetById(unitId);
if (unit is null) return new WriteResultDto(false, null, "Unbekannte Einheiten-ID.");
var changes = new StringBuilder();
if (title is not null && title != unit.Title) { changes.AppendLine($"Titel: „{unit.Title}“ → „{title}“"); unit.Title = title; }
if (startDate is not null && startDate != unit.StartDate) { changes.AppendLine($"Start: {unit.StartDate:dd.MM.yyyy} → {startDate:dd.MM.yyyy}"); unit.StartDate = startDate; }
if (endDate is not null && endDate != unit.EndDate) { changes.AppendLine($"Ende: {unit.EndDate:dd.MM.yyyy} → {endDate:dd.MM.yyyy}"); unit.EndDate = endDate; }
if (status is not null && status != unit.Status) { changes.AppendLine($"Status: {unit.Status} → {status}"); unit.Status = status.Value; }
if (changes.Length == 0)
return new WriteResultDto(true, unit.Id, "Keine Änderung nötig.");
if (!await confirmation.ConfirmAsync("Unterrichtseinheit ändern?", $"„{unit.Title}“\n{changes}".TrimEnd(), ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
units.Save(unit);
return new WriteResultDto(true, unit.Id, "Unterrichtseinheit gespeichert.");
}
// ── Einzelstunden (Lesson) — Metadaten ───────────────────────────────────────────────────
[Description("Legt eine neue Einzelstunde ohne Verlaufsplan-Phasen an. Phasen danach einzeln über add_lesson_phase hinzufügen. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> CreateLesson(
[Description("ID der übergeordneten Unterrichtseinheit.")] Guid unitId,
[Description("Datum, Format YYYY-MM-DD.")] DateOnly date,
[Description("Thema der Stunde.")] string topic,
[Description("Optionale Stundennummer im Tagesraster.")] int? lessonNumber = null,
[Description("Optionaler Stundenbeginn, Format HH:mm.")] TimeOnly? startTime = null,
CancellationToken ct = default)
{
var unit = units.GetById(unitId);
if (unit is null) return new WriteResultDto(false, null, "Unbekannte Einheiten-ID.");
var message = $"Neue Stunde „{topic}“ am {date:dd.MM.yyyy} in Einheit „{unit.Title}“ anlegen?";
if (!await confirmation.ConfirmAsync("Einzelstunde anlegen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var lesson = new Lesson
{
UnitId = unitId,
GroupId = unit.GroupId,
Date = date,
Topic = topic,
LessonNumber = lessonNumber,
StartTime = startTime,
};
lessons.Save(lesson);
return new WriteResultDto(true, lesson.Id, "Einzelstunde gespeichert.");
}
[Description("Ändert Metadaten einer bestehenden Einzelstunde (Thema, Hausaufgabe, Status, Beginn, Stundennummer) — der Verlaufsplan (Phasen) bleibt unverändert. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateLesson(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null,
[Description("Neue Hausaufgabe. Unverändert lassen: weglassen.")] string? homework = null,
[Description("Neuer Status: Planned, Conducted, Draft oder Ready. Unverändert lassen: weglassen.")] LessonStatus? status = null,
[Description("Neuer Stundenbeginn, Format HH:mm. Unverändert lassen: weglassen.")] TimeOnly? startTime = null,
[Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var changes = new StringBuilder();
if (topic is not null && topic != lesson.Topic) { changes.AppendLine($"Thema: „{lesson.Topic}“ → „{topic}“"); lesson.Topic = topic; }
if (homework is not null && homework != lesson.Homework) { changes.AppendLine($"Hausaufgabe: „{lesson.Homework}“ → „{homework}“"); lesson.Homework = homework; }
if (status is not null && status != lesson.Status) { changes.AppendLine($"Status: {lesson.Status} → {status}"); lesson.Status = status.Value; }
if (startTime is not null && startTime != lesson.StartTime) { changes.AppendLine($"Beginn: {lesson.StartTime:HH\\:mm} → {startTime:HH\\:mm}"); lesson.StartTime = startTime; }
if (lessonNumber is not null && lessonNumber != lesson.LessonNumber) { changes.AppendLine($"Nr.: {lesson.LessonNumber} → {lessonNumber}"); lesson.LessonNumber = lessonNumber; }
if (changes.Length == 0)
return new WriteResultDto(true, lesson.Id, "Keine Änderung nötig.");
if (!await confirmation.ConfirmAsync("Einzelstunde ändern?", $"„{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy}\n{changes}".TrimEnd(), ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
lessons.Save(lesson);
return new WriteResultDto(true, lesson.Id, "Einzelstunde gespeichert.");
}
// ── Einzelstunden (Lesson) — Verlaufsplan-Phasen ─────────────────────────────────────────
[Description("Fügt einer Einzelstunde eine neue Verlaufsplan-Phase hinzu (ans Ende). Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> AddLessonPhase(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Name der Phase, z.B. \"Einstieg\", \"Erarbeitung\".")] string name,
[Description("Dauer in Minuten.")] int durationMinutes,
[Description("Tätigkeit/Sozialform.")] string activity = "",
[Description("Material.")] string material = "",
[Description("Kurzsymbol, z.B. \"AB001->S\".")] string shorthand = "",
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var message = $"Neue Phase „{name}“ ({durationMinutes} Min.) zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?";
if (!await confirmation.ConfirmAsync("Phase hinzufügen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var phase = new LessonPhaseStep
{
Name = name, DurationMinutes = durationMinutes, Activity = activity,
Material = material, Shorthand = shorthand,
};
lesson.Phases.Add(phase);
lessons.Save(lesson);
return new WriteResultDto(true, phase.Id, "Phase hinzugefügt.");
}
[Description("Ändert eine bestehende Verlaufsplan-Phase einer Einzelstunde. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateLessonPhase(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("ID der Phase, aus get_lesson_plans.")] Guid phaseId,
[Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null,
[Description("Neue Dauer in Minuten. Unverändert lassen: weglassen.")] int? durationMinutes = null,
[Description("Neue Tätigkeit. Unverändert lassen: weglassen.")] string? activity = null,
[Description("Neues Material. Unverändert lassen: weglassen.")] string? material = null,
[Description("Neues Kurzsymbol. Unverändert lassen: weglassen.")] string? shorthand = null,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var phase = lesson.Phases.FirstOrDefault(p => p.Id == phaseId);
if (phase is null) return new WriteResultDto(false, null, "Unbekannte Phasen-ID an dieser Stunde.");
var changes = new StringBuilder();
if (name is not null && name != phase.Name) { changes.AppendLine($"Name: „{phase.Name}“ → „{name}“"); phase.Name = name; }
if (durationMinutes is not null && durationMinutes != phase.DurationMinutes) { changes.AppendLine($"Dauer: {phase.DurationMinutes} → {durationMinutes} Min."); phase.DurationMinutes = durationMinutes.Value; }
if (activity is not null && activity != phase.Activity) { changes.AppendLine($"Tätigkeit: „{phase.Activity}“ → „{activity}“"); phase.Activity = activity; }
if (material is not null && material != phase.Material) { changes.AppendLine($"Material: „{phase.Material}“ → „{material}“"); phase.Material = material; }
if (shorthand is not null && shorthand != phase.Shorthand) { changes.AppendLine($"Kürzel: „{phase.Shorthand}“ → „{shorthand}“"); phase.Shorthand = shorthand; }
if (changes.Length == 0)
return new WriteResultDto(true, phase.Id, "Keine Änderung nötig.");
if (!await confirmation.ConfirmAsync("Phase ändern?", $"Phase „{phase.Name}“ in „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy}\n{changes}".TrimEnd(), ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
lessons.Save(lesson);
return new WriteResultDto(true, phase.Id, "Phase gespeichert.");
}
[Description("Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> RemoveLessonPhase(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("ID der Phase, aus get_lesson_plans.")] Guid phaseId,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var phase = lesson.Phases.FirstOrDefault(p => p.Id == phaseId);
if (phase is null) return new WriteResultDto(false, null, "Unbekannte Phasen-ID an dieser Stunde.");
var message = $"Phase „{phase.Name}“ ({phase.DurationMinutes} Min.) aus „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} entfernen?";
if (!await confirmation.ConfirmAsync("Phase entfernen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
lesson.Phases.Remove(phase);
lessons.Save(lesson);
return new WriteResultDto(true, phase.Id, "Phase entfernt.");
}
private static LessonDto ToDto(Lesson l) => new(
l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.Status,
l.Phases.Select(p => new LessonPhaseDto(p.Id, p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand)).ToList(),
l.Attachments.Select(a => new LessonAttachmentDto(a.StorageId, a.FileName, a.SizeBytes)).ToList());
}