feat: lokaler MCP-Server, Phase 1 (Infrastruktur + Read-Tools)
Erlaubt einem lokalen KI-Client (z.B. Claude Desktop) strukturierten Lesezugriff auf Schüler, Klausuren, Noten, Stundenplan und Zeiterfassung. Neuer LehrerApp.McpBridge-Prozess reicht stdio-JSON-RPC über eine Named Pipe an einen In-Process-MCP-Server im Avalonia-Hauptprozess durch (ModelContextProtocol.Core, StreamServerTransport direkt auf der Pipe). Standardmäßig deaktiviert, Opt-in über neuen Einstellungen-Tab. Dokumentationstypen (Gesprächsnotizen/Vorfälle/Förderpläne) sind auf Code-Ebene nie erreichbar (McpToolScope, analog PlainEventStore.Allowed). Write-Tools, Bestätigungsdialog-UI, Worksheets/Lesson-Plans-Tools und macOS-Packaging folgen in späteren Phasen (siehe TODO.md 4.5.25). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -31,6 +31,14 @@ 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);
|
||||
}
|
||||
|
||||
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
||||
public static WebUntisSettingsService BuildWebUntisSettingsService()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
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_EnthaeltGenauDieFuenfPhase1Tools()
|
||||
{
|
||||
Assert.Equal(
|
||||
new[] { "get_exams", "get_grades", "get_schedule", "get_students", "get_time_entries" },
|
||||
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowedReadTools_EnthaeltKeineDokumentationstypen()
|
||||
{
|
||||
// Gesprächsnotizen/Vorfälle/Förderpläne dürfen technisch nie über MCP erreichbar sein
|
||||
// (siehe Planungsdokument) - die Namenskonvention "documentation"/"vorgang" darf nie auftauchen.
|
||||
Assert.DoesNotContain(McpToolScope.AllowedReadTools, n =>
|
||||
n.Contains("documentation", StringComparison.OrdinalIgnoreCase) ||
|
||||
n.Contains("vorgang", 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);
|
||||
|
||||
var dto = Assert.Single(tool.GetGrades(groupId, studentA));
|
||||
|
||||
Assert.Equal(studentA, dto.StudentId);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
||||
var dto = Assert.Single(tool.GetTimeEntries(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
|
||||
|
||||
Assert.Equal(30, dto.DurationMinutes);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ 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.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
|
||||
@@ -341,6 +342,7 @@ 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.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
@@ -369,6 +371,7 @@ 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.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
@@ -401,6 +404,7 @@ 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.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,15 @@ 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<StudentTools>();
|
||||
services.AddSingleton<ExamTools>();
|
||||
services.AddSingleton<GradeTools>();
|
||||
services.AddSingleton<ScheduleTools>();
|
||||
services.AddSingleton<TimeEntryTools>();
|
||||
services.AddSingleton<McpServerHostedService>();
|
||||
|
||||
// ── 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,149 @@
|
||||
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, 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)
|
||||
{
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
_serverOptions = BuildServerOptions(studentTools, examTools, gradeTools, scheduleTools, timeEntryTools);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
|
||||
void AddTool(Delegate handler, string name, string description)
|
||||
{
|
||||
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
ReadOnly = true,
|
||||
}));
|
||||
}
|
||||
|
||||
AddTool(studentTools.GetStudents, "get_students",
|
||||
"Listet Schüler, optional gefiltert nach Lerngruppe.");
|
||||
AddTool(examTools.GetExams, "get_exams",
|
||||
"Listet Klausuren, optional gefiltert nach Lerngruppe.");
|
||||
AddTool(gradeTools.GetGrades, "get_grades",
|
||||
"Listet Noten einer Lerngruppe, optional gefiltert auf einen Schüler.");
|
||||
AddTool(scheduleTools.GetSchedule, "get_schedule",
|
||||
"Listet Stundenplan-Einträge, optional gefiltert nach Lerngruppe.");
|
||||
AddTool(timeEntryTools.GetTimeEntries, "get_time_entries",
|
||||
"Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.");
|
||||
|
||||
System.Diagnostics.Debug.Assert(
|
||||
toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n)
|
||||
.SequenceEqual(McpToolScope.AllowedReadTools.OrderBy(n => n)),
|
||||
"Registrierte MCP-Tools weichen von McpToolScope.AllowedReadTools ab.");
|
||||
|
||||
return new McpServerOptions
|
||||
{
|
||||
ServerInfo = new Implementation { Name = "LehrerApp", Version = "1.0.0" },
|
||||
Capabilities = new ServerCapabilities { Tools = new ToolsCapability() },
|
||||
ToolCollection = toolCollection,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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",
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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);
|
||||
@@ -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,18 @@
|
||||
using System.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <summary>MCP-Read-Tool "get_grades" (Phase 1, siehe Planungsdokument).</summary>
|
||||
public class GradeTools(IGradeRepository grades)
|
||||
{
|
||||
[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();
|
||||
}
|
||||
}
|
||||
@@ -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,18 @@
|
||||
using System.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <summary>MCP-Read-Tool "get_time_entries" (Phase 1, siehe Planungsdokument). Der Zeitraum ist
|
||||
/// Pflicht (nicht optional), damit eine unbedachte Anfrage nicht die gesamte Zeiterfassungshistorie
|
||||
/// zurückgibt.</summary>
|
||||
public class TimeEntryTools(ITimeEntryRepository timeEntries)
|
||||
{
|
||||
[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();
|
||||
}
|
||||
@@ -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,17 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LehrerApp.Desktop.Services;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ public enum SettingsTab
|
||||
WebUntis = 13,
|
||||
Appearance = 14,
|
||||
Trash = 15,
|
||||
Mcp = 16,
|
||||
}
|
||||
|
||||
// ── Haupt-ViewModel ───────────────────────────────────────────────────────────
|
||||
@@ -73,6 +74,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
private readonly ISupervisionDutyRepository _supervisionDuties;
|
||||
private readonly AiSettingsService _aiSettings;
|
||||
private readonly AiPlanningService _aiPlanning;
|
||||
private readonly McpSettingsService _mcpSettings;
|
||||
private readonly WebUntisSettingsService _untisSettings;
|
||||
private readonly WebUntisIntegrationService? _untisIntegration;
|
||||
private readonly UntisSyncService? _untisSync;
|
||||
@@ -99,7 +101,7 @@ 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,
|
||||
WebUntisSettingsService untisSettings,
|
||||
AnnualPlanSettingsService annualPlanSettings,
|
||||
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
|
||||
@@ -137,6 +139,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
_letterTemplates = letterTemplates;
|
||||
_aiSettings = aiSettings;
|
||||
_aiPlanning = aiPlanning;
|
||||
_mcpSettings = mcpSettings;
|
||||
_untisSettings = untisSettings;
|
||||
_untisIntegration = untisIntegration;
|
||||
_untisSync = untisSync;
|
||||
@@ -164,6 +167,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
LoadSupervisionDuties();
|
||||
LoadLetterTemplates();
|
||||
LoadAiSettings();
|
||||
LoadMcpSettings();
|
||||
LoadUntisSettings();
|
||||
LoadAnnualPlanSettings();
|
||||
LoadSyncSettings();
|
||||
|
||||
@@ -1142,6 +1142,21 @@
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
<!-- Tab: MCP-Server (lokal, Phase 1) -->
|
||||
<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 Lesezugriff auf Schüler, Klausuren, Noten, Stundenplan und Zeiterfassung 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. Eine Änderung wirkt erst nach einem Neustart der App."/>
|
||||
|
||||
<CheckBox Content="MCP-Server aktivieren" IsChecked="{Binding McpEnabled}"/>
|
||||
|
||||
</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,45 @@ 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,
|
||||
`get_lesson_plans`, 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).
|
||||
|
||||
**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`,
|
||||
|
||||
Reference in New Issue
Block a user