feat: lokaler MCP-Server, Phase 2 (Write-Tools + Bestätigungsdialog + Lesson-Plans)

Neue Write-Tools create_time_entry, create_grade_entry und
update_student_group_assignment schreiben nie direkt: jeder Aufruf zeigt
zuerst einen menschenlesbaren Bestätigungsdialog (bestehender
ConfirmDialog, über Dispatcher.UIThread aus dem Pipe-Session-Thread
angezeigt) und schreibt erst nach Bestätigung, mit 2-Minuten-Timeout
gegen eine hängende Session. Zusätzliches Read-Tool get_lesson_plans.

create_note bewusst nicht umgesetzt (kollidiert mit dem bestehenden
Dokumentations-Ausschluss aus Phase 1), create_lesson_plan/
update_lesson_plan wegen der Modellkomplexität von Lesson zurückgestellt
(siehe TODO.md 4.5.26).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 20:14:51 +02:00
co-authored by Claude Sonnet 5
parent 98f5573999
commit 9567d8d616
13 changed files with 529 additions and 30 deletions
+148 -7
View File
@@ -10,21 +10,31 @@ public sealed class McpToolsTests
// ── McpToolScope ─────────────────────────────────────────────────────────────────────────
[Fact]
public void AllowedReadTools_EnthaeltGenauDieFuenfPhase1Tools()
public void AllowedReadTools_EnthaeltGenauDieSechsReadTools()
{
Assert.Equal(
new[] { "get_exams", "get_grades", "get_schedule", "get_students", "get_time_entries" },
new[] { "get_exams", "get_grades", "get_lesson_plans", "get_schedule", "get_students", "get_time_entries" },
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
}
[Fact]
public void AllowedReadTools_EnthaeltKeineDokumentationstypen()
public void AllowedWriteTools_EnthaeltGenauDieDreiPhase2WriteTools()
{
Assert.Equal(
new[] { "create_grade_entry", "create_time_entry", "update_student_group_assignment" },
McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
}
[Fact]
public void AllowedTools_EnthaeltKeineDokumentationstypen()
{
// Gesprächsnotizen/Vorfälle/Förderpläne dürfen technisch nie über MCP erreichbar sein
// (siehe Planungsdokument) - die Namenskonvention "documentation"/"vorgang" darf nie auftauchen.
Assert.DoesNotContain(McpToolScope.AllowedReadTools, n =>
var allNames = McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools);
Assert.DoesNotContain(allNames, n =>
n.Contains("documentation", StringComparison.OrdinalIgnoreCase) ||
n.Contains("vorgang", StringComparison.OrdinalIgnoreCase));
n.Contains("vorgang", StringComparison.OrdinalIgnoreCase) ||
n.Contains("note", StringComparison.OrdinalIgnoreCase));
}
// ── StudentTools ─────────────────────────────────────────────────────────────────────────
@@ -98,13 +108,50 @@ public sealed class McpToolsTests
var grades = new FakeGrades();
grades.Add(new Grade { GroupId = groupId, StudentId = studentA, Value = "2" });
grades.Add(new Grade { GroupId = groupId, StudentId = studentB, Value = "3" });
var tool = new GradeTools(grades);
var tool = new GradeTools(grades, new FakeStudents([]), new FakeMcpConfirmation());
var dto = Assert.Single(tool.GetGrades(groupId, studentA));
Assert.Equal(studentA, dto.StudentId);
}
[Fact]
public async Task CreateGradeEntry_NutzerBestaetigt_SpeichertNote()
{
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
var grades = new FakeGrades();
var confirmation = new FakeMcpConfirmation { Response = true };
var tool = new GradeTools(grades, new FakeStudents([student]), confirmation);
var groupId = Guid.NewGuid();
var result = await tool.CreateGradeEntry(
student.Id, groupId, GradeCategory.Oral, "2+", new DateOnly(2026, 1, 10));
Assert.True(result.Applied);
Assert.NotNull(result.Id);
Assert.Single(grades.GetByGroup(groupId));
// Bestätigungstext muss für einen Menschen lesbar sein (Name statt bloßer GUID).
Assert.Contains("Anna", confirmation.LastMessage);
Assert.DoesNotContain(student.Id.ToString(), confirmation.LastMessage);
}
[Fact]
public async Task CreateGradeEntry_NutzerLehntAb_SpeichertNichts()
{
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
var grades = new FakeGrades();
var confirmation = new FakeMcpConfirmation { Response = false };
var tool = new GradeTools(grades, new FakeStudents([student]), confirmation);
var groupId = Guid.NewGuid();
var result = await tool.CreateGradeEntry(
student.Id, groupId, GradeCategory.Oral, "2+", new DateOnly(2026, 1, 10));
Assert.False(result.Applied);
Assert.Empty(grades.GetByGroup(groupId));
Assert.Equal(1, confirmation.CallCount);
}
// ── ScheduleTools ────────────────────────────────────────────────────────────────────────
[Fact]
@@ -129,10 +176,104 @@ public sealed class McpToolsTests
var entries = new FakeTimeEntries();
entries.Add(new TimeEntry { Date = new DateOnly(2026, 1, 5), DurationMinutes = 30 });
entries.Add(new TimeEntry { Date = new DateOnly(2026, 2, 1), DurationMinutes = 45 });
var tool = new TimeEntryTools(entries);
var tool = new TimeEntryTools(entries, new FakeGroups([]), new FakeMcpConfirmation());
var dto = Assert.Single(tool.GetTimeEntries(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
Assert.Equal(30, dto.DurationMinutes);
}
[Fact]
public async Task CreateTimeEntry_NutzerBestaetigt_SpeichertEintrag()
{
var entries = new FakeTimeEntries();
var confirmation = new FakeMcpConfirmation { Response = true };
var tool = new TimeEntryTools(entries, new FakeGroups([]), confirmation);
var result = await tool.CreateTimeEntry("Korrektur", new DateOnly(2026, 1, 10), 30);
Assert.True(result.Applied);
Assert.Single(entries.GetByDateRange(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
}
[Fact]
public async Task CreateTimeEntry_NutzerLehntAb_SpeichertNichts()
{
var entries = new FakeTimeEntries();
var confirmation = new FakeMcpConfirmation { Response = false };
var tool = new TimeEntryTools(entries, new FakeGroups([]), confirmation);
var result = await tool.CreateTimeEntry("Korrektur", new DateOnly(2026, 1, 10), 30);
Assert.False(result.Applied);
Assert.Empty(entries.GetByDateRange(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
}
// ── LessonPlanTools ──────────────────────────────────────────────────────────────────────
[Fact]
public void GetLessonPlans_LiefertEinheitenDerGruppeUndStundenImZeitraum()
{
var groupId = Guid.NewGuid();
var units = new FakeUnits();
units.Add(new Unit { GroupId = groupId, Title = "Optik" });
var lessons = new FakeLessons();
lessons.Add(new Lesson { GroupId = groupId, Date = new DateOnly(2026, 1, 5), Topic = "Brechung" });
lessons.Add(new Lesson { GroupId = groupId, Date = new DateOnly(2026, 3, 1), Topic = "Später" });
var tool = new LessonPlanTools(units, lessons);
var result = tool.GetLessonPlans(groupId, new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31));
Assert.Single(result.Units);
var lesson = Assert.Single(result.Lessons);
Assert.Equal("Brechung", lesson.Topic);
}
// ── GroupMembershipTools ─────────────────────────────────────────────────────────────────
[Fact]
public async Task UpdateStudentGroupAssignment_KeineBestehendeMitgliedschaft_LegtNeueAn()
{
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
var group = new LearningGroup { Name = "7a" };
var memberships = new FakeMemberships([]);
var confirmation = new FakeMcpConfirmation { Response = true };
var tool = new GroupMembershipTools(memberships, new FakeStudents([student]), new FakeGroups([group]), confirmation);
var result = await tool.UpdateStudentGroupAssignment(student.Id, group.Id, niveau: Niveau.E);
Assert.True(result.Applied);
var membership = Assert.Single(memberships.GetByStudent(student.Id));
Assert.Equal(Niveau.E, membership.Niveau);
Assert.Contains("Anna", confirmation.LastMessage);
}
[Fact]
public async Task UpdateStudentGroupAssignment_BestehendeMitgliedschaftUnveraendert_FragtNichtNochmalNach()
{
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
var group = new LearningGroup { Name = "7a" };
var existing = new GroupMembership { StudentId = student.Id, GroupId = group.Id, Niveau = Niveau.G };
var memberships = new FakeMemberships([existing]);
var confirmation = new FakeMcpConfirmation { Response = true };
var tool = new GroupMembershipTools(memberships, new FakeStudents([student]), new FakeGroups([group]), confirmation);
var result = await tool.UpdateStudentGroupAssignment(student.Id, group.Id, niveau: Niveau.G);
Assert.True(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
[Fact]
public async Task UpdateStudentGroupAssignment_UnbekannterSchueler_LiefertFehlerOhneNachfrage()
{
var group = new LearningGroup { Name = "7a" };
var confirmation = new FakeMcpConfirmation { Response = true };
var tool = new GroupMembershipTools(new FakeMemberships([]), new FakeStudents([]), new FakeGroups([group]), confirmation);
var result = await tool.UpdateStudentGroupAssignment(Guid.NewGuid(), group.Id);
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
}