From e6c30b0bc744cba59725ce47de5b4d835b036839 Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Fri, 11 Sep 2026 23:45:56 +0200 Subject: [PATCH] feat: MCP-Tool add_lesson_attachment (Nutzer-Nachtrag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit download_lesson_attachment deckte bisher nur die Leserichtung ab, der Nutzer legt Arbeitsblätter aber tatsächlich über die Anhang-Funktion an Einzelstunden ab. Neues Write-Tool nimmt Dateiname + Base64-Inhalt entgegen, validiert vor der Bestätigungsnachfrage (leer/ungültig/zu groß) und lädt erst nach Bestätigung hoch. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Desktop.Tests/McpToolsTests.cs | 69 ++++++++++++++++++- .../Services/Mcp/McpServerHostedService.cs | 2 + .../Services/Mcp/McpToolScope.cs | 1 + .../Services/Mcp/Tools/LessonPlanTools.cs | 36 ++++++++++ TODO.md | 14 ++++ 5 files changed, 119 insertions(+), 3 deletions(-) diff --git a/LehrerApp.Desktop.Tests/McpToolsTests.cs b/LehrerApp.Desktop.Tests/McpToolsTests.cs index bf8699a..d12204f 100644 --- a/LehrerApp.Desktop.Tests/McpToolsTests.cs +++ b/LehrerApp.Desktop.Tests/McpToolsTests.cs @@ -28,9 +28,9 @@ public sealed class McpToolsTests Assert.Equal( new[] { - "add_lesson_phase", "create_grade_entry", "create_lesson", "create_time_entry", - "create_unit", "remove_lesson_phase", "update_lesson", "update_lesson_phase", - "update_student_group_assignment", "update_unit", + "add_lesson_attachment", "add_lesson_phase", "create_grade_entry", "create_lesson", + "create_time_entry", "create_unit", "remove_lesson_phase", "update_lesson", + "update_lesson_phase", "update_student_group_assignment", "update_unit", }, McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal)); } @@ -400,6 +400,69 @@ public sealed class McpToolsTests Assert.Throws(() => tool.DownloadLessonAttachment(lesson.Id, "big")); } + [Fact] + public async Task AddLessonAttachment_NutzerBestaetigt_LaedtHochUndHaengtAn() + { + var lesson = new Lesson { Topic = "Brechung" }; + var lessons = new FakeLessons(); + lessons.Add(lesson); + var storage = new FakeAttachmentStorage(); + var tool = BuildLessonPlanTools(lessons: lessons, attachments: storage); + var content = Convert.ToBase64String("Arbeitsblatt-Inhalt"u8.ToArray()); + + var result = await tool.AddLessonAttachment(lesson.Id, "arbeitsblatt.pdf", content); + + Assert.True(result.Applied); + var attachment = Assert.Single(lessons.GetById(lesson.Id)!.Attachments); + Assert.Equal("arbeitsblatt.pdf", attachment.FileName); + using var stream = storage.OpenRead(attachment.StorageId)!; + using var reader = new StreamReader(stream); + Assert.Equal("Arbeitsblatt-Inhalt", reader.ReadToEnd()); + } + + [Fact] + public async Task AddLessonAttachment_NutzerLehntAb_SpeichertNichts() + { + var lesson = new Lesson { Topic = "Brechung" }; + var lessons = new FakeLessons(); + lessons.Add(lesson); + var confirmation = new FakeMcpConfirmation { Response = false }; + var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation); + var content = Convert.ToBase64String("Inhalt"u8.ToArray()); + + var result = await tool.AddLessonAttachment(lesson.Id, "arbeitsblatt.pdf", content); + + Assert.False(result.Applied); + Assert.Empty(lessons.GetById(lesson.Id)!.Attachments); + } + + [Fact] + public async Task AddLessonAttachment_UngueltigesBase64_LiefertFehlerOhneNachfrage() + { + var lesson = new Lesson { Topic = "Brechung" }; + var lessons = new FakeLessons(); + lessons.Add(lesson); + var confirmation = new FakeMcpConfirmation(); + var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation); + + var result = await tool.AddLessonAttachment(lesson.Id, "arbeitsblatt.pdf", "nicht-base64!!!"); + + Assert.False(result.Applied); + Assert.Equal(0, confirmation.CallCount); + } + + [Fact] + public async Task AddLessonAttachment_UnbekannteStunde_LiefertFehlerOhneNachfrage() + { + var confirmation = new FakeMcpConfirmation(); + var tool = BuildLessonPlanTools(confirmation: confirmation); + + var result = await tool.AddLessonAttachment(Guid.NewGuid(), "x.pdf", Convert.ToBase64String("x"u8.ToArray())); + + Assert.False(result.Applied); + Assert.Equal(0, confirmation.CallCount); + } + // ── GroupMembershipTools ───────────────────────────────────────────────────────────────── [Fact] diff --git a/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs b/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs index aad1b81..772c163 100644 --- a/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs +++ b/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs @@ -180,6 +180,8 @@ public sealed class McpServerHostedService : IAsyncDisposable "Ändert eine Verlaufsplan-Phase einer Einzelstunde (Bestätigung durch den Nutzer nötig)."); AddWriteTool(lessonPlanTools.RemoveLessonPhase, "remove_lesson_phase", "Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(lessonPlanTools.AddLessonAttachment, "add_lesson_attachment", + "Fügt einer Einzelstunde ein neues Material als Base64-kodierten Anhang hinzu (Bestätigung durch den Nutzer nötig)."); System.Diagnostics.Debug.Assert( toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n) diff --git a/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs b/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs index 68dc797..cc14022 100644 --- a/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs +++ b/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs @@ -40,5 +40,6 @@ public static class McpToolScope "add_lesson_phase", "update_lesson_phase", "remove_lesson_phase", + "add_lesson_attachment", ]; } diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs index 1c1e66b..870d311 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs @@ -63,6 +63,42 @@ public class LessonPlanTools( return new AttachmentContentDto(attachment.FileName, attachment.SizeBytes, Convert.ToBase64String(buffer.ToArray())); } + [Description("Fügt einer Einzelstunde ein neues Material (z.B. ein von der KI erzeugtes Arbeitsblatt) als Anhang hinzu, Base64-kodiert. Muss der Nutzer erst bestätigen.")] + public async Task AddLessonAttachment( + [Description("ID der Einzelstunde.")] Guid lessonId, + [Description("Dateiname inkl. Endung, z.B. \"arbeitsblatt.pdf\".")] string fileName, + [Description("Dateiinhalt, Base64-kodiert.")] string base64Content, + CancellationToken ct = default) + { + var lesson = lessons.GetById(lessonId); + if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); + + byte[] bytes; + try { bytes = Convert.FromBase64String(base64Content); } + catch (FormatException) { return new WriteResultDto(false, null, "Ungültiger Base64-Inhalt."); } + + if (bytes.Length == 0) return new WriteResultDto(false, null, "Leerer Dateiinhalt."); + if (bytes.Length > IAttachmentStorage.MaxSizeBytes) + return new WriteResultDto(false, null, + $"Datei ist mit {bytes.Length / 1024 / 1024} MB zu groß (Limit {IAttachmentStorage.MaxSizeBytes / 1024 / 1024} MB)."); + + var sizeDisplay = bytes.Length >= 1024 * 1024 + ? $"{bytes.Length / 1024 / 1024} MB" : $"{Math.Max(1, bytes.Length / 1024)} KB"; + var message = $"„{fileName}“ ({sizeDisplay}) zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?"; + if (!await confirmation.ConfirmAsync("Material anhängen?", message, ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + string storageId; + using (var stream = new MemoryStream(bytes)) storageId = attachments.Upload(fileName, stream); + + lesson.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = fileName, SizeBytes = bytes.Length }); + lessons.Save(lesson); + // DocumentAttachment hat keine eigene Guid-Id (nur StorageId, ein string) - deshalb Id hier + // null, storageId steht stattdessen in der Nachricht (für einen unmittelbaren Folgeaufruf, + // z.B. download_lesson_attachment zur Bestätigung, ohne erst get_lesson_plans erneut aufzurufen). + return new WriteResultDto(true, null, $"Anhang gespeichert (storageId={storageId})."); + } + // ── Unterrichtseinheiten (Unit) ────────────────────────────────────────────────────────── [Description("Legt eine neue Unterrichtseinheit an. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen.")] diff --git a/TODO.md b/TODO.md index adb54e2..27d2ae9 100644 --- a/TODO.md +++ b/TODO.md @@ -2636,6 +2636,20 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`: "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. +- [x] **4.5.30** `add_lesson_attachment`-Tool (2026-09-11, Nutzer-Nachtrag zu 4.5.28): Nutzer nutzt + die bestehende Anhang-Funktion an Einzelstunden (`Lesson.Attachments`) tatsächlich, um + Arbeitsblätter abzulegen — `download_lesson_attachment` (4.5.28) deckte davon nur die + Leserichtung ab. Neues Write-Tool in + [LessonPlanTools.cs](LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs) nimmt + Dateiname + Base64-Inhalt entgegen, validiert (leer/ungültiges Base64/über + `IAttachmentStorage.MaxSizeBytes`) **vor** der Bestätigungsnachfrage, lädt erst nach + Bestätigung über `IAttachmentStorage.Upload` hoch und hängt den `DocumentAttachment`-Eintrag + an die Stunde. `DocumentAttachment` hat keine eigene Guid-Id (nur `StorageId`, ein string) — + `WriteResultDto.Id` bleibt deshalb `null`, die `storageId` steht stattdessen in der + Erfolgsmeldung, damit ein Folgeaufruf (z.B. zur Kontrolle per `download_lesson_attachment`) + ohne erneutes `get_lesson_plans` möglich ist. 4 neue Tests in + [McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs) (jetzt 32). + **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`,