feat: MCP-Tool add_lesson_attachment (Nutzer-Nachtrag)
CI / build-and-test (push) Canceled after 0s

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 23:45:56 +02:00
co-authored by Claude Sonnet 5
parent 55fba2cadb
commit e6c30b0bc7
5 changed files with 119 additions and 3 deletions
+66 -3
View File
@@ -28,9 +28,9 @@ public sealed class McpToolsTests
Assert.Equal( Assert.Equal(
new[] new[]
{ {
"add_lesson_phase", "create_grade_entry", "create_lesson", "create_time_entry", "add_lesson_attachment", "add_lesson_phase", "create_grade_entry", "create_lesson",
"create_unit", "remove_lesson_phase", "update_lesson", "update_lesson_phase", "create_time_entry", "create_unit", "remove_lesson_phase", "update_lesson",
"update_student_group_assignment", "update_unit", "update_lesson_phase", "update_student_group_assignment", "update_unit",
}, },
McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal)); McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
} }
@@ -400,6 +400,69 @@ public sealed class McpToolsTests
Assert.Throws<InvalidOperationException>(() => tool.DownloadLessonAttachment(lesson.Id, "big")); Assert.Throws<InvalidOperationException>(() => 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 ───────────────────────────────────────────────────────────────── // ── GroupMembershipTools ─────────────────────────────────────────────────────────────────
[Fact] [Fact]
@@ -180,6 +180,8 @@ public sealed class McpServerHostedService : IAsyncDisposable
"Ändert eine Verlaufsplan-Phase einer Einzelstunde (Bestätigung durch den Nutzer nötig)."); "Ändert eine Verlaufsplan-Phase einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.RemoveLessonPhase, "remove_lesson_phase", AddWriteTool(lessonPlanTools.RemoveLessonPhase, "remove_lesson_phase",
"Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde (Bestätigung durch den Nutzer nötig)."); "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( System.Diagnostics.Debug.Assert(
toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n) toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n)
@@ -40,5 +40,6 @@ public static class McpToolScope
"add_lesson_phase", "add_lesson_phase",
"update_lesson_phase", "update_lesson_phase",
"remove_lesson_phase", "remove_lesson_phase",
"add_lesson_attachment",
]; ];
} }
@@ -63,6 +63,42 @@ public class LessonPlanTools(
return new AttachmentContentDto(attachment.FileName, attachment.SizeBytes, Convert.ToBase64String(buffer.ToArray())); 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<WriteResultDto> 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) ────────────────────────────────────────────────────────── // ── Unterrichtseinheiten (Unit) ──────────────────────────────────────────────────────────
[Description("Legt eine neue Unterrichtseinheit an. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen.")] [Description("Legt eine neue Unterrichtseinheit an. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen.")]
+14
View File
@@ -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 "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. 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, **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`,