Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6c30b0bc7 | ||
|
|
55fba2cadb | ||
|
|
dd2e1e7c61 | ||
|
|
0c60a54c4d | ||
|
|
9567d8d616 | ||
|
|
98f5573999 |
@@ -25,6 +25,9 @@
|
||||
<!-- API -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||
|
||||
<!-- MCP-Server (lokal, siehe TODO.md) -->
|
||||
<PackageVersion Include="ModelContextProtocol.Core" Version="2.2.0" />
|
||||
|
||||
<!-- Tests -->
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
|
||||
@@ -95,6 +95,7 @@ public interface IUnitRepository
|
||||
}
|
||||
public interface ILessonRepository
|
||||
{
|
||||
Lesson? GetById(Guid id);
|
||||
List<Lesson> GetByUnit(Guid unitId);
|
||||
List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date);
|
||||
List<Lesson> GetByGroupAndRange(Guid groupId, DateOnly from, DateOnly to);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace LehrerApp.Core.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Named-Pipe-Konvention zwischen dem Avalonia-Hauptprozess (Pipe-Server, siehe
|
||||
/// LehrerApp.Desktop/Services/Mcp) und LehrerApp.McpBridge (Pipe-Client). Bewusst als einzelne
|
||||
/// geteilte Konstante statt eigener Bibliothek — die Bridge reicht JSON-RPC-Nachrichten unverändert
|
||||
/// durch und braucht sonst keine gemeinsamen Typen mit der App (siehe Planungsdokument).
|
||||
/// </summary>
|
||||
public static class McpPipeConstants
|
||||
{
|
||||
public const string PipeName = "LehrerApp.Mcp";
|
||||
}
|
||||
@@ -386,6 +386,7 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
|
||||
|
||||
public class LessonRepository(LiteDbContext db) : ILessonRepository
|
||||
{
|
||||
public Lesson? GetById(Guid id) => db.Lessons.FindById(id);
|
||||
public List<Lesson> GetByUnit(Guid id) =>
|
||||
db.Lessons.Find(l => l.UnitId == id).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList();
|
||||
public List<Lesson> GetByGroupAndDate(Guid gid, DateOnly date) =>
|
||||
|
||||
@@ -2,6 +2,7 @@ using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.Services.Mcp;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
@@ -31,6 +32,17 @@ public static class TestSupport
|
||||
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||
|
||||
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
||||
public static McpSettingsService BuildMcpSettingsService()
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-mcpsettings-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new McpSettingsService(tempPath);
|
||||
}
|
||||
|
||||
/// Kein Konstruktorparameter - Pfade werden intern über Environment.SpecialFolder aufgelöst.
|
||||
public static McpClientRegistrationService BuildMcpClientRegistrationService() => new();
|
||||
|
||||
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
||||
public static WebUntisSettingsService BuildWebUntisSettingsService()
|
||||
{
|
||||
@@ -346,6 +358,7 @@ public class FakeLessons : ILessonRepository
|
||||
{
|
||||
private readonly List<Lesson> _all = [];
|
||||
public void Add(Lesson l) => _all.Add(l);
|
||||
public Lesson? GetById(Guid id) => _all.FirstOrDefault(l => l.Id == id);
|
||||
public List<Lesson> GetByUnit(Guid unitId) =>
|
||||
_all.Where(l => l.UnitId == unitId).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList();
|
||||
public List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date) =>
|
||||
@@ -586,3 +599,22 @@ public class FakeReportGrades : IReportGradeRepository
|
||||
}
|
||||
public void Delete(Guid id) => _all.RemoveAll(r => r.Id == id);
|
||||
}
|
||||
|
||||
/// <summary>Fake für MCP-Write-Tool-Tests (Phase 2): antwortet ohne echtes UI, konfigurierbar über
|
||||
/// <see cref="Response"/>, merkt sich Titel/Nachricht des letzten Aufrufs zur Prüfung, dass der
|
||||
/// Bestätigungstext tatsächlich menschenlesbar ist (kein rohes JSON/GUID-Dump).</summary>
|
||||
public class FakeMcpConfirmation : IMcpConfirmationService
|
||||
{
|
||||
public bool Response { get; set; } = true;
|
||||
public string? LastTitle { get; private set; }
|
||||
public string? LastMessage { get; private set; }
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Task<bool> ConfirmAsync(string title, string message, CancellationToken ct)
|
||||
{
|
||||
CallCount++;
|
||||
LastTitle = title;
|
||||
LastMessage = message;
|
||||
return Task.FromResult(Response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class LetterTemplateToolsTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-lettertools-tests-{Guid.NewGuid():N}");
|
||||
public LetterTemplateToolsTests() => Directory.CreateDirectory(_directory);
|
||||
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||
|
||||
private LetterTemplateTools BuildTool(TemplateStore store, FakeStudents? students = null, FakeGroups? groups = null) =>
|
||||
new(store, new TemplateLoader(), new QuestTemplateRenderer(), students ?? new FakeStudents([]), groups ?? new FakeGroups([]));
|
||||
|
||||
private TemplateStore StoreWithTemplate(string name, params PlaceholderDefinition[] definitions)
|
||||
{
|
||||
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||
var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = name, Placeholders = [.. definitions] };
|
||||
var lines = new List<string> { "PAGE 210 297 mm" };
|
||||
var y = 20;
|
||||
foreach (var definition in definitions)
|
||||
{
|
||||
var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
|
||||
lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
|
||||
y += 20;
|
||||
}
|
||||
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
|
||||
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
|
||||
store.Import(source);
|
||||
return store;
|
||||
}
|
||||
|
||||
private static Student StudentWithContact(string? salutation = "Sehr geehrte Frau Muster,") => new()
|
||||
{
|
||||
FirstName = "Lena", LastName = "Beispiel",
|
||||
Contacts = [new Contact
|
||||
{
|
||||
Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation,
|
||||
Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt",
|
||||
}],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ListLetterTemplates_ListetVorlageMitPlatzhaltern()
|
||||
{
|
||||
var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
|
||||
var tool = BuildTool(store);
|
||||
|
||||
var result = Assert.Single(tool.ListLetterTemplates());
|
||||
|
||||
Assert.Equal("Elternbrief", result.Name);
|
||||
Assert.Contains(result.Placeholders, p => p.Name == "Anrede" && p.Required);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderLetter_UnbekannteVorlage_LiefertFehler()
|
||||
{
|
||||
var student = StudentWithContact();
|
||||
var tool = BuildTool(new TemplateStore(_directory), new FakeStudents([student]));
|
||||
|
||||
var result = tool.RenderLetter("nicht-vorhanden", student.Id, "Text", "Frau Lehrer");
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Null(result.Base64Pdf);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderLetter_UnbekannterSchueler_LiefertFehler()
|
||||
{
|
||||
var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
|
||||
var installed = Assert.Single(store.GetTemplates());
|
||||
var tool = BuildTool(store);
|
||||
|
||||
var result = tool.RenderLetter(installed.Id, Guid.NewGuid(), "Text", "Frau Lehrer");
|
||||
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderLetter_PflichtplatzhalterFehlt_LiefertFehlerOhnePdf()
|
||||
{
|
||||
var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
|
||||
var installed = Assert.Single(store.GetTemplates());
|
||||
var student = StudentWithContact(salutation: null); // keine Anrede hinterlegt
|
||||
var tool = BuildTool(store, new FakeStudents([student]));
|
||||
|
||||
var result = tool.RenderLetter(installed.Id, student.Id, "Text", "Frau Lehrer");
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Anrede", result.Message);
|
||||
Assert.Null(result.Base64Pdf);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderLetter_VollstaendigeDaten_LiefertBase64Pdf()
|
||||
{
|
||||
var store = StoreWithTemplate("Elternbrief",
|
||||
new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
|
||||
var installed = Assert.Single(store.GetTemplates());
|
||||
var student = StudentWithContact();
|
||||
var tool = BuildTool(store, new FakeStudents([student]));
|
||||
|
||||
var result = tool.RenderLetter(installed.Id, student.Id, "Dies ist der Inhalt.", "Frau Lehrer");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Base64Pdf);
|
||||
var bytes = Convert.FromBase64String(result.Base64Pdf!);
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(bytes, 0, 4));
|
||||
Assert.Equal("Elternbrief_Beispiel_Lena.pdf", result.SuggestedFileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderLetter_ExtraValues_FuelltZusaetzlichenPlatzhalter()
|
||||
{
|
||||
var store = StoreWithTemplate("Elternbrief",
|
||||
new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||
new PlaceholderDefinition("Betreff", PlaceholderType.Text, true));
|
||||
var installed = Assert.Single(store.GetTemplates());
|
||||
var student = StudentWithContact();
|
||||
var tool = BuildTool(store, new FakeStudents([student]));
|
||||
|
||||
var result = tool.RenderLetter(installed.Id, student.Id, "Text", "Frau Lehrer",
|
||||
extraValues: new Dictionary<string, string> { ["Betreff"] = "Elternabend" });
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Base64Pdf);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using LehrerApp.Desktop.Services.Mcp;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class McpClientRegistrationServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-mcpreg-tests-{Guid.NewGuid():N}");
|
||||
public McpClientRegistrationServiceTests() => Directory.CreateDirectory(_directory);
|
||||
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||
|
||||
private string ConfigPath => Path.Combine(_directory, "claude_desktop_config.json");
|
||||
|
||||
private string BuildFakeBridge()
|
||||
{
|
||||
var bridgePath = Path.Combine(_directory, "LehrerApp.McpBridge.exe");
|
||||
File.WriteAllText(bridgePath, "fake");
|
||||
return bridgePath;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_OhneBridgeDatei_LiefertFehlerUndSchreibtNichts()
|
||||
{
|
||||
var service = new McpClientRegistrationService(ConfigPath, Path.Combine(_directory, "fehlt.exe"));
|
||||
|
||||
var result = service.Register();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(File.Exists(ConfigPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_OhneBestehendeKonfiguration_LegtDateiMitEintragAn()
|
||||
{
|
||||
var bridgePath = BuildFakeBridge();
|
||||
var service = new McpClientRegistrationService(ConfigPath, bridgePath);
|
||||
|
||||
var result = service.Register();
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(service.IsRegistered());
|
||||
var root = JsonNode.Parse(File.ReadAllText(ConfigPath))!;
|
||||
Assert.Equal(bridgePath, root["mcpServers"]!["lehrerapp"]!["command"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_BestehendeKonfigurationMitAnderenServern_BleibtErhalten()
|
||||
{
|
||||
File.WriteAllText(ConfigPath, """{"mcpServers":{"andererServer":{"command":"foo"}},"globalShortcut":"Ctrl+X"}""");
|
||||
var bridgePath = BuildFakeBridge();
|
||||
var service = new McpClientRegistrationService(ConfigPath, bridgePath);
|
||||
|
||||
var result = service.Register();
|
||||
|
||||
Assert.True(result.Success);
|
||||
var root = JsonNode.Parse(File.ReadAllText(ConfigPath))!;
|
||||
Assert.Equal("foo", root["mcpServers"]!["andererServer"]!["command"]!.GetValue<string>());
|
||||
Assert.Equal("Ctrl+X", root["globalShortcut"]!.GetValue<string>());
|
||||
Assert.Equal(bridgePath, root["mcpServers"]!["lehrerapp"]!["command"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_KaputteBestehendeKonfiguration_WirdNichtUeberschrieben()
|
||||
{
|
||||
File.WriteAllText(ConfigPath, "{ das ist kein json");
|
||||
var bridgePath = BuildFakeBridge();
|
||||
var service = new McpClientRegistrationService(ConfigPath, bridgePath);
|
||||
|
||||
var result = service.Register();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("{ das ist kein json", File.ReadAllText(ConfigPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unregister_EntferntNurEigenenEintrag()
|
||||
{
|
||||
File.WriteAllText(ConfigPath, """{"mcpServers":{"andererServer":{"command":"foo"},"lehrerapp":{"command":"bar"}}}""");
|
||||
var service = new McpClientRegistrationService(ConfigPath, BuildFakeBridge());
|
||||
|
||||
var result = service.Unregister();
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.False(service.IsRegistered());
|
||||
Assert.Contains("andererServer", File.ReadAllText(ConfigPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRegistered_OhneDatei_IstFalse()
|
||||
{
|
||||
var service = new McpClientRegistrationService(ConfigPath, BuildFakeBridge());
|
||||
|
||||
Assert.False(service.IsRegistered());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services.Mcp;
|
||||
using LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class McpToolsTests
|
||||
{
|
||||
// ── McpToolScope ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AllowedReadTools_EnthaeltGenauDieErwartetenReadTools()
|
||||
{
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"download_lesson_attachment", "get_exams", "get_grades", "get_lesson_plans",
|
||||
"get_schedule", "get_students", "get_time_entries", "list_letter_templates",
|
||||
"render_letter",
|
||||
},
|
||||
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowedWriteTools_EnthaeltGenauDieErwartetenWriteTools()
|
||||
{
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"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));
|
||||
}
|
||||
|
||||
[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.
|
||||
var allNames = McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools);
|
||||
Assert.DoesNotContain(allNames, n =>
|
||||
n.Contains("documentation", StringComparison.OrdinalIgnoreCase) ||
|
||||
n.Contains("vorgang", StringComparison.OrdinalIgnoreCase) ||
|
||||
n.Contains("note", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// ── StudentTools ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetStudents_OhneFilter_LiefertNurAktiveSchueler()
|
||||
{
|
||||
var active = new Student { FirstName = "Anna", LastName = "Aktiv", IsActive = true };
|
||||
var inactive = new Student { FirstName = "Ida", LastName = "Inaktiv", IsActive = false };
|
||||
var tool = new StudentTools(new FakeStudents([active, inactive]));
|
||||
|
||||
var result = tool.GetStudents();
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(active.Id, result[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStudents_IncludeInactive_LiefertAuchInaktive()
|
||||
{
|
||||
var active = new Student { FirstName = "Anna", LastName = "Aktiv", IsActive = true };
|
||||
var inactive = new Student { FirstName = "Ida", LastName = "Inaktiv", IsActive = false };
|
||||
var tool = new StudentTools(new FakeStudents([active, inactive]));
|
||||
|
||||
var result = tool.GetStudents(includeInactive: true);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
// ── ExamTools ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetExams_OhneIncludeResults_LiefertKeineErgebnisse()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var exam = new Exam { GroupId = groupId, Title = "Klausur 1" };
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = exam.Id, StudentId = Guid.NewGuid(), TotalPoints = 10 });
|
||||
var tool = new ExamTools(new FakeExams([exam]), results);
|
||||
|
||||
var dto = Assert.Single(tool.GetExams(groupId));
|
||||
|
||||
Assert.Null(dto.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetExams_MitIncludeResults_LiefertErgebnisseJeSchueler()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var studentId = Guid.NewGuid();
|
||||
var exam = new Exam { GroupId = groupId, Title = "Klausur 1" };
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = exam.Id, StudentId = studentId, TotalPoints = 12.5, Grade = "2+" });
|
||||
var tool = new ExamTools(new FakeExams([exam]), results);
|
||||
|
||||
var dto = Assert.Single(tool.GetExams(groupId, includeResults: true));
|
||||
|
||||
var result = Assert.Single(dto.Results!);
|
||||
Assert.Equal(studentId, result.StudentId);
|
||||
Assert.Equal("2+", result.Grade);
|
||||
}
|
||||
|
||||
// ── GradeTools ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetGrades_MitStudentId_FiltertAufEinenSchueler()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var studentA = Guid.NewGuid();
|
||||
var studentB = Guid.NewGuid();
|
||||
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, 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]
|
||||
public void GetSchedule_MitGroupId_FiltertNachGruppe()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
|
||||
slots.Add(new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 2 });
|
||||
var tool = new ScheduleTools(slots);
|
||||
|
||||
var dto = Assert.Single(tool.GetSchedule(groupId));
|
||||
|
||||
Assert.Equal(groupId, dto.GroupId);
|
||||
}
|
||||
|
||||
// ── TimeEntryTools ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetTimeEntries_FiltertAufDenAngegebenenZeitraum()
|
||||
{
|
||||
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, 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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static LessonPlanTools BuildLessonPlanTools(
|
||||
FakeUnits? units = null, FakeLessons? lessons = null, FakeGroups? groups = null,
|
||||
FakeAttachmentStorage? attachments = null, FakeMcpConfirmation? confirmation = null) =>
|
||||
new(units ?? new FakeUnits(), lessons ?? new FakeLessons(), groups ?? new FakeGroups([]),
|
||||
attachments ?? new FakeAttachmentStorage(), confirmation ?? new FakeMcpConfirmation());
|
||||
|
||||
[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 = BuildLessonPlanTools(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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateUnit_NutzerBestaetigt_SpeichertEinheit()
|
||||
{
|
||||
var group = new LearningGroup { Name = "7a" };
|
||||
var units = new FakeUnits();
|
||||
var tool = BuildLessonPlanTools(units: units, groups: new FakeGroups([group]));
|
||||
|
||||
var result = await tool.CreateUnit(group.Id, "Optik");
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Single(units.GetByGroup(group.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUnit_OhneAenderung_FragtNichtNach()
|
||||
{
|
||||
var unit = new Unit { Title = "Optik", Status = UnitStatus.Planned };
|
||||
var units = new FakeUnits();
|
||||
units.Add(unit);
|
||||
var confirmation = new FakeMcpConfirmation();
|
||||
var tool = BuildLessonPlanTools(units: units, confirmation: confirmation);
|
||||
|
||||
var result = await tool.UpdateUnit(unit.Id, title: "Optik", status: UnitStatus.Planned);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Equal(0, confirmation.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateLesson_UnbekannteEinheit_LiefertFehlerOhneNachfrage()
|
||||
{
|
||||
var confirmation = new FakeMcpConfirmation();
|
||||
var tool = BuildLessonPlanTools(confirmation: confirmation);
|
||||
|
||||
var result = await tool.CreateLesson(Guid.NewGuid(), new DateOnly(2026, 1, 5), "Brechung");
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(0, confirmation.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateLesson_NutzerBestaetigt_UebernimmtGruppeVonDerEinheit()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var unit = new Unit { GroupId = groupId, Title = "Optik" };
|
||||
var units = new FakeUnits();
|
||||
units.Add(unit);
|
||||
var lessons = new FakeLessons();
|
||||
var tool = BuildLessonPlanTools(units, lessons);
|
||||
|
||||
var result = await tool.CreateLesson(unit.Id, new DateOnly(2026, 1, 5), "Brechung");
|
||||
|
||||
Assert.True(result.Applied);
|
||||
var lesson = Assert.Single(lessons.GetByUnit(unit.Id));
|
||||
Assert.Equal(groupId, lesson.GroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLesson_AendertNurAngegebeneFelder()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Brechung", Homework = "S. 12" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||
|
||||
var result = await tool.UpdateLesson(lesson.Id, topic: "Brechung II");
|
||||
|
||||
Assert.True(result.Applied);
|
||||
var updated = lessons.GetById(lesson.Id)!;
|
||||
Assert.Equal("Brechung II", updated.Topic);
|
||||
Assert.Equal("S. 12", updated.Homework);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddLessonPhase_NutzerBestaetigt_HaengtPhaseAn()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||
|
||||
var result = await tool.AddLessonPhase(lesson.Id, "Einstieg", 10);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
var phase = Assert.Single(lessons.GetById(lesson.Id)!.Phases);
|
||||
Assert.Equal("Einstieg", phase.Name);
|
||||
Assert.Equal(result.Id, phase.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLessonPhase_AendertNurAngegebeneFelder()
|
||||
{
|
||||
var phase = new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10, Material = "Folie" };
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
lesson.Phases.Add(phase);
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||
|
||||
var result = await tool.UpdateLessonPhase(lesson.Id, phase.Id, durationMinutes: 15);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
var updated = lessons.GetById(lesson.Id)!.Phases.Single();
|
||||
Assert.Equal(15, updated.DurationMinutes);
|
||||
Assert.Equal("Folie", updated.Material);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveLessonPhase_NutzerBestaetigt_EntferntPhase()
|
||||
{
|
||||
var phase = new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10 };
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
lesson.Phases.Add(phase);
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||
|
||||
var result = await tool.RemoveLessonPhase(lesson.Id, phase.Id);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Empty(lessons.GetById(lesson.Id)!.Phases);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadLessonAttachment_LiefertBase64Inhalt()
|
||||
{
|
||||
var storage = new FakeAttachmentStorage();
|
||||
var storageId = storage.Upload("blatt.pdf", new MemoryStream("PDF-Inhalt"u8.ToArray()));
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
lesson.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "blatt.pdf", SizeBytes = 10 });
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var tool = BuildLessonPlanTools(lessons: lessons, attachments: storage);
|
||||
|
||||
var result = tool.DownloadLessonAttachment(lesson.Id, storageId);
|
||||
|
||||
Assert.Equal("blatt.pdf", result.FileName);
|
||||
Assert.Equal("PDF-Inhalt", System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(result.Base64Content)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadLessonAttachment_ZuGross_WirftFehler()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
lesson.Attachments.Add(new DocumentAttachment
|
||||
{
|
||||
StorageId = "big", FileName = "video.mp4", SizeBytes = LessonPlanTools.MaxInlineAttachmentBytes + 1,
|
||||
});
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ public sealed class SettingsViewModelTests
|
||||
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
||||
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
||||
new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildMcpSettingsService(),
|
||||
TestSupport.BuildMcpClientRegistrationService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
|
||||
@@ -341,6 +343,8 @@ public sealed class SettingsViewModelTests
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildMcpSettingsService(),
|
||||
TestSupport.BuildMcpClientRegistrationService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
@@ -369,6 +373,8 @@ public sealed class SettingsViewModelTests
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildMcpSettingsService(),
|
||||
TestSupport.BuildMcpClientRegistrationService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
@@ -401,6 +407,8 @@ public sealed class SettingsViewModelTests
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildMcpSettingsService(),
|
||||
TestSupport.BuildMcpClientRegistrationService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
|
||||
@@ -94,6 +94,11 @@ public class App : Application
|
||||
_exitHandlerAttached = true;
|
||||
}
|
||||
|
||||
// MCP-Server (lokal, Phase 1): Start ist ohne Wirkung, falls in den Einstellungen nicht
|
||||
// aktiviert (siehe McpServerHostedService.Start). Kein Live-Reload beim Umschalten des
|
||||
// Opt-in - ein Neustart der App richtet den Pipe-Listener neu ein.
|
||||
Services.GetRequiredService<Services.Mcp.McpServerHostedService>().Start();
|
||||
|
||||
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
||||
WireCallbacks(mainVm);
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Data.Repositories;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.Services.Mcp;
|
||||
using LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using LehrerApp.Desktop.ViewModels.Exams;
|
||||
@@ -213,6 +215,20 @@ public static class AppBootstrapper
|
||||
});
|
||||
services.AddSingleton<AiPlanningService>();
|
||||
|
||||
// ── MCP-Server (lokal, Phase 1 – siehe Planungsdokument, optional per Opt-in) ─────────
|
||||
services.AddSingleton(_ => new McpSettingsService(appData));
|
||||
services.AddSingleton<IMcpConfirmationService, AvaloniaMcpConfirmationService>();
|
||||
services.AddSingleton<StudentTools>();
|
||||
services.AddSingleton<ExamTools>();
|
||||
services.AddSingleton<GradeTools>();
|
||||
services.AddSingleton<ScheduleTools>();
|
||||
services.AddSingleton<TimeEntryTools>();
|
||||
services.AddSingleton<LessonPlanTools>();
|
||||
services.AddSingleton<GroupMembershipTools>();
|
||||
services.AddSingleton<LetterTemplateTools>();
|
||||
services.AddSingleton<McpServerHostedService>();
|
||||
services.AddSingleton<McpClientRegistrationService>();
|
||||
|
||||
// ── WebUntis-iCal-Abgleich (optional – nur wenn URL hinterlegt und aktiviert) ─────────
|
||||
var untisSettings = new WebUntisSettingsService(appData);
|
||||
services.AddSingleton(untisSettings);
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="QuestPDF" />
|
||||
<PackageReference Include="ModelContextProtocol.Core" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Baut die Standard-Platzhalterwerte für Elternbrief-Vorlagen (Datum, Anrede, Kontaktadresse,
|
||||
/// Schülerdaten, Brieftext, ...). Ursprünglich Teil von
|
||||
/// <see cref="ViewModels.Students.CreateLetterDialogViewModel"/>, hierher extrahiert, damit
|
||||
/// <c>LetterTemplateTools</c> (MCP, siehe Planungsdokument) dieselbe Logik nutzt statt sie zu
|
||||
/// duplizieren — beide Aufrufer müssen exakt dieselben Platzhalternamen befüllen, sonst driften
|
||||
/// Dialog-generierte und KI-generierte Briefe unbemerkt auseinander.
|
||||
/// </summary>
|
||||
public static class LetterPlaceholderBuilder
|
||||
{
|
||||
public static Dictionary<string, PlaceholderValue> BuildStandardValues(
|
||||
Student student, Contact? contact, LearningGroup? group, DateOnly date,
|
||||
string letterText, string teacherName)
|
||||
{
|
||||
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)));
|
||||
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 ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
public static bool IsEmpty(PlaceholderValue value) => value switch
|
||||
{
|
||||
TextValue x => string.IsNullOrWhiteSpace(x.Value),
|
||||
MultilineValue x => string.IsNullOrWhiteSpace(x.Value),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LehrerApp.Desktop.Views.Shared;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Produktive <see cref="IMcpConfirmationService"/>-Implementierung: zeigt den bestehenden
|
||||
/// <see cref="ConfirmDialog"/> (Views/Shared) über dem Hauptfenster an. Der aufrufende Tool-Handler
|
||||
/// läuft auf einem Hintergrund-Thread (MCP-Pipe-Session in <see cref="McpServerHostedService"/>),
|
||||
/// deshalb Marshalling über <see cref="Dispatcher.UIThread"/>.
|
||||
///
|
||||
/// Ohne Reaktion des Nutzers würde die Pipe-Session (und damit der wartende KI-Client) unbegrenzt
|
||||
/// hängen bleiben — nach <see cref="Timeout"/> wird der Dialog automatisch geschlossen und die
|
||||
/// Änderung als abgelehnt gewertet.
|
||||
/// </summary>
|
||||
public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
|
||||
|
||||
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken ct)
|
||||
{
|
||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
|
||||
return false;
|
||||
|
||||
// Die gesamte Warte-/Timeout-Logik läuft als ein Stück innerhalb des UI-Thread-Callbacks:
|
||||
// Avalonias Dispatcher-Synchronisationskontext sorgt dafür, dass die Fortsetzung nach
|
||||
// "await Task.WhenAny(...)" wieder auf dem UI-Thread läuft, sodass dialog.Close() dort
|
||||
// sicher aufgerufen werden kann.
|
||||
return await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var dialog = new ConfirmDialog
|
||||
{
|
||||
DataContext = new ConfirmDialogInfo { Title = title, Message = message, ConfirmText = "Übernehmen" },
|
||||
};
|
||||
var dialogTask = dialog.ShowDialog<bool>(owner);
|
||||
var timeoutTask = Task.Delay(Timeout, ct);
|
||||
var completed = await Task.WhenAny(dialogTask, timeoutTask);
|
||||
if (completed != dialogTask)
|
||||
{
|
||||
dialog.Close(false);
|
||||
return false;
|
||||
}
|
||||
return await dialogTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace LehrerApp.Desktop.Services.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Zeigt dem Nutzer eine über MCP vorgeschlagene Änderung an, bevor sie tatsächlich geschrieben
|
||||
/// wird — Sicherheitsmodell aus dem Planungsdokument: "Tool-Aufruf erzeugt einen Vorschlag/Diff,
|
||||
/// der im Avalonia-Client als Bestätigungsdialog angezeigt wird, [...] kein 'silent write' durch
|
||||
/// das Modell." Als Interface gehalten, damit Write-Tool-Tests ohne echtes UI laufen können (siehe
|
||||
/// <see cref="AvaloniaMcpConfirmationService"/> für die produktive Implementierung).
|
||||
/// </summary>
|
||||
public interface IMcpConfirmationService
|
||||
{
|
||||
/// <returns>true, wenn der Nutzer bestätigt hat; false bei Ablehnung, Timeout oder falls kein
|
||||
/// Hauptfenster verfügbar ist (z.B. während des DB-Passwort-Prompts beim Start).</returns>
|
||||
Task<bool> ConfirmAsync(string title, string message, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp;
|
||||
|
||||
public record McpRegistrationResult(bool Success, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Trägt LehrerApp.McpBridge in die MCP-Server-Konfiguration von Claude Desktop ein (siehe
|
||||
/// Planungsdokument, "Registrierungs-Workflow"). Bewusst nur für Claude Desktop: das ist der einzige
|
||||
/// KI-Client, den die Spec konkret benennt und der eine dokumentierte, stabile Config-Datei-Konvention
|
||||
/// hat (<c>mcpServers</c>-Objekt in <c>claude_desktop_config.json</c>) — andere lokale Clients
|
||||
/// (Ollama, LM Studio, ...) haben keine einheitliche Konvention, die sich hier ohne Rätselraten
|
||||
/// unterstützen ließe.
|
||||
///
|
||||
/// Läuft nur auf explizite Nutzeraktion (Button in den Einstellungen), nie automatisch beim
|
||||
/// App-Start — das Schreiben in die Konfigurationsdatei eines fremden Programms ist ein sichtbarer
|
||||
/// externer Seiteneffekt und gehört nicht in den normalen Startpfad.
|
||||
///
|
||||
/// Setzt eine gepackte Installation voraus (Bridge liegt neben der Hauptapp-Executable, siehe
|
||||
/// build-macos-app.sh) — bei einem lokalen "dotnet build/run" liegt die Bridge in ihrem eigenen
|
||||
/// separaten bin-Ordner, <see cref="ResolveBridgePath"/> findet sie dann nicht.
|
||||
/// </summary>
|
||||
public class McpClientRegistrationService
|
||||
{
|
||||
private const string ServerName = "lehrerapp";
|
||||
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
|
||||
private readonly string? _bridgePath;
|
||||
|
||||
public McpClientRegistrationService() : this(ResolveConfigPath(), ResolveBridgePath()) { }
|
||||
|
||||
/// <summary>Test-Seam: erlaubt, Konfigurations- und Bridge-Pfad ohne echte
|
||||
/// Sonderordner/Installation vorzugeben (siehe <see cref="ResolveConfigPath"/>/
|
||||
/// <see cref="ResolveBridgePath"/> für das produktive Verhalten).</summary>
|
||||
public McpClientRegistrationService(string? configPath, string? bridgePath)
|
||||
{
|
||||
ConfigPath = configPath;
|
||||
_bridgePath = bridgePath;
|
||||
}
|
||||
|
||||
public string? ConfigPath { get; }
|
||||
|
||||
/// <summary>Ob überhaupt ein Claude-Desktop-Konfigurationsordner existiert — ein Hinweis, ob die
|
||||
/// Anwendung installiert ist, unabhängig davon, ob LehrerApp dort schon eingetragen ist.</summary>
|
||||
public bool IsClaudeDesktopDetected => ConfigPath is not null && File.Exists(ConfigPath);
|
||||
|
||||
public bool IsRegistered() =>
|
||||
TryLoadRoot(out var root, out _) && root["mcpServers"]?[ServerName] is not null;
|
||||
|
||||
public McpRegistrationResult Register()
|
||||
{
|
||||
if (ConfigPath is null)
|
||||
return new(false, "Claude Desktop wird auf diesem Betriebssystem nicht unterstützt.");
|
||||
|
||||
var bridgePath = _bridgePath;
|
||||
if (bridgePath is null || !File.Exists(bridgePath))
|
||||
return new(false,
|
||||
$"Bridge-Programm nicht gefunden ({bridgePath ?? "unbekannter Pfad"}). " +
|
||||
"Diese Funktion setzt eine gepackte Installation voraus, nicht einen lokalen Entwicklungs-Build.");
|
||||
|
||||
if (!TryLoadRoot(out var root, out var error))
|
||||
return new(false, error!);
|
||||
|
||||
if (root["mcpServers"] is not JsonObject servers)
|
||||
{
|
||||
servers = new JsonObject();
|
||||
root["mcpServers"] = servers;
|
||||
}
|
||||
servers[ServerName] = new JsonObject
|
||||
{
|
||||
["command"] = bridgePath,
|
||||
["args"] = new JsonArray(),
|
||||
};
|
||||
|
||||
var detectedBefore = IsClaudeDesktopDetected;
|
||||
var result = WriteRoot(root, "In Claude Desktop eingetragen. Claude Desktop neu starten, damit die Änderung wirkt.");
|
||||
if (result.Success && !detectedBefore)
|
||||
return result with
|
||||
{
|
||||
Message = result.Message +
|
||||
" Hinweis: Es wurde keine bestehende Claude-Desktop-Installation erkannt — die Konfiguration " +
|
||||
"greift erst, sobald Claude Desktop installiert ist.",
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
public McpRegistrationResult Unregister()
|
||||
{
|
||||
if (ConfigPath is null || !File.Exists(ConfigPath))
|
||||
return new(true, "Nichts einzutragen gefunden.");
|
||||
if (!TryLoadRoot(out var root, out var error))
|
||||
return new(false, error!);
|
||||
if (root["mcpServers"] is not JsonObject servers || !servers.Remove(ServerName))
|
||||
return new(true, "War nicht eingetragen.");
|
||||
return WriteRoot(root, "Eintrag entfernt.");
|
||||
}
|
||||
|
||||
private bool TryLoadRoot(out JsonObject root, out string? error)
|
||||
{
|
||||
error = null;
|
||||
if (ConfigPath is null || !File.Exists(ConfigPath)) { root = new JsonObject(); return true; }
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(File.ReadAllText(ConfigPath)) as JsonObject ?? new JsonObject();
|
||||
return true;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
// Nicht raten und überschreiben - eine kaputte fremde Konfigurationsdatei bleibt unangetastet,
|
||||
// der Nutzer bekommt stattdessen eine klare Fehlermeldung mit Pfad.
|
||||
root = new JsonObject();
|
||||
error = $"Bestehende Claude-Desktop-Konfiguration ist kein gültiges JSON ({ex.Message}). " +
|
||||
$"Bitte manuell prüfen: {ConfigPath}";
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Z.B. gesperrt, weil Claude Desktop selbst gerade schreibt - ebenfalls nicht überschreiben.
|
||||
root = new JsonObject();
|
||||
error = $"Bestehende Claude-Desktop-Konfiguration konnte nicht gelesen werden: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private McpRegistrationResult WriteRoot(JsonObject root, string successMessage)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath!)!);
|
||||
File.WriteAllText(ConfigPath!, root.ToJsonString(WriteOptions));
|
||||
return new(true, successMessage);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return new(false, $"Konfiguration konnte nicht geschrieben werden: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveBridgePath()
|
||||
{
|
||||
var bridgeFile = OperatingSystem.IsWindows() ? "LehrerApp.McpBridge.exe" : "LehrerApp.McpBridge";
|
||||
return Path.Combine(AppContext.BaseDirectory, bridgeFile);
|
||||
}
|
||||
|
||||
private static string? ResolveConfigPath()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"Claude", "claude_desktop_config.json");
|
||||
if (OperatingSystem.IsMacOS())
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Library", "Application Support", "Claude", "claude_desktop_config.json");
|
||||
return null; // Linux: kein offizieller Claude-Desktop-Client bekannt.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.IO.Pipes;
|
||||
using LehrerApp.Core.Mcp;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// In-Process-MCP-Server (Phase 1–3, siehe Planungsdokument). Lauscht auf der Named Pipe
|
||||
/// <see cref="McpPipeConstants.PipeName"/> und bedient jede eingehende Verbindung (eine je
|
||||
/// LehrerApp.McpBridge-Instanz) als eigene MCP-Session über <see cref="StreamServerTransport"/> —
|
||||
/// ein <see cref="NamedPipeServerStream"/> ist ein normaler <see cref="Stream"/> und kann direkt
|
||||
/// als Ein-/Ausgabe der Session übergeben werden, ohne eigenes JSON-RPC-Parsing.
|
||||
///
|
||||
/// Nur aktiv, wenn <see cref="McpSettingsService.Enabled"/> — sonst tut <see cref="Start"/> nichts.
|
||||
/// Repositories sind im DI-Container Singletons (siehe AppBootstrapper), deshalb reicht es, die
|
||||
/// Tool-Instanzen und die daraus gebaute <see cref="McpServerOptions"/> einmalig zu bauen und für
|
||||
/// alle Sessions zu teilen.
|
||||
/// </summary>
|
||||
public sealed class McpServerHostedService : IAsyncDisposable
|
||||
{
|
||||
private readonly McpSettingsService _settings;
|
||||
private readonly AppLogger _logger;
|
||||
private readonly McpServerOptions _serverOptions;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _acceptLoop;
|
||||
|
||||
public McpServerHostedService(
|
||||
McpSettingsService settings, AppLogger logger,
|
||||
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
||||
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools)
|
||||
{
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
_serverOptions = BuildServerOptions(
|
||||
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
|
||||
groupMembershipTools, letterTemplateTools);
|
||||
}
|
||||
|
||||
/// <summary>Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls
|
||||
/// bereits gestartet oder in den Einstellungen deaktiviert (dann bleibt keine Pipe offen).</summary>
|
||||
public void Start()
|
||||
{
|
||||
if (!_settings.Enabled || _cts is not null) return;
|
||||
_cts = new CancellationTokenSource();
|
||||
_acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token));
|
||||
_logger.Info("MCP-Server gestartet, lauscht auf Pipe '" + McpPipeConstants.PipeName + "'.");
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var pipe = new NamedPipeServerStream(
|
||||
McpPipeConstants.PipeName, PipeDirection.InOut,
|
||||
NamedPipeServerStream.MaxAllowedServerInstances,
|
||||
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
|
||||
try
|
||||
{
|
||||
await pipe.WaitForConnectionAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await pipe.DisposeAsync();
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("MCP: Fehler beim Warten auf eine Bridge-Verbindung.", ex);
|
||||
await pipe.DisposeAsync();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nicht awaiten: die Accept-Loop muss sofort weiterlaufen, damit mehrere gleichzeitige
|
||||
// Bridge-Instanzen (mehrere KI-Client-Sitzungen) unabhängig bedient werden.
|
||||
_ = RunSessionAsync(pipe, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunSessionAsync(NamedPipeServerStream pipe, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var transport = new StreamServerTransport(pipe, pipe, "LehrerApp");
|
||||
await using var server = McpServer.Create(transport, _serverOptions, loggerFactory: null, serviceProvider: null);
|
||||
await server.RunAsync(ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.Warn($"MCP: Session beendet ({ex.Message}).");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await pipe.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_cts is null) return;
|
||||
await _cts.CancelAsync();
|
||||
if (_acceptLoop is not null)
|
||||
{
|
||||
try { await _acceptLoop; }
|
||||
catch { /* Beenden über Cancellation ist der Normalfall hier */ }
|
||||
}
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
private static McpServerOptions BuildServerOptions(
|
||||
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
||||
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools)
|
||||
{
|
||||
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
|
||||
void AddReadTool(Delegate handler, string name, string description)
|
||||
{
|
||||
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
ReadOnly = true,
|
||||
}));
|
||||
}
|
||||
|
||||
// Write-Tools schreiben nie direkt - jede Handler-Methode ruft selbst erst
|
||||
// IMcpConfirmationService auf (siehe die jeweilige Tool-Klasse). ReadOnly bewusst false,
|
||||
// Destructive bewusst false (keine der Phase-2-Schreiboperationen löscht etwas).
|
||||
void AddWriteTool(Delegate handler, string name, string description)
|
||||
{
|
||||
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
ReadOnly = false,
|
||||
Destructive = false,
|
||||
}));
|
||||
}
|
||||
|
||||
AddReadTool(studentTools.GetStudents, "get_students",
|
||||
"Listet Schüler, optional gefiltert nach Lerngruppe.");
|
||||
AddReadTool(examTools.GetExams, "get_exams",
|
||||
"Listet Klausuren, optional gefiltert nach Lerngruppe.");
|
||||
AddReadTool(gradeTools.GetGrades, "get_grades",
|
||||
"Listet Noten einer Lerngruppe, optional gefiltert auf einen Schüler.");
|
||||
AddReadTool(scheduleTools.GetSchedule, "get_schedule",
|
||||
"Listet Stundenplan-Einträge, optional gefiltert nach Lerngruppe.");
|
||||
AddReadTool(timeEntryTools.GetTimeEntries, "get_time_entries",
|
||||
"Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.");
|
||||
AddReadTool(lessonPlanTools.GetLessonPlans, "get_lesson_plans",
|
||||
"Listet Unterrichtseinheiten und -stunden einer Lerngruppe in einem Zeitraum.");
|
||||
AddReadTool(lessonPlanTools.DownloadLessonAttachment, "download_lesson_attachment",
|
||||
"Lädt den Inhalt eines an eine Einzelstunde angehängten Materials Base64-kodiert herunter.");
|
||||
AddReadTool(letterTemplateTools.ListLetterTemplates, "list_letter_templates",
|
||||
"Listet importierte Elternbrief-Vorlagen mit ihren Platzhaltern.");
|
||||
AddReadTool(letterTemplateTools.RenderLetter, "render_letter",
|
||||
"Erzeugt einen Elternbrief aus einer Vorlage für einen Schüler als Base64-PDF.");
|
||||
|
||||
AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry",
|
||||
"Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(gradeTools.CreateGradeEntry, "create_grade_entry",
|
||||
"Schlägt eine neue Note für einen Schüler vor (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(groupMembershipTools.UpdateStudentGroupAssignment, "update_student_group_assignment",
|
||||
"Legt eine Gruppenmitgliedschaft an oder ändert Niveau/Zeitraum (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(lessonPlanTools.CreateUnit, "create_unit",
|
||||
"Legt eine neue Unterrichtseinheit an (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(lessonPlanTools.UpdateUnit, "update_unit",
|
||||
"Ändert Titel/Zeitraum/Status einer Unterrichtseinheit (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(lessonPlanTools.CreateLesson, "create_lesson",
|
||||
"Legt eine neue Einzelstunde ohne Verlaufsplan an (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(lessonPlanTools.UpdateLesson, "update_lesson",
|
||||
"Ändert Metadaten einer Einzelstunde, ohne den Verlaufsplan anzufassen (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(lessonPlanTools.AddLessonPhase, "add_lesson_phase",
|
||||
"Fügt einer Einzelstunde eine Verlaufsplan-Phase hinzu (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(lessonPlanTools.UpdateLessonPhase, "update_lesson_phase",
|
||||
"Ä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)
|
||||
.SequenceEqual(McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools).OrderBy(n => n)),
|
||||
"Registrierte MCP-Tools weichen von McpToolScope ab.");
|
||||
|
||||
return new McpServerOptions
|
||||
{
|
||||
ServerInfo = new Implementation { Name = "LehrerApp", Version = "1.0.0" },
|
||||
Capabilities = new ServerCapabilities { Tools = new ToolsCapability() },
|
||||
ToolCollection = toolCollection,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace LehrerApp.Desktop.Services.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Allowlist der über MCP exponierten Tool-Namen. Dieselbe Absicherung wie
|
||||
/// LehrerApp.Api/PlainEventStore.cs (dort für den Klartext-Sync-Kanal): Gesprächsnotizen, Vorfälle
|
||||
/// und Förderpläne (Documentation/Vorgang) sind hier bewusst nie aufgeführt und werden von keiner
|
||||
/// Tool-Klasse referenziert — ein KI-Client kann diese Daten technisch nicht erreichen, unabhängig
|
||||
/// davon, wie vertrauenswürdig der lokale Modell-Client erscheint oder wie die Tool-Liste künftig
|
||||
/// wächst. <see cref="McpServerHostedService"/> registriert nur exakt diese Namen.
|
||||
/// </summary>
|
||||
public static class McpToolScope
|
||||
{
|
||||
public static readonly IReadOnlyCollection<string> AllowedReadTools =
|
||||
[
|
||||
"get_students",
|
||||
"get_exams",
|
||||
"get_grades",
|
||||
"get_schedule",
|
||||
"get_time_entries",
|
||||
"get_lesson_plans",
|
||||
"download_lesson_attachment",
|
||||
"list_letter_templates",
|
||||
"render_letter",
|
||||
];
|
||||
|
||||
/// <summary>Write-Tools (Phase 2+3) — jeder Aufruf läuft über <see cref="IMcpConfirmationService"/>,
|
||||
/// bevor irgendetwas geschrieben wird (siehe die jeweilige Tool-Klasse). Absichtlich kleinteilig
|
||||
/// bei Unit/Lesson (create/update_unit, create/update_lesson, add/update/remove_lesson_phase)
|
||||
/// statt eines einzelnen "update_lesson" für die ganze Stunde inkl. Verlaufsplan — siehe
|
||||
/// Begründung in LessonPlanTools.</summary>
|
||||
public static readonly IReadOnlyCollection<string> AllowedWriteTools =
|
||||
[
|
||||
"create_time_entry",
|
||||
"create_grade_entry",
|
||||
"update_student_group_assignment",
|
||||
"create_unit",
|
||||
"update_unit",
|
||||
"create_lesson",
|
||||
"update_lesson",
|
||||
"add_lesson_phase",
|
||||
"update_lesson_phase",
|
||||
"remove_lesson_phase",
|
||||
"add_lesson_attachment",
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
// Schlanke, bewusst nicht 1:1 zu den LiteDB-Entities gehaltene Rückgabetypen: verhindert, dass ein
|
||||
// später zum Modell hinzugefügtes Feld (z.B. ein neues personenbezogenes Attribut) unbeabsichtigt
|
||||
// über ein MCP-Tool nach außen dringt, nur weil es Teil der Entity-Klasse ist.
|
||||
|
||||
public record StudentDto(Guid Id, string FirstName, string LastName, bool IsActive);
|
||||
|
||||
public record ExamResultDto(Guid StudentId, double TotalPoints, string? Grade, bool Absent);
|
||||
|
||||
public record ExamDto(
|
||||
Guid Id, Guid GroupId, string Title, DateOnly Date, ExamStatus Status, Niveau? Niveau,
|
||||
List<ExamResultDto>? Results);
|
||||
|
||||
public record GradeDto(
|
||||
Guid Id, Guid StudentId, Guid GroupId, GradeCategory Category, string Value, DateOnly Date,
|
||||
double Weight, string? Note);
|
||||
|
||||
public record TimetableSlotDto(Guid Id, Guid GroupId, DayOfWeek Weekday, int PeriodNumber, string? Room);
|
||||
|
||||
public record TimeEntryDto(
|
||||
Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date,
|
||||
TimeOnly? StartTime, TimeOnly? EndTime, int DurationMinutes, string? Description);
|
||||
|
||||
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,
|
||||
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,
|
||||
UnitStatus Status, List<string> Competencies);
|
||||
|
||||
public record LessonPlanResultDto(List<UnitDto> Units, List<LessonDto> Lessons);
|
||||
|
||||
public record GroupMembershipDto(
|
||||
Guid Id, Guid StudentId, Guid GroupId, MembershipPeriod Period,
|
||||
DateOnly? JoinedAt, DateOnly? LeftAt, Niveau? Niveau);
|
||||
|
||||
/// <summary>Ergebnis eines Write-Tools (Phase 2, siehe Planungsdokument): <see cref="Applied"/> ist
|
||||
/// nur dann true, wenn der Nutzer die Änderung im Bestätigungsdialog angenommen hat.</summary>
|
||||
public record WriteResultDto(bool Applied, Guid? Id, string Message);
|
||||
|
||||
public record PlaceholderInfoDto(string Name, string Type, bool Required, bool IsConstant);
|
||||
|
||||
/// <summary>"Worksheets" aus der ursprünglichen Spec entsprechen im tatsächlichen Datenmodell den
|
||||
/// importierten Elternbrief-Vorlagen (<c>.lavorlage</c>, <c>LehrerApp.Templating</c>) — es gibt kein
|
||||
/// separates "Arbeitsblatt"-Konzept mit Fach/Klassenstufe-Metadaten. Siehe LetterTemplateTools.</summary>
|
||||
public record LetterTemplateDto(string Id, string Name, string Description, List<PlaceholderInfoDto> Placeholders);
|
||||
|
||||
public record LetterRenderResultDto(bool Success, string Message, string? Base64Pdf, string? SuggestedFileName);
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <summary>MCP-Read-Tool "get_exams" (Phase 1, siehe Planungsdokument).</summary>
|
||||
public class ExamTools(IExamRepository exams, IExamResultRepository examResults)
|
||||
{
|
||||
[Description("Listet Klausuren, optional gefiltert nach Lerngruppe.")]
|
||||
public List<ExamDto> GetExams(
|
||||
[Description("Optionale Lerngruppen-ID zum Filtern.")] Guid? groupId = null,
|
||||
[Description("Ergebnisse je Schüler mitliefern (Standard: nein, hält die Antwort klein).")] bool includeResults = false)
|
||||
{
|
||||
var list = groupId is { } id ? exams.GetByGroup(id) : exams.GetAll();
|
||||
return list.Select(e => new ExamDto(
|
||||
e.Id, e.GroupId, e.Title, e.Date, e.Status, e.Niveau,
|
||||
includeResults
|
||||
? examResults.GetByExam(e.Id)
|
||||
.Select(r => new ExamResultDto(r.StudentId, r.TotalPoints, r.Grade, r.Absent))
|
||||
.ToList()
|
||||
: null)).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <summary>MCP-Tools "get_grades" (Phase 1) und "create_grade_entry" (Phase 2), siehe
|
||||
/// Planungsdokument.</summary>
|
||||
public class GradeTools(IGradeRepository grades, IStudentRepository students, IMcpConfirmationService confirmation)
|
||||
{
|
||||
[Description("Listet Noten einer Lerngruppe, optional gefiltert auf einen einzelnen Schüler.")]
|
||||
public List<GradeDto> GetGrades(
|
||||
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||
[Description("Optionale Schüler-ID zum Filtern auf einen einzelnen Schüler.")] Guid? studentId = null)
|
||||
{
|
||||
var list = studentId is { } sid ? grades.GetByStudentAndGroup(sid, groupId) : grades.GetByGroup(groupId);
|
||||
return list.Select(g => new GradeDto(
|
||||
g.Id, g.StudentId, g.GroupId, g.Category, g.Value, g.Date, g.Weight, g.Note)).ToList();
|
||||
}
|
||||
|
||||
[Description("Schlägt eine neue Note für einen Schüler vor. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen, bevor sie gespeichert wird.")]
|
||||
public async Task<WriteResultDto> CreateGradeEntry(
|
||||
[Description("Schüler-ID.")] Guid studentId,
|
||||
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||
[Description("Kategorie: Oral, Homework, Participation, Project oder Other.")] GradeCategory category,
|
||||
[Description("Notenwert als Text, z.B. \"2+\" oder \"gut\".")] string value,
|
||||
[Description("Datum, Format YYYY-MM-DD.")] DateOnly date,
|
||||
[Description("Gewichtung, Standard 1.0.")] double weight = 1.0,
|
||||
[Description("Optionale Notiz.")] string? note = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var student = students.GetById(studentId);
|
||||
if (student is null)
|
||||
return new WriteResultDto(false, null, "Unbekannte Schüler-ID.");
|
||||
|
||||
var message =
|
||||
$"Neue Note für {student.FullName}: {GradeCategoryDisplayName(category)} = {value}" +
|
||||
(weight != 1.0 ? $" (Gewichtung {weight:0.##})" : "") +
|
||||
$", am {date:dd.MM.yyyy}" +
|
||||
(string.IsNullOrWhiteSpace(note) ? "" : $"\n„{note}“");
|
||||
|
||||
if (!await confirmation.ConfirmAsync("Note anlegen?", message, ct))
|
||||
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||
|
||||
var grade = new Grade
|
||||
{
|
||||
StudentId = studentId,
|
||||
GroupId = groupId,
|
||||
Category = category,
|
||||
Value = value,
|
||||
Date = date,
|
||||
Weight = weight,
|
||||
Note = note,
|
||||
};
|
||||
grades.Save(grade);
|
||||
return new WriteResultDto(true, grade.Id, "Note gespeichert.");
|
||||
}
|
||||
|
||||
// Eigene, schlanke Beschriftung statt Wiederverwendung von ViewModels.Groups.GradeCategoryDisplay:
|
||||
// Tool-Klassen unter Services/Mcp sollen nicht von ViewModel-Klassen abhängen.
|
||||
private static string GradeCategoryDisplayName(GradeCategory c) => c switch
|
||||
{
|
||||
GradeCategory.Oral => "Mündlich",
|
||||
GradeCategory.Homework => "Hausaufgaben",
|
||||
GradeCategory.Participation => "Mitarbeit",
|
||||
GradeCategory.Project => "Projekt",
|
||||
GradeCategory.Other => "Sonstiges",
|
||||
_ => c.ToString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <summary>MCP-Write-Tool "update_student_group_assignment" (Phase 2, siehe Planungsdokument).
|
||||
/// Legt eine <see cref="GroupMembership"/> an, falls noch keine für Schüler+Gruppe existiert, sonst
|
||||
/// werden nur die übergebenen (nicht-null) Felder überschrieben.</summary>
|
||||
public class GroupMembershipTools(
|
||||
IGroupMembershipRepository memberships, IStudentRepository students, IGroupRepository groups,
|
||||
IMcpConfirmationService confirmation)
|
||||
{
|
||||
[Description("Legt eine Gruppenmitgliedschaft eines Schülers an oder ändert Niveau/Zeitraum einer bestehenden. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen.")]
|
||||
public async Task<WriteResultDto> UpdateStudentGroupAssignment(
|
||||
[Description("Schüler-ID.")] Guid studentId,
|
||||
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||
[Description("Niveau: E, G oder Foerder. Unverändert lassen: weglassen.")] Niveau? niveau = null,
|
||||
[Description("Zeitraum: FullYear, H1Only, H2Only oder Custom. Unverändert lassen: weglassen.")] MembershipPeriod? period = null,
|
||||
[Description("Beitrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? joinedAt = null,
|
||||
[Description("Austrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? leftAt = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var student = students.GetById(studentId);
|
||||
var group = groups.GetById(groupId);
|
||||
if (student is null || group is null)
|
||||
return new WriteResultDto(false, null, "Unbekannte Schüler- oder Gruppen-ID.");
|
||||
|
||||
var existing = memberships.GetByStudentAndGroup(studentId, groupId);
|
||||
var target = existing is null
|
||||
? new GroupMembership { StudentId = studentId, GroupId = groupId }
|
||||
: Clone(existing);
|
||||
|
||||
var changes = new StringBuilder();
|
||||
if (niveau is not null && niveau != target.Niveau) { changes.AppendLine($"Niveau: {NiveauName(target.Niveau)} → {NiveauName(niveau)}"); target.Niveau = niveau; }
|
||||
if (period is not null && period != target.Period) { changes.AppendLine($"Zeitraum: {target.Period} → {period}"); target.Period = period.Value; }
|
||||
if (joinedAt is not null && joinedAt != target.JoinedAt) { changes.AppendLine($"Beitritt: {target.JoinedAt:dd.MM.yyyy} → {joinedAt:dd.MM.yyyy}"); target.JoinedAt = joinedAt; }
|
||||
if (leftAt is not null && leftAt != target.LeftAt) { changes.AppendLine($"Austritt: {target.LeftAt:dd.MM.yyyy} → {leftAt:dd.MM.yyyy}"); target.LeftAt = leftAt; }
|
||||
|
||||
if (existing is not null && changes.Length == 0)
|
||||
return new WriteResultDto(true, existing.Id, "Keine Änderung nötig, Mitgliedschaft besteht bereits unverändert.");
|
||||
|
||||
var title = existing is null ? "Gruppenmitgliedschaft anlegen?" : "Gruppenmitgliedschaft ändern?";
|
||||
var message = $"{student.FullName} — {group.Name}" +
|
||||
(existing is null ? "\nNeue Mitgliedschaft anlegen." : "") +
|
||||
(changes.Length > 0 ? "\n" + changes.ToString().TrimEnd() : "");
|
||||
|
||||
if (!await confirmation.ConfirmAsync(title, message, ct))
|
||||
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||
|
||||
memberships.Save(target);
|
||||
return new WriteResultDto(true, target.Id, "Gruppenmitgliedschaft gespeichert.");
|
||||
}
|
||||
|
||||
private static GroupMembership Clone(GroupMembership m) => new()
|
||||
{
|
||||
Id = m.Id, StudentId = m.StudentId, GroupId = m.GroupId, AddedOn = m.AddedOn,
|
||||
Period = m.Period, JoinedAt = m.JoinedAt, LeftAt = m.LeftAt, Niveau = m.Niveau,
|
||||
};
|
||||
|
||||
private static string NiveauName(Niveau? n) => n switch
|
||||
{
|
||||
Core.Models.Niveau.E => "E-Niveau",
|
||||
Core.Models.Niveau.G => "G-Niveau",
|
||||
Core.Models.Niveau.Foerder => "Förderniveau",
|
||||
_ => "–",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <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,
|
||||
[Description("Startdatum (einschließlich) für die Einzelstunden, Format YYYY-MM-DD.")] DateOnly from,
|
||||
[Description("Enddatum (einschließlich) für die Einzelstunden, Format YYYY-MM-DD.")] DateOnly to)
|
||||
{
|
||||
var unitDtos = units.GetByGroup(groupId)
|
||||
.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(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()));
|
||||
}
|
||||
|
||||
[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) ──────────────────────────────────────────────────────────
|
||||
|
||||
[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());
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <summary>MCP-Tools "list_letter_templates"/"render_letter" (siehe Planungsdokument, Abschnitt
|
||||
/// "Worksheets"). Die ursprüngliche Spec sah "Arbeitsblätter" mit Fach-/Klassenstufe-Metadaten vor —
|
||||
/// das existiert im Datenmodell nicht. Was tatsächlich existiert: importierte
|
||||
/// <c>.lavorlage</c>-Elternbrief-Vorlagen (<see cref="TemplateStore"/>), an Schüler+Kontakt
|
||||
/// gebunden, exakt wie im bestehenden "Elternbrief"-Dialog
|
||||
/// (<c>CreateLetterDialogViewModel</c>). Bewusst NICHT umgesetzt: "upload_worksheet"/
|
||||
/// "update_worksheet" im Sinne von "eine neue Vorlage per KI entwerfen" — das Seitenlayout ist eine
|
||||
/// eigene, positionsbasierte DSL (siehe LehrerApp.Templating/LayoutParser.cs), für die es absichtlich
|
||||
/// einen eigenen visuellen Editor (LehrerApp.TemplateDesigner) gibt; ein LLM würde diese DSL blind
|
||||
/// erzeugen müssen, mit hohem Risiko für unbrauchbare/kaputte Layouts. "render_letter" deckt den
|
||||
/// tatsächlich nützlichen Fall ab: eine bestehende, vom Nutzer gestaltete Vorlage mit Werten füllen.</summary>
|
||||
public class LetterTemplateTools(
|
||||
TemplateStore templates, ITemplateLoader loader, ITemplateRenderer renderer,
|
||||
IStudentRepository students, IGroupRepository groups)
|
||||
{
|
||||
[Description("Listet importierte Elternbrief-Vorlagen mit ihren deklarierten Platzhaltern (Name, Typ, Pflichtfeld, konstant).")]
|
||||
public List<LetterTemplateDto> ListLetterTemplates()
|
||||
{
|
||||
var result = new List<LetterTemplateDto>();
|
||||
foreach (var installed in templates.GetTemplates())
|
||||
{
|
||||
try
|
||||
{
|
||||
var loaded = templates.Load(installed);
|
||||
result.Add(new LetterTemplateDto(installed.Id, installed.Name, installed.Description,
|
||||
loaded.Manifest.Placeholders
|
||||
.Select(p => new PlaceholderInfoDto(p.Name, p.Type.ToString(), p.Required, p.IsConstant))
|
||||
.ToList()));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
|
||||
{
|
||||
// Beschädigtes/ungültiges Paket - wie TemplateStore.GetTemplates() selbst überspringt
|
||||
// auch diese Auflistung es kommentarlos, statt die ganze Liste scheitern zu lassen.
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
[Description("Erzeugt einen Elternbrief aus einer importierten Vorlage für einen Schüler und liefert ihn als Base64-PDF. Reiner Lesezugriff (kein Datenbank-Schreibzugriff), deshalb ohne Bestätigungsdialog.")]
|
||||
public LetterRenderResultDto RenderLetter(
|
||||
[Description("Vorlagen-ID, aus list_letter_templates.")] string templateId,
|
||||
[Description("Schüler-ID.")] Guid studentId,
|
||||
[Description("Brieftext (Fließtext).")] string letterText,
|
||||
[Description("Name der unterschreibenden Lehrkraft.")] string teacherName,
|
||||
[Description("Optionale Kontakt-ID des Schülers. Ohne Angabe wird der erste gültige Kontakt verwendet.")] Guid? contactId = null,
|
||||
[Description("Optionale Lerngruppen-ID für Group.Name/SchoolYear-Platzhalter.")] Guid? groupId = null,
|
||||
[Description("Briefdatum, Format YYYY-MM-DD. Standard: heute.")] DateOnly? letterDate = null,
|
||||
[Description("Zusätzliche, von der Vorlage deklarierte Platzhalterwerte über die Standardfelder hinaus (Name -> Text/Zahl/Datum als Text).")]
|
||||
Dictionary<string, string>? extraValues = null)
|
||||
{
|
||||
var student = students.GetById(studentId);
|
||||
if (student is null) return new LetterRenderResultDto(false, "Unbekannte Schüler-ID.", null, null);
|
||||
|
||||
var installed = templates.GetTemplates().FirstOrDefault(t => t.Id == templateId);
|
||||
if (installed is null) return new LetterRenderResultDto(false, "Unbekannte Vorlagen-ID.", null, null);
|
||||
|
||||
LoadedTemplate loaded;
|
||||
try { loaded = templates.Load(installed); }
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
|
||||
{ return new LetterRenderResultDto(false, $"Vorlage ist ungültig: {ex.Message}", null, null); }
|
||||
|
||||
var contact = contactId is { } cid
|
||||
? student.Contacts.FirstOrDefault(c => c.Id == cid)
|
||||
: student.Contacts.FirstOrDefault(c => !c.InvalidSince.HasValue);
|
||||
var group = groupId is { } gid ? groups.GetById(gid) : null;
|
||||
var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText, teacherName);
|
||||
foreach (var (name, raw) in extraValues ?? [])
|
||||
{
|
||||
var definition = loaded.Manifest.Placeholders.FirstOrDefault(p => p.Name == name);
|
||||
if (definition is null || definition.IsConstant) continue;
|
||||
if (TryConvert(definition.Type, raw) is { } converted) values[name] = converted;
|
||||
}
|
||||
var resolved = TemplateDataResolver.Resolve(loaded.Manifest, values);
|
||||
|
||||
var validation = loader.Validate(loaded, resolved.ToDictionary(x => x.Key, x => x.Value.Type));
|
||||
var missing = loaded.Manifest.Placeholders
|
||||
.Where(p => !p.IsConstant && p.Required &&
|
||||
(!resolved.TryGetValue(p.Name, out var v) || LetterPlaceholderBuilder.IsEmpty(v)))
|
||||
.Select(p => p.Name).ToList();
|
||||
if (!validation.IsValid || missing.Count > 0)
|
||||
{
|
||||
var issues = validation.Issues.Select(i => i.Message)
|
||||
.Concat(missing.Select(m => $"Pflichtfeld „{m}“ fehlt."));
|
||||
return new LetterRenderResultDto(false,
|
||||
"Vorlage kann mit diesen Werten nicht gefüllt werden: " + string.Join(" ", issues), null, null);
|
||||
}
|
||||
|
||||
byte[] pdf;
|
||||
try { pdf = renderer.RenderToPdf(loaded, new DictionaryDataProvider(resolved)); }
|
||||
catch (Exception ex) when (ex is InvalidDataException or TemplateValidationException)
|
||||
{ return new LetterRenderResultDto(false, $"Brief konnte nicht erzeugt werden: {ex.Message}", null, null); }
|
||||
|
||||
var fileName = SanitizeFileName($"{installed.Name}_{student.LastName}_{student.FirstName}.pdf");
|
||||
return new LetterRenderResultDto(true, "Brief erzeugt.", Convert.ToBase64String(pdf), fileName);
|
||||
}
|
||||
|
||||
private static PlaceholderValue? TryConvert(PlaceholderType type, string raw) => type switch
|
||||
{
|
||||
PlaceholderType.Text => new TextValue(raw),
|
||||
PlaceholderType.Multiline => new MultilineValue(raw),
|
||||
PlaceholderType.Date => TryParseDate(raw) is { } date ? new DateValue(date) : null,
|
||||
PlaceholderType.Number => TryParseNumber(raw) is { } number ? new NumberValue(number) : null,
|
||||
// Image/Table/Chart/Drawing sind über MCP-Textparameter nicht sinnvoll setzbar.
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static DateOnly? TryParseDate(string raw) =>
|
||||
DateOnly.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ||
|
||||
DateOnly.TryParse(raw, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out d) ? d : null;
|
||||
|
||||
private static decimal? TryParseNumber(string raw) =>
|
||||
decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out var n) ||
|
||||
decimal.TryParse(raw, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out n) ? n : null;
|
||||
|
||||
private static string SanitizeFileName(string value)
|
||||
{
|
||||
foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_');
|
||||
return value;
|
||||
}
|
||||
|
||||
private sealed class DictionaryDataProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||
{
|
||||
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <summary>MCP-Read-Tool "get_schedule" (Phase 1, siehe Planungsdokument).</summary>
|
||||
public class ScheduleTools(ITimetableSlotRepository slots)
|
||||
{
|
||||
[Description("Listet Stundenplan-Einträge (Wochenraster), optional gefiltert nach Lerngruppe.")]
|
||||
public List<TimetableSlotDto> GetSchedule(
|
||||
[Description("Optionale Lerngruppen-ID zum Filtern.")] Guid? groupId = null)
|
||||
{
|
||||
var list = groupId is { } id ? slots.GetByGroup(id) : slots.GetAll();
|
||||
return list.Select(s => new TimetableSlotDto(s.Id, s.GroupId, s.Weekday, s.PeriodNumber, s.Room)).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <summary>MCP-Read-Tool "get_students" (Phase 1, siehe Planungsdokument). Reine Lesezugriffe auf
|
||||
/// die bestehenden Repositories, keine eigene Datenzugriffslogik.</summary>
|
||||
public class StudentTools(IStudentRepository students)
|
||||
{
|
||||
[Description("Listet Schüler, optional gefiltert nach Lerngruppe. Enthält standardmäßig nur aktive Schüler.")]
|
||||
public List<StudentDto> GetStudents(
|
||||
[Description("Optionale Lerngruppen-ID zum Filtern.")] Guid? groupId = null,
|
||||
[Description("Auch inaktive/ausgeschiedene Schüler einbeziehen.")] bool includeInactive = false)
|
||||
{
|
||||
var list = groupId is { } id ? students.GetByGroup(id) : students.GetAll(includeInactive);
|
||||
if (groupId is not null && !includeInactive)
|
||||
list = list.Where(s => s.IsActive).ToList();
|
||||
return list.Select(s => new StudentDto(s.Id, s.FirstName, s.LastName, s.IsActive)).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <summary>MCP-Tools "get_time_entries" (Phase 1) und "create_time_entry" (Phase 2), siehe
|
||||
/// Planungsdokument. Der Zeitraum bei "get_time_entries" ist Pflicht (nicht optional), damit eine
|
||||
/// unbedachte Anfrage nicht die gesamte Zeiterfassungshistorie zurückgibt.</summary>
|
||||
public class TimeEntryTools(ITimeEntryRepository timeEntries, IGroupRepository groups, IMcpConfirmationService confirmation)
|
||||
{
|
||||
[Description("Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.")]
|
||||
public List<TimeEntryDto> GetTimeEntries(
|
||||
[Description("Startdatum (einschließlich), Format YYYY-MM-DD.")] DateOnly from,
|
||||
[Description("Enddatum (einschließlich), Format YYYY-MM-DD.")] DateOnly to) =>
|
||||
timeEntries.GetByDateRange(from, to).Select(t => new TimeEntryDto(
|
||||
t.Id, t.TaskId, t.Category, t.GroupId, t.Date, t.StartTime, t.EndTime,
|
||||
t.DurationMinutes, t.Description)).ToList();
|
||||
|
||||
[Description("Schlägt einen neuen Zeiterfassungs-Eintrag vor. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen, bevor er gespeichert wird.")]
|
||||
public async Task<WriteResultDto> CreateTimeEntry(
|
||||
[Description("Kategorie, z.B. \"Unterricht\", \"Korrektur\", \"Vorbereitung\".")] string category,
|
||||
[Description("Datum, Format YYYY-MM-DD.")] DateOnly date,
|
||||
[Description("Dauer in Minuten.")] int durationMinutes,
|
||||
[Description("Optionale Lerngruppen-ID.")] Guid? groupId = null,
|
||||
[Description("Optionale Beschreibung.")] string? description = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var groupName = groupId is { } gid ? groups.GetById(gid)?.Name : null;
|
||||
var message =
|
||||
$"Neuer Zeiteintrag: {category}, {durationMinutes} Min. am {date:dd.MM.yyyy}" +
|
||||
(groupName is not null ? $", Gruppe {groupName}" : "") +
|
||||
(string.IsNullOrWhiteSpace(description) ? "" : $"\n„{description}“");
|
||||
|
||||
if (!await confirmation.ConfirmAsync("Zeiteintrag anlegen?", message, ct))
|
||||
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||
|
||||
var entry = new TimeEntry
|
||||
{
|
||||
Category = category,
|
||||
Date = date,
|
||||
DurationMinutes = durationMinutes,
|
||||
GroupId = groupId,
|
||||
Description = description,
|
||||
};
|
||||
timeEntries.Save(entry);
|
||||
return new WriteResultDto(true, entry.Id, "Zeiteintrag gespeichert.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
internal class McpSettingsConfig
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opt-in-Schalter für den lokalen MCP-Server (siehe Planungsdokument, Phase 1). Anders als
|
||||
/// <see cref="AiSettingsService"/> braucht Phase 1 kein Login/Token — die Named Pipe selbst ist die
|
||||
/// Vertrauensgrenze (lokaler Prozess, gleiche Windows-Session bzw. Unix-Dateirechte), siehe
|
||||
/// Begründung im Planungsdokument.
|
||||
/// </summary>
|
||||
public class McpSettingsService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
private McpSettingsConfig _config;
|
||||
|
||||
public bool Enabled => _config.Enabled;
|
||||
|
||||
public McpSettingsService(string appDataPath)
|
||||
{
|
||||
_configPath = Path.Combine(appDataPath, "mcp-settings.json");
|
||||
_config = Load();
|
||||
}
|
||||
|
||||
public void SetEnabled(bool enabled)
|
||||
{
|
||||
_config.Enabled = enabled;
|
||||
Save();
|
||||
}
|
||||
|
||||
private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
|
||||
|
||||
private McpSettingsConfig Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_configPath))
|
||||
return JsonSerializer.Deserialize<McpSettingsConfig>(File.ReadAllText(_configPath))
|
||||
?? new McpSettingsConfig();
|
||||
}
|
||||
catch { /* beschädigte Konfiguration -> Standardwert */ }
|
||||
return new McpSettingsConfig();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.Services.Mcp;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class SettingsViewModel
|
||||
{
|
||||
// ── MCP-Server (lokal, Phase 1) ──────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private bool _mcpEnabled;
|
||||
|
||||
private void LoadMcpSettings() => McpEnabled = _mcpSettings.Enabled;
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ public enum SettingsTab
|
||||
WebUntis = 13,
|
||||
Appearance = 14,
|
||||
Trash = 15,
|
||||
Mcp = 16,
|
||||
}
|
||||
|
||||
// ── Haupt-ViewModel ───────────────────────────────────────────────────────────
|
||||
@@ -73,6 +74,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
private readonly ISupervisionDutyRepository _supervisionDuties;
|
||||
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;
|
||||
@@ -99,7 +102,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
||||
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
||||
ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates,
|
||||
AiSettingsService aiSettings, AiPlanningService aiPlanning,
|
||||
AiSettingsService aiSettings, AiPlanningService aiPlanning, McpSettingsService mcpSettings,
|
||||
Services.Mcp.McpClientRegistrationService mcpRegistration,
|
||||
WebUntisSettingsService untisSettings,
|
||||
AnnualPlanSettingsService annualPlanSettings,
|
||||
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
|
||||
@@ -137,6 +141,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
_letterTemplates = letterTemplates;
|
||||
_aiSettings = aiSettings;
|
||||
_aiPlanning = aiPlanning;
|
||||
_mcpSettings = mcpSettings;
|
||||
_mcpRegistration = mcpRegistration;
|
||||
_untisSettings = untisSettings;
|
||||
_untisIntegration = untisIntegration;
|
||||
_untisSync = untisSync;
|
||||
@@ -164,6 +170,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
LoadSupervisionDuties();
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -1142,6 +1142,37 @@
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
<!-- Tab: MCP-Server (lokal) -->
|
||||
<ContentPage Header="MCP-Server">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="460">
|
||||
|
||||
<TextBlock Text="MCP-Server" FontSize="16" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="Erlaubt einem lokalen KI-Client (z.B. Claude Desktop) strukturierten Zugriff auf Schüler, Klausuren, Noten, Stundenplan, Zeiterfassung, Unterrichtsplanung und Elternbrief-Vorlagen dieses Geräts über eine lokale Named Pipe. Es verlassen keine Daten das Gerät; Gesprächsnotizen, Vorfälle und Förderpläne sind nicht erreichbar. Schreibende Aktionen zeigen immer erst einen Bestätigungsdialog, bevor etwas gespeichert wird. Eine Änderung hier wirkt erst nach einem Neustart der App."/>
|
||||
|
||||
<CheckBox Content="MCP-Server aktivieren" IsChecked="{Binding McpEnabled}"/>
|
||||
|
||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderThickness="0,1,0,0" Margin="0,6,0,0" Padding="0,14,0,0">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Claude Desktop" FontSize="14" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="Trägt den Pfad zur MCP-Bridge dieser Installation in die Claude-Desktop-Konfiguration ein, damit Claude Desktop LehrerApp automatisch als MCP-Server findet. Funktioniert nur bei einer gepackten Installation, nicht bei einem lokalen Entwicklungs-Build."/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Bei Claude Desktop eintragen" Command="{Binding RegisterWithClaudeDesktopCommand}"
|
||||
IsVisible="{Binding !McpIsRegisteredWithClaude}"/>
|
||||
<Button Content="Eintrag entfernen" Command="{Binding UnregisterFromClaudeDesktopCommand}"
|
||||
IsVisible="{Binding McpIsRegisteredWithClaude}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding McpRegistrationStatus}" FontSize="12" TextWrapping="Wrap"
|
||||
IsVisible="{Binding McpRegistrationStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
</TabbedPage>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using LehrerApp.Core.Mcp;
|
||||
|
||||
// Zustandsloser Bridge-Prozess: reicht die vom KI-Client über stdin/stdout gesprochene
|
||||
// JSON-RPC-Verbindung unverändert an die Named Pipe des laufenden LehrerApp-Hauptprozesses durch.
|
||||
// Kein eigenes JSON-RPC-Verständnis nötig, außer im Fehlerfall (siehe unten) — stdout ist
|
||||
// ausschließlich für den durchgereichten Protokollstrom reserviert, jede Diagnose geht nach stderr.
|
||||
|
||||
const int ConnectTimeoutMs = 3000;
|
||||
|
||||
await using var pipe = new NamedPipeClientStream(
|
||||
".", McpPipeConstants.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
|
||||
try
|
||||
{
|
||||
await pipe.ConnectAsync(ConnectTimeoutMs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Console.Error.WriteLineAsync(
|
||||
$"LehrerApp.McpBridge: Verbindung zu LehrerApp fehlgeschlagen ({ex.Message}). " +
|
||||
"Läuft die App und ist der MCP-Server in den Einstellungen aktiviert?");
|
||||
await RespondWithConnectionErrorAsync();
|
||||
Environment.Exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
await Console.Error.WriteLineAsync("LehrerApp.McpBridge: verbunden.");
|
||||
|
||||
await using var stdin = Console.OpenStandardInput();
|
||||
await using var stdout = Console.OpenStandardOutput();
|
||||
|
||||
var toApp = stdin.CopyToAsync(pipe);
|
||||
var toClient = pipe.CopyToAsync(stdout);
|
||||
|
||||
// Sobald eine Richtung endet (App beendet die Verbindung, oder der KI-Client schließt stdin),
|
||||
// ist die Session vorbei — die andere Kopie hängt sonst an einem offenen Handle.
|
||||
await Task.WhenAny(toApp, toClient);
|
||||
|
||||
// Der Verbindungsaufbau ist fehlgeschlagen, bevor irgendetwas an die App durchgereicht wurde. Der
|
||||
// MCP-Client wartet zu diesem Zeitpunkt bereits auf eine Antwort auf seine erste Anfrage
|
||||
// ("initialize") — eine korrekte JSON-RPC-Fehlerantwort braucht deren "id", also wird diese eine
|
||||
// Zeile noch selbst gelesen und beantwortet, statt den Prozess kommentarlos zu beenden.
|
||||
static async Task RespondWithConnectionErrorAsync()
|
||||
{
|
||||
string? line;
|
||||
try
|
||||
{
|
||||
line = await Console.In.ReadLineAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(line)) return;
|
||||
|
||||
JsonNode? requestId = null;
|
||||
try
|
||||
{
|
||||
requestId = JsonNode.Parse(line)?["id"];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Keine gültige JSON-RPC-Nachricht - ohne "id" ist keine korrekte Fehlerantwort möglich,
|
||||
// der Prozess beendet sich dann einfach mit einem Fehler-Exitcode.
|
||||
return;
|
||||
}
|
||||
if (requestId is null) return;
|
||||
|
||||
var response = new JsonObject
|
||||
{
|
||||
["jsonrpc"] = "2.0",
|
||||
["id"] = requestId.DeepClone(),
|
||||
["error"] = new JsonObject
|
||||
{
|
||||
["code"] = -32001,
|
||||
["message"] = "LehrerApp läuft nicht oder der MCP-Server ist in den Einstellungen nicht aktiviert.",
|
||||
},
|
||||
};
|
||||
await Console.Out.WriteLineAsync(response.ToJsonString());
|
||||
await Console.Out.FlushAsync();
|
||||
}
|
||||
+54
-40
@@ -32,6 +32,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Templating.Tests"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.TemplateDesigner.Tests", "LehrerApp.TemplateDesigner.Tests\LehrerApp.TemplateDesigner.Tests.csproj", "{B2000004-0000-0000-0000-000000000004}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.McpBridge", "LehrerApp.McpBridge\LehrerApp.McpBridge.csproj", "{4468C960-EE77-4116-A881-B0BDA0ABDC39}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -42,22 +44,6 @@ Global
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2000002-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2000002-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2000002-0000-0000-0000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2000002-0000-0000-0000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2000003-0000-0000-0000-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2000003-0000-0000-0000-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2000003-0000-0000-0000-000000000003}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2000003-0000-0000-0000-000000000003}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2000004-0000-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2000004-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2000004-0000-0000-0000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2000004-0000-0000-0000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
@@ -94,6 +80,30 @@ Global
|
||||
{A1000003-0000-0000-0000-000000000003}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000003-0000-0000-0000-000000000003}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A1000003-0000-0000-0000-000000000003}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A1000004-0000-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1000004-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1000004-0000-0000-0000-000000000004}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
@@ -178,30 +188,34 @@ Global
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A1000007-0000-0000-0000-000000000007}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A1000008-0000-0000-0000-000000000008}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2000002-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2000002-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2000002-0000-0000-0000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2000002-0000-0000-0000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2000003-0000-0000-0000-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2000003-0000-0000-0000-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2000003-0000-0000-0000-000000000003}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2000003-0000-0000-0000-000000000003}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2000004-0000-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2000004-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2000004-0000-0000-0000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2000004-0000-0000-0000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4468C960-EE77-4116-A881-B0BDA0ABDC39}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4468C960-EE77-4116-A881-B0BDA0ABDC39}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4468C960-EE77-4116-A881-B0BDA0ABDC39}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4468C960-EE77-4116-A881-B0BDA0ABDC39}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4468C960-EE77-4116-A881-B0BDA0ABDC39}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4468C960-EE77-4116-A881-B0BDA0ABDC39}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4468C960-EE77-4116-A881-B0BDA0ABDC39}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -2461,6 +2461,195 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
|
||||
`WeekCellItem.Date` — letzteres ist nur bei Kopfzeilen gesetzt, nicht bei
|
||||
Stunden-Kacheln (führte im ersten Testlauf zu einem Bug: die Automatik griff nie).
|
||||
|
||||
- [x] **4.5.25** Lokaler MCP-Server, Phase 1 (Infrastruktur + Read-Tools), 2026-09-11: erlaubt einem
|
||||
lokalen KI-Client (z.B. Claude Desktop) strukturierten Lesezugriff auf Schüler, Klausuren,
|
||||
Noten, Stundenplan und Zeiterfassung — analog zur bestehenden Regel "LLM nur für Intent, nie
|
||||
für Geometrie" gilt hier "LLM nur für Anfrage/Absicht, nie für direkten Datenbankzugriff":
|
||||
alle Zugriffe laufen über typisierte Tools, kein freier Query-Zugriff.
|
||||
- **Architektur:** neues, eigenständiges `LehrerApp.McpBridge`-Projekt (Konsolenprozess, vom
|
||||
KI-Client per stdio gestartet) reicht JSON-RPC-Nachrichten zeilenweise unverändert über
|
||||
eine Named Pipe (`LehrerApp.Core/Mcp/McpPipeConstants.cs`) an einen In-Process-MCP-Server im
|
||||
laufenden Avalonia-Hauptprozess durch
|
||||
([McpServerHostedService.cs](LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs)).
|
||||
Grund: LiteDB ist Single-Writer/Embedded ohne Cross-Prozess-Benachrichtigung — ein
|
||||
separater Prozess mit eigener DB-Verbindung hätte Stale-Reads gegenüber der laufenden GUI
|
||||
riskiert. Nutzt `ModelContextProtocol.Core` (`StreamServerTransport` direkt auf dem
|
||||
`NamedPipeServerStream`, kein eigenes JSON-RPC-Parsing nötig) statt eines
|
||||
selbstgebauten Protokolls.
|
||||
- **Tools (5, alle Read-only):** `get_students`, `get_exams`, `get_grades`, `get_schedule`,
|
||||
`get_time_entries` — je eine schlanke Tool-Klasse unter
|
||||
[Services/Mcp/Tools/](LehrerApp.Desktop/Services/Mcp/Tools/), DTOs statt direkt
|
||||
serialisierter LiteDB-Entities. `McpToolScope.cs` dokumentiert die Allowlist der
|
||||
exponierten Tool-Namen nach demselben Muster wie `PlainEventStore.Allowed` (Klartext-
|
||||
Sync-Kanal) — Gesprächsnotizen/Vorfälle/Förderpläne (`Documentation`/`Vorgang`) werden von
|
||||
keiner Tool-Klasse referenziert und sind damit technisch nie erreichbar, nicht nur per
|
||||
Konvention.
|
||||
- **Opt-in:** standardmäßig deaktiviert, `McpSettingsService` + Checkbox im neuen
|
||||
Einstellungen-Tab "MCP-Server" (`SettingsViewModel.McpSettings.cs`). Kein Token/Login nötig
|
||||
(anders als bei 4.5.9) — die Named Pipe selbst ist die Vertrauensgrenze (lokaler Prozess,
|
||||
gleiche Windows-Session bzw. Unix-Dateirechte). Wirkt erst nach Neustart der App (kein
|
||||
Live-Reload des Pipe-Listeners).
|
||||
- **Verifiziert:** End-to-End-Smoke-Test (Bridge-Prozess als echter Kindprozess, `initialize`
|
||||
→ `tools/list` → `tools/call get_students` über die reale Named Pipe) bestätigt die volle
|
||||
Kette; 9 Unit-Tests für Tool-Filterlogik und die Scope-Allowlist in
|
||||
[McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs).
|
||||
- **Bewusst zurückgestellt (spätere Phasen):** Write-Tools samt Bestätigungsdialog-UI
|
||||
(folgt in 4.5.26), `get_lesson_plans` (folgt in 4.5.26), Worksheets-Tools
|
||||
(`list_worksheets`/`download_worksheet`/...), macOS-Bundle-Signierung der Bridge-Binary,
|
||||
Named-Pipe-basierte Einzelinstanz-Absicherung (die Spec-Idee dazu funktioniert nicht, da
|
||||
die Pipe nur bei aktiviertem Opt-in existiert — LehrerApp hat ohnehin noch keinen
|
||||
Single-Instance-Mechanismus, unabhängig von MCP).
|
||||
|
||||
- [x] **4.5.26** Lokaler MCP-Server, Phase 2 (Write-Tools + Bestätigungsdialog + Lesson-Plans),
|
||||
2026-09-11, direkte Fortsetzung von 4.5.25.
|
||||
- **Bestätigungsdialog:** `IMcpConfirmationService`/`AvaloniaMcpConfirmationService`
|
||||
([Services/Mcp/AvaloniaMcpConfirmationService.cs](LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs))
|
||||
zeigt den bereits vorhandenen `ConfirmDialog` (Views/Shared, bisher nur intern genutzt) über
|
||||
`Dispatcher.UIThread.InvokeAsync` an, obwohl der aufrufende Tool-Handler auf dem
|
||||
Pipe-Session-Hintergrund-Thread läuft — "kein silent write": ein Write-Tool schreibt nie,
|
||||
ohne dass der Nutzer den konkreten (menschenlesbaren, nicht bloß JSON/GUIDs) Vorschlag
|
||||
gesehen und bestätigt hat. 2 Minuten Timeout (schließt den Dialog automatisch und wertet als
|
||||
abgelehnt), damit eine hängende Pipe-Session nicht unbegrenzt den wartenden KI-Client blockiert.
|
||||
Interface gehalten, damit Write-Tool-Tests ohne echtes UI laufen (`FakeMcpConfirmation` in
|
||||
[Fakes.cs](LehrerApp.Desktop.Tests/Fakes.cs)).
|
||||
- **Neue Tools:** `create_time_entry`, `create_grade_entry` (beide Write, mit Bestätigung),
|
||||
`update_student_group_assignment` (Write; legt `GroupMembership` an oder ändert nur
|
||||
Niveau/Zeitraum/Beitritt/Austritt — bei bereits identischem Stand keine erneute Nachfrage),
|
||||
`get_lesson_plans` (Read; `Unit`+`Lesson` einer Gruppe im Zeitraum). `McpToolScope` um
|
||||
`AllowedWriteTools` erweitert, `McpServerHostedService` prüft per `Debug.Assert` weiterhin,
|
||||
dass die tatsächlich registrierten Tools exakt der Allowlist entsprechen.
|
||||
- **Bewusste Abweichung von der Spec:** `create_note` (Spec: "nur für nicht-sensible
|
||||
Notiztypen") wird **nicht** umgesetzt — das einzige existierende Notiz-Modell
|
||||
(`Documentation`, Gesprächsnotizen/Vorfälle/Förderpläne) ist bereits vollständig
|
||||
MCP-ausgeschlossen (4.5.25), ein separates "nicht-sensibles" Notiz-Konzept existiert im
|
||||
Datenmodell nicht und würde eine neue Entität erfinden, nur um die Spec-Zeile zu erfüllen.
|
||||
- **`create_lesson_plan`/`update_lesson_plan`** bewusst weiter zurückgestellt: `Lesson` ist
|
||||
das mit Abstand komplexeste Modell (verschachtelte `Phases`, Anhänge) und verdient einen
|
||||
eigenen Schritt statt in Phase 2 mit reinzurutschen.
|
||||
- **Verifiziert:** End-to-End-Smoke-Test wie in 4.5.25, erweitert um einen echten
|
||||
`create_time_entry`-Aufruf über Bridge → Pipe → `McpServer` → Confirmation-Callback →
|
||||
Repository-Save (mit automatisch bestätigender Test-`IMcpConfirmationService`-Instanz statt
|
||||
echtem Dialog) — bestätigt, dass async Write-Tool-Handler mit `CancellationToken`-Bindung
|
||||
durch das SDK korrekt funktionieren. 18 Unit-Tests in
|
||||
[McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs) (vorher 9), decken u.a. ab:
|
||||
Bestätigung/Ablehnung je Write-Tool, dass die Bestätigungsnachricht den Schülernamen statt
|
||||
einer rohen GUID enthält, und dass eine unveränderte Gruppenmitgliedschaft keine erneute
|
||||
Nachfrage auslöst.
|
||||
|
||||
- [x] **4.5.27** `build-macos-app.sh` um die Bridge-Binary erweitert (2026-09-11, aus 4.5.25/4.5.26
|
||||
zurückgestellt): `LehrerApp.McpBridge` wird jetzt als zweites Self-Contained-Publish pro
|
||||
Architektur gebaut und als lose Executable neben die Hauptapp nach
|
||||
`Contents/MacOS/LehrerApp.McpBridge` gelegt (nicht als `CFBundleExecutable` - sie wird nie
|
||||
von Finder/launchd gestartet, sondern vom KI-Client direkt über ihren vollen Pfad als
|
||||
Kindprozess). Die bisher einmalige Publish/Universal-Binary/Kopier-Logik ist in eine
|
||||
`place_binary()`-Funktion extrahiert und wird für Hauptapp und Bridge gleichermaßen
|
||||
aufgerufen. Bridge wird **zusätzlich einzeln** ad-hoc signiert (Schritt 6/7), bevor der
|
||||
Bundle-weite `codesign --deep`-Schritt (7/7) läuft — sie liegt lose in `Contents/MacOS`
|
||||
statt in einem eigenen `.framework`/`.bundle`, daher nicht verlassen auf das (in dem Fall
|
||||
nicht sicher dokumentierte) Rekursionsverhalten von `--deep`.
|
||||
- **Nicht gelöst, bewusst zurückgestellt:** Laufzeit-Duplikation (beide Publishes bringen
|
||||
ihre eigene self-contained .NET-Runtime mit) — siehe Planungsdokument, spätere Optimierung
|
||||
über einen framework-dependent Bridge-Build + angepasste `runtimeconfig.json`.
|
||||
- **Nicht verifiziert:** Diese Session lief auf Windows, ohne Zugriff auf `sips`/`iconutil`/
|
||||
`lipo`/`codesign` — das Skript wurde nur auf Bash-Syntax geprüft (`bash -n`), nicht auf
|
||||
einem echten Mac gebaut/ausgeführt. Vor Verlass auf das erzeugte Bundle: auf macOS
|
||||
`./build-macos-app.sh` laufen lassen und `open build/LehrerApp.app` sowie einen
|
||||
MCP-Client, der auf `build/LehrerApp.app/Contents/MacOS/LehrerApp.McpBridge` zeigt, gegen
|
||||
die laufende App testen.
|
||||
|
||||
- [x] **4.5.28** Lokaler MCP-Server, Phase 3 (kleinteilige Unit/Lesson-Tools), 2026-09-11: statt
|
||||
eines einzelnen `update_lesson`, das die komplette Stunde inkl. Verlaufsplan als ein großes
|
||||
JSON-Objekt tauscht, gezielt kleine Tools je Teil-Operation — auf Nutzervorschlag: `Lesson`
|
||||
ist zwar technisch ein eingebettetes LiteDB-Dokument ohne Zeilen-Locking auf DB-Ebene, aber
|
||||
kleinteilige Tools verkürzen das Lesen-Schreiben-Zeitfenster je Operation drastisch und
|
||||
liefern einen für den Bestätigungsdialog tatsächlich lesbaren Diff statt eines
|
||||
Objekt-Dumps. Ein Tool, das die ganze Stunde überschreibt, wurde bewusst NICHT gebaut.
|
||||
- **Neue Tools:** `create_unit`/`update_unit` (Einheiten-Metadaten), `create_lesson` (ohne
|
||||
Phasen; übernimmt `GroupId` automatisch von der übergeordneten `Unit`, kein eigener
|
||||
`groupId`-Parameter — verhindert Inkonsistenz zwischen Lesson und Unit),
|
||||
`update_lesson` (Metadaten, Phasen bleiben unangetastet), `add_lesson_phase`/
|
||||
`update_lesson_phase`/`remove_lesson_phase` (je eine `LessonPhaseStep`), sowie
|
||||
`download_lesson_attachment` (Read-Tool, Base64, gedeckelt auf
|
||||
`LessonPlanTools.MaxInlineAttachmentBytes` = 3 MB — größere Anhänge liefern einen klaren
|
||||
Fehler statt einer aufgeblähten Antwort; echtes MCP-Resource-Streaming für große Dateien
|
||||
bleibt ein offener Punkt, siehe Planungsdokument).
|
||||
- **`ILessonRepository` um `GetById(Guid id)` ergänzt** (fehlte bisher komplett -
|
||||
`GetByUnit`/`GetByGroupAndDate`/`GetByGroupAndRange` decken keinen Einzelabruf per ID ab).
|
||||
Ohne diese Methode wäre kein einziges der neuen Lesson-Tools möglich gewesen, da sie alle
|
||||
eine bestehende Stunde gezielt nachladen müssen. `LessonRepository.GetById` delegiert auf
|
||||
das bereits intern (in `Delete`) genutzte `db.Lessons.FindById(id)`.
|
||||
- `get_lesson_plans` liefert jetzt zusätzlich `Attachments` (Speicher-ID + Dateiname +
|
||||
Größe) je Stunde und `Phases` inklusive `Id` je Phase — beides war vorher nicht
|
||||
exponiert und ist Voraussetzung dafür, dass ein KI-Client eine Phase oder einen Anhang
|
||||
gezielt referenzieren kann.
|
||||
- `McpToolScope`/`McpServerHostedService` entsprechend erweitert (7 Read-, 10 Write-Tools
|
||||
insgesamt), 14 neue Unit-Tests in
|
||||
[McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs) (jetzt 28), decken u.a. ab:
|
||||
dass `create_lesson` die Gruppe von der Einheit übernimmt statt einen eigenen Parameter zu
|
||||
vertrauen, dass `update_lesson`/`update_lesson_phase` wirklich nur die angegebenen Felder
|
||||
ändern, und dass ein zu großer Anhang beim Download einen Fehler statt einer Antwort liefert.
|
||||
|
||||
- [x] **4.5.29** Lokaler MCP-Server, Phase 4 (Elternbrief-Vorlagen + Claude-Desktop-Registrierung),
|
||||
2026-09-11 — schließt die Spec ab (Rest siehe "Bewusst nicht umgesetzt" unten).
|
||||
- **"Worksheets" umbenannt zu Elternbrief-Vorlagen:** die Spec sah `list_worksheets`/
|
||||
`download_worksheet`/`upload_worksheet`/`update_worksheet` mit Fach-/Klassenstufe-Metadaten
|
||||
vor — das existiert im Datenmodell nicht. Was tatsächlich existiert, ist der
|
||||
`.lavorlage`-Vorlagenmechanismus (`LehrerApp.Templating`), der ausschließlich für
|
||||
Elternbriefe genutzt wird (an Schüler+Kontakt gebunden, siehe
|
||||
`CreateLetterDialogViewModel`). Neue Tools `list_letter_templates` (Read) und
|
||||
`render_letter` (Read — schreibt nichts in die Datenbank, deshalb ohne Bestätigungsdialog,
|
||||
liefert ein Base64-PDF).
|
||||
- **`upload_worksheet`/`update_worksheet` bewusst NICHT umgesetzt:** das Seitenlayout ist
|
||||
eine eigene, positionsbasierte DSL (`LayoutParser`/`.tpl`-Dateien mit absoluten
|
||||
Koordinaten), für die es einen eigenen visuellen Editor gibt (`LehrerApp.TemplateDesigner`)
|
||||
— ein LLM müsste diese DSL blind erzeugen, mit hohem Risiko für unbrauchbare oder defekte
|
||||
Layouts. `render_letter` deckt den tatsächlich nützlichen Fall ab: eine bestehende,
|
||||
von Hand gestaltete Vorlage mit Werten füllen.
|
||||
- **Platzhalter-Logik aus `CreateLetterDialogViewModel` extrahiert** nach
|
||||
[Services/LetterPlaceholderBuilder.cs](LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs),
|
||||
damit Dialog und MCP-Tool exakt dieselben Standard-Platzhalternamen (Datum, Anrede,
|
||||
Contact.Address, ...) befüllen, statt still auseinanderzudriften. Bestehende
|
||||
`CreateLetterDialogViewModelTests` liefen nach dem Refactor unverändert grün.
|
||||
- **Registrierungs-Workflow:** neuer
|
||||
[McpClientRegistrationService.cs](LehrerApp.Desktop/Services/Mcp/McpClientRegistrationService.cs)
|
||||
trägt den Bridge-Pfad in `claude_desktop_config.json` ein (Windows: `%APPDATA%\Claude\...`,
|
||||
macOS: `~/Library/Application Support/Claude/...`) — bewusst nur für Claude Desktop, der
|
||||
einzige in der Spec konkret genannte Client mit dokumentierter Config-Konvention. Button
|
||||
"Bei Claude Desktop eintragen"/"Eintrag entfernen" im MCP-Einstellungen-Tab, bewusst nur
|
||||
auf explizite Nutzeraktion, nie automatisch beim App-Start (Schreiben in die
|
||||
Konfigurationsdatei eines fremden Programms ist ein sichtbarer externer Seiteneffekt).
|
||||
Bestehende Config-Inhalte (andere MCP-Server, sonstige Claude-Desktop-Einstellungen)
|
||||
bleiben beim Eintragen erhalten; eine nicht als JSON lesbare bestehende Datei wird nicht
|
||||
angefasst, sondern liefert einen Fehler mit Pfadangabe statt sie zu überschreiben.
|
||||
Funktioniert nur bei einer gepackten Installation (Bridge liegt neben der
|
||||
Hauptapp-Executable) — bei einem lokalen `dotnet build/run` liegt die Bridge in ihrem
|
||||
eigenen separaten bin-Ordner und wird nicht gefunden.
|
||||
- `McpToolScope`/`McpServerHostedService` um `list_letter_templates`/`render_letter`
|
||||
erweitert (9 Read-, 10 Write-Tools). 6 neue Tests in
|
||||
[LetterTemplateToolsTests.cs](LehrerApp.Desktop.Tests/LetterTemplateToolsTests.cs)
|
||||
(rendert echte PDFs über `QuestTemplateRenderer`, kein Fake) und 6 in
|
||||
[McpClientRegistrationServiceTests.cs](LehrerApp.Desktop.Tests/McpClientRegistrationServiceTests.cs)
|
||||
(u.a. bestehende Fremdeinträge bleiben erhalten, kaputtes JSON wird nicht überschrieben).
|
||||
- **Bewusst nicht umgesetzt (verworfen, nicht nur zurückgestellt):** `upload_worksheet`/
|
||||
`update_worksheet` (siehe oben), Mehrbenutzer-/Remote-Zugriff, Lösch-Tools,
|
||||
Dokumentationstypen-Zugriff, Zugriff über den Sync-Server — alle laut Spec explizit
|
||||
"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`,
|
||||
|
||||
+71
-29
@@ -3,13 +3,20 @@ set -euo pipefail
|
||||
|
||||
# ============================================================
|
||||
# build-macos-app.sh
|
||||
# Baut LehrerApp.Desktop als natives macOS .app-Bundle
|
||||
# Baut LehrerApp.Desktop als natives macOS .app-Bundle, inklusive
|
||||
# LehrerApp.McpBridge als Companion-Binary im selben Bundle
|
||||
# (Contents/MacOS/LehrerApp.McpBridge neben Contents/MacOS/LehrerApp.Desktop) -
|
||||
# siehe TODO.md 4.5.25 zur Architektur-Begründung (Bridge muss im selben
|
||||
# Bundle liegen, damit sie ihren Pfad relativ zur Hauptapp ermitteln und sich
|
||||
# selbst in die MCP-Server-Config eines KI-Clients eintragen kann).
|
||||
# ============================================================
|
||||
|
||||
# ---- Konfiguration (bei Bedarf anpassen) --------------------
|
||||
PROJECT_PATH="LehrerApp.Desktop" # Pfad zum .csproj / Projektordner
|
||||
APP_NAME="LehrerApp" # Name des App-Bundles (LehrerApp.app)
|
||||
EXECUTABLE_NAME="LehrerApp.Desktop" # Name der Binary aus dotnet publish (= AssemblyName des Projekts!)
|
||||
BRIDGE_PROJECT_PATH="LehrerApp.McpBridge" # Pfad zum Bridge-.csproj / Projektordner
|
||||
BRIDGE_EXECUTABLE_NAME="LehrerApp.McpBridge" # Name der Bridge-Binary (= AssemblyName des Bridge-Projekts!)
|
||||
BUNDLE_ID="de.deinname.lehrerapp" # CFBundleIdentifier
|
||||
VERSION="1.0.0" # CFBundleShortVersionString
|
||||
MIN_MACOS="12.0" # LSMinimumSystemVersion
|
||||
@@ -25,49 +32,67 @@ CONTENTS_DIR="${APP_BUNDLE}/Contents"
|
||||
MACOS_DIR="${CONTENTS_DIR}/MacOS"
|
||||
RESOURCES_DIR="${CONTENTS_DIR}/Resources"
|
||||
|
||||
echo "=== 1/6: Alte Build-Artefakte entfernen ==="
|
||||
echo "=== 1/7: Alte Build-Artefakte entfernen ==="
|
||||
rm -rf "${BUILD_DIR}"
|
||||
mkdir -p "${BUILD_DIR}"
|
||||
|
||||
echo "=== 2/6: dotnet publish für jede Architektur ==="
|
||||
echo "=== 2/7: dotnet publish für jede Architektur (Hauptapp + Bridge) ==="
|
||||
for RID in "${RUNTIME_IDS[@]}"; do
|
||||
echo " -> Publish für ${RID}"
|
||||
echo " -> Publish Hauptapp für ${RID}"
|
||||
dotnet publish "${PROJECT_PATH}" \
|
||||
-c Release \
|
||||
-r "${RID}" \
|
||||
--self-contained true \
|
||||
-p:PublishSingleFile=false \
|
||||
-o "${BUILD_DIR}/publish/${RID}"
|
||||
|
||||
echo " -> Publish McpBridge für ${RID}"
|
||||
dotnet publish "${BRIDGE_PROJECT_PATH}" \
|
||||
-c Release \
|
||||
-r "${RID}" \
|
||||
--self-contained true \
|
||||
-p:PublishSingleFile=false \
|
||||
-o "${BUILD_DIR}/publish-bridge/${RID}"
|
||||
done
|
||||
|
||||
echo "=== 3/6: App-Bundle-Struktur anlegen ==="
|
||||
echo "=== 3/7: App-Bundle-Struktur anlegen (Hauptapp + Bridge) ==="
|
||||
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
|
||||
|
||||
if [ "${UNIVERSAL_BUILD}" = true ] && [ "${#RUNTIME_IDS[@]}" -ge 2 ]; then
|
||||
echo " -> Universal Binary via lipo erzeugen"
|
||||
SRC_A="${BUILD_DIR}/publish/${RUNTIME_IDS[0]}/${EXECUTABLE_NAME}"
|
||||
SRC_B="${BUILD_DIR}/publish/${RUNTIME_IDS[1]}/${EXECUTABLE_NAME}"
|
||||
# Kopiert den Publish-Output einer Architektur (oder beider via lipo zu einem Universal Binary)
|
||||
# nach MACOS_DIR. Für Hauptapp und Bridge wiederverwendet - beide sind self-contained-Publishes
|
||||
# derselben TFM, gemeinsame .NET-Runtime-Dateien werden beim zweiten Aufruf einfach mit sich
|
||||
# selbst überschrieben (keine Laufzeit-Duplikate im fertigen Bundle vermieden, nur der
|
||||
# Publish-Ordner enthält sie doppelt - siehe TODO.md 4.5.25 zur noch offenen
|
||||
# Runtime-Dedup-Optimierung, für v1 bewusst nicht angegangen).
|
||||
place_binary() {
|
||||
local publish_root="$1"
|
||||
local executable_name="$2"
|
||||
|
||||
# Alle Nicht-Binärdateien (Assets etc.) aus der ersten Architektur übernehmen
|
||||
rsync -a --exclude "${EXECUTABLE_NAME}" "${BUILD_DIR}/publish/${RUNTIME_IDS[0]}/" "${MACOS_DIR}/"
|
||||
if [ "${UNIVERSAL_BUILD}" = true ] && [ "${#RUNTIME_IDS[@]}" -ge 2 ]; then
|
||||
local src_a="${publish_root}/${RUNTIME_IDS[0]}/${executable_name}"
|
||||
local src_b="${publish_root}/${RUNTIME_IDS[1]}/${executable_name}"
|
||||
rsync -a --exclude "${executable_name}" "${publish_root}/${RUNTIME_IDS[0]}/" "${MACOS_DIR}/"
|
||||
lipo -create -output "${MACOS_DIR}/${executable_name}" "${src_a}" "${src_b}"
|
||||
else
|
||||
cp -r "${publish_root}/${RUNTIME_IDS[0]}/"* "${MACOS_DIR}/"
|
||||
fi
|
||||
|
||||
lipo -create -output "${MACOS_DIR}/${EXECUTABLE_NAME}" "${SRC_A}" "${SRC_B}"
|
||||
else
|
||||
echo " -> Einzelne Architektur (${RUNTIME_IDS[0]}) übernehmen"
|
||||
cp -r "${BUILD_DIR}/publish/${RUNTIME_IDS[0]}/"* "${MACOS_DIR}/"
|
||||
fi
|
||||
if [ ! -f "${MACOS_DIR}/${executable_name}" ]; then
|
||||
echo "FEHLER: Erwartete Executable '${executable_name}' nicht in ${MACOS_DIR} gefunden."
|
||||
echo "Tatsächlicher Inhalt von ${publish_root}/${RUNTIME_IDS[0]}/:"
|
||||
ls "${publish_root}/${RUNTIME_IDS[0]}/"
|
||||
echo "-> Passe den entsprechenden EXECUTABLE_NAME im Skript-Kopf an."
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "${MACOS_DIR}/${executable_name}"
|
||||
}
|
||||
|
||||
if [ ! -f "${MACOS_DIR}/${EXECUTABLE_NAME}" ]; then
|
||||
echo "FEHLER: Erwartete Executable '${EXECUTABLE_NAME}' nicht in ${MACOS_DIR} gefunden."
|
||||
echo "Tatsächlicher Inhalt von ${BUILD_DIR}/publish/${RUNTIME_IDS[0]}/:"
|
||||
ls "${BUILD_DIR}/publish/${RUNTIME_IDS[0]}/"
|
||||
echo "-> Passe EXECUTABLE_NAME im Skript-Kopf entsprechend an."
|
||||
exit 1
|
||||
fi
|
||||
echo " -> Hauptapp"
|
||||
place_binary "${BUILD_DIR}/publish" "${EXECUTABLE_NAME}"
|
||||
echo " -> Bridge"
|
||||
place_binary "${BUILD_DIR}/publish-bridge" "${BRIDGE_EXECUTABLE_NAME}"
|
||||
|
||||
chmod +x "${MACOS_DIR}/${EXECUTABLE_NAME}"
|
||||
|
||||
echo "=== 4/6: Icon konvertieren (.icns) ==="
|
||||
echo "=== 4/7: Icon konvertieren (.icns) ==="
|
||||
if [ -f "${ICON_PNG}" ]; then
|
||||
ICONSET_DIR="${BUILD_DIR}/icon.iconset"
|
||||
mkdir -p "${ICONSET_DIR}"
|
||||
@@ -90,7 +115,7 @@ else
|
||||
ICON_KEY=""
|
||||
fi
|
||||
|
||||
echo "=== 5/6: Info.plist schreiben ==="
|
||||
echo "=== 5/7: Info.plist schreiben ==="
|
||||
cat > "${CONTENTS_DIR}/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -110,7 +135,23 @@ cat > "${CONTENTS_DIR}/Info.plist" <<PLIST
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
echo "=== 6/6: Signieren ==="
|
||||
# Die Bridge ist bewusst NICHT CFBundleExecutable - sie wird nie von Finder/launchd gestartet,
|
||||
# sondern ausschließlich vom KI-Client als Kindprozess über ihren vollen Pfad
|
||||
# (Contents/MacOS/LehrerApp.McpBridge). Kein eigener Eintrag im Info.plist nötig.
|
||||
|
||||
echo "=== 6/7: Bridge-Binary einzeln signieren ==="
|
||||
if [ "${CODESIGN_ADHOC}" = true ]; then
|
||||
# Getrennt vom Bundle-weiten --deep-Signieren in Schritt 7: die Bridge ist eine zweite, lose
|
||||
# liegende Mach-O-Executable direkt in Contents/MacOS (kein eigenes .framework/.bundle) - explizit
|
||||
# zuerst signieren statt sich allein auf die Rekursion von --deep zu verlassen, damit die
|
||||
# Bridge auch dann ein gültiges Signat trägt, falls --deep das nicht selbst abdeckt.
|
||||
codesign --force --sign - "${MACOS_DIR}/${BRIDGE_EXECUTABLE_NAME}"
|
||||
echo " -> Bridge ad-hoc signiert."
|
||||
else
|
||||
echo " -> Signierung übersprungen."
|
||||
fi
|
||||
|
||||
echo "=== 7/7: Bundle-weit signieren ==="
|
||||
if [ "${CODESIGN_ADHOC}" = true ]; then
|
||||
codesign --force --deep --sign - "${APP_BUNDLE}"
|
||||
echo " -> Ad-hoc signiert (nur lokal lauffähig, Gatekeeper-Warnung bei Weitergabe)."
|
||||
@@ -120,4 +161,5 @@ fi
|
||||
|
||||
echo ""
|
||||
echo "Fertig! App-Bundle liegt unter: ${APP_BUNDLE}"
|
||||
echo "Test mit: open '${APP_BUNDLE}'"
|
||||
echo "Test mit: open '${APP_BUNDLE}'"
|
||||
echo "Bridge-Binary für die MCP-Server-Config eines KI-Clients: ${APP_BUNDLE}/Contents/MacOS/${BRIDGE_EXECUTABLE_NAME}"
|
||||
|
||||
Reference in New Issue
Block a user