Statt eines "update_lesson", das die ganze Stunde inkl. Verlaufsplan als ein großes JSON-Objekt tauscht, gezielte kleine Tools je Teiloperation (Nutzervorschlag): create/update_unit, create/update_lesson (Metadaten ohne Phasen), add/update/remove_lesson_phase (je eine Phase), download_lesson_attachment (Base64, auf 3 MB gedeckelt). Verkürzt das Lesen-Schreiben-Zeitfenster je Operation und liefert lesbare Diffs für den Bestätigungsdialog statt eines Objekt-Dumps. ILessonRepository um GetById ergänzt (fehlte bisher, war aber Voraussetzung für jedes der neuen Tools). get_lesson_plans liefert jetzt zusätzlich Phase-IDs und Attachment-Metadaten, damit ein Client sie gezielt referenzieren kann. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
193 lines
9.1 KiB
C#
193 lines
9.1 KiB
C#
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)
|
||
{
|
||
_settings = settings;
|
||
_logger = logger;
|
||
_serverOptions = BuildServerOptions(
|
||
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
|
||
groupMembershipTools);
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
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.");
|
||
|
||
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).");
|
||
|
||
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,
|
||
};
|
||
}
|
||
}
|