From 0bb72422ef73e9756a89e5434b255a8a6ac2beb4 Mon Sep 17 00:00:00 2001
From: PianoNic <79938743+Pianonic@users.noreply.github.com>
Date: Thu, 12 Nov 2026 10:24:01 +0100
Subject: [PATCH 1/2] Add list_subjects MCP tool with subject aliases
---
src/Schuly.API/Helply/HelplyPrompt.cs | 2 +-
src/Schuly.API/Helply/HelplyToolLabels.cs | 1 +
.../Mcp/Tools/MicrosoftSubjectTools.cs | 280 +++++++++++++++
src/Schuly.API/Mcp/Tools/SubjectAliases.cs | 209 +++++++++++
src/Schuly.Tests/Helply/HelplyPolicyTests.cs | 9 +
src/Schuly.Tests/Mcp/McpSubjectTests.cs | 334 ++++++++++++++++++
6 files changed, 834 insertions(+), 1 deletion(-)
create mode 100644 src/Schuly.API/Mcp/Tools/MicrosoftSubjectTools.cs
create mode 100644 src/Schuly.API/Mcp/Tools/SubjectAliases.cs
create mode 100644 src/Schuly.Tests/Mcp/McpSubjectTests.cs
diff --git a/src/Schuly.API/Helply/HelplyPrompt.cs b/src/Schuly.API/Helply/HelplyPrompt.cs
index cebd0c15..885a056a 100644
--- a/src/Schuly.API/Helply/HelplyPrompt.cs
+++ b/src/Schuly.API/Helply/HelplyPrompt.cs
@@ -30,7 +30,7 @@ How you work
- First pin down the assessment. Use list_exams with a date range, but an empty result does not mean there is no test: teachers often enter tests late or not at all. Then look for a class schedule document (Semesterplan, Jahresplan, Masterplan, Terminplan, Prüfungsplan, Beurteilungstermine) with search_files and read it with read_file; it is often the most reliable source for dates. Use list_grades for the subject to see past tests, their topics and their order. Note the subject and the teacher's name.
- Questions about upcoming tests or dates ("when is my next math test", "what tests do I have this month", "do I have anything graded next week") use the same discovery steps. Never answer "you have no tests" just because list_exams is empty: also check the class schedule documents (Semesterplan, Masterplan, Prüfungsplan, Beurteilungstermine ...), assessment-date pages in OneNote, team channel posts and files, and the teacher's mails before concluding, and say which sources you checked. If dates conflict between sources, show both with their source and date.
- Do not ask clarifying questions first. If the user names only a subject ("Lernziele Physik", "what's on the next math test", "Stoff für Geschichte"), assume the next upcoming assessment in that subject (or the most recent one if none is upcoming), run the discovery and state which test you assumed.
- - Resolve the subject first, from the user's own data, before searching. Collect subject names from list_grades, list_agenda and list_exams, team and channel names from list_teams, and notebook and section names from list_notebooks and list_sections. They often name the same subject differently (Mathe / Mathematik / MA, W&R / Wirtschaft und Recht / WR, Geschichte und Politik / GP, Deutsch / DE, Physik / physique / fisica, and the user's own shorthand): match the user's word against all of them, in every language.
+ - Resolve the subject first, before searching: call list_subjects with the user's word as subject. It returns the user's subjects with their name variants, teacher, matching team channels and OneNote notebooks or sections (with a confidence) and the next and last assessment, so use those names and ids. If it is unavailable or finds nothing, collect subject names yourself from list_grades, list_agenda and list_exams, team and channel names from list_teams, and notebook and section names from list_notebooks and list_sections. They often name the same subject differently (Mathe / Mathematik / MA, W&R / Wirtschaft und Recht / WR, Geschichte und Politik / GP, Deutsch / DE, Physik / physique / fisica, and the user's own shorthand): match the user's word against all of them, in every language.
- For the matched subject note the teacher (from the agenda, grades or exams), the team and channel, and the notebook and section, and search exactly there with the name variants. If several subjects match (for example Mathematik Grundlagen and Schwerpunkt), take the one with an upcoming assessment, otherwise ask.
- "Next test" is the first future entry; "last test" is the most recent past entry or the latest grade. Agenda entries with words like Test, Prüfung or LK are another fallback for dates.
- If the user asks for several subjects or "all my tests", run the discovery per subject and answer in one overview: subject, test, date, objectives or "not found" plus where they probably are, and the source.
diff --git a/src/Schuly.API/Helply/HelplyToolLabels.cs b/src/Schuly.API/Helply/HelplyToolLabels.cs
index fa6d6ce1..5c79d371 100644
--- a/src/Schuly.API/Helply/HelplyToolLabels.cs
+++ b/src/Schuly.API/Helply/HelplyToolLabels.cs
@@ -23,6 +23,7 @@ public static class HelplyToolLabels
["list_grades"] = ("Looking up your grades", "Looked up your grades"),
["list_semester_reports"] = ("Looking up your semester reports", "Looked up your semester reports"),
["list_exams"] = ("Checking your exams", "Checked your exams"),
+ ["list_subjects"] = ("Looking up your subjects", "Looked up your subjects"),
["list_agenda"] = ("Checking your agenda", "Checked your agenda"),
["list_absences"] = ("Checking your absences", "Checked your absences"),
["list_documents"] = ("Looking through your documents", "Looked through your documents"),
diff --git a/src/Schuly.API/Mcp/Tools/MicrosoftSubjectTools.cs b/src/Schuly.API/Mcp/Tools/MicrosoftSubjectTools.cs
new file mode 100644
index 00000000..a6563b24
--- /dev/null
+++ b/src/Schuly.API/Mcp/Tools/MicrosoftSubjectTools.cs
@@ -0,0 +1,280 @@
+using System.ComponentModel;
+using System.Collections.Concurrent;
+using System.Text.Json;
+using Mediator;
+using ModelContextProtocol;
+using ModelContextProtocol.Server;
+using Schuly.Application.Dtos;
+using Schuly.Application.Queries.Agenda;
+using Schuly.Application.Queries.Exam;
+using Schuly.Application.Queries.SchoolUser;
+using Schuly.Domain.Enums;
+using Schuly.Domain.Mcp;
+using static Schuly.API.Mcp.Tools.McpToolSupport;
+
+namespace Schuly.API.Mcp.Tools
+{
+ /// The user's subject map: subjects from the school data, the names they go by, and the Teams channels and OneNote notebooks and sections that belong to them.
+ public sealed partial class MicrosoftTools
+ {
+ private const int MaxChannelCalls = 12;
+ private const int MaxNotebookSectionCalls = 8;
+ private const int MaxMatchesPerKind = 5;
+ private const int SubjectsConcurrency = 4;
+ private static readonly TimeSpan SubjectsTimeout = TimeSpan.FromSeconds(20);
+
+ [McpServerTool(Name = "list_subjects", Title = "List my subjects", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = true)]
+ [Description("Returns the user's subjects in one call, from their own school data (per school account) with the teacher where the school passes it on, every name the subject goes by (Mathe / Mathematik / MA / maths, W&R / Wirtschaft und Recht, Physik / physique ...; use these as search terms), the Teams channels and OneNote notebooks or sections that match it (confidence exact, alias or fuzzy) and the next and the last assessment. Call it first to resolve what the user means by a subject, then search exactly there. Pass subject to get only the subjects that match a name or abbreviation in any language, with year and class prefixes ignored (Mathe 4. LJ matches Mathematik). Teams and OneNote matches are left out, and the answer says so, when this connection may not read them; exams, agenda and grades likewise need Exams, Calendar and Grades read. partial is true when a limit or a failing source made the answer incomplete.")]
+ [McpAccess(McpArea.SchoolAccounts, McpAccessLevel.Read)]
+ public async Task ListSubjects([Description("Only this subject, by any of its names or abbreviations.")] string? subject = null, [Description("Microsoft account for the Teams and OneNote matches; defaults to the first one this connection may use.")] Guid? accountId = null, CancellationToken ct = default)
+ {
+ var permissions = Caller(accessor).Permissions;
+ var skipped = new List();
+ var errors = new List();
+
+ var schoolUsers = Unwrap(await mediator.Send(new GetSchoolUsersQuery(), ct));
+ var all = schoolUsers
+ .SelectMany(s => s.Classes.Select(c => new SubjectRow(c.ClassId, c.ClassName, s.Id, s.SchoolName)))
+ .DistinctBy(r => (r.SchoolUserId, r.ClassId))
+ .ToList();
+ var rows = string.IsNullOrWhiteSpace(subject) ? all : all.Where(r => SubjectAliases.Match(subject, r.Name) != SubjectMatch.None).ToList();
+
+ List exams = [];
+ if (!permissions.Allows(McpArea.Exams, McpAccessLevel.Read))
+ skipped.Add(new { source = "exams", reason = "needs Exams read" });
+ else
+ exams = Unwrap(await mediator.Send(new GetExamsQuery(), ct));
+ var gradesAllowed = permissions.Allows(McpArea.Grades, McpAccessLevel.Read);
+ if (!gradesAllowed)
+ skipped.Add(new { source = "grades", reason = "needs Grades read" });
+
+ List lessons = [];
+ if (!permissions.Allows(McpArea.Calendar, McpAccessLevel.Read))
+ skipped.Add(new { source = "teachers", reason = "needs Calendar read" });
+ else
+ lessons = Unwrap(await mediator.Send(new GetAgendasQuery(), ct)).Where(a => a.EntryType == AgendaEntryType.Lesson).ToList();
+
+ var partial = false;
+ var names = new MicrosoftNames();
+ if (rows.Count > 0)
+ {
+ var wantTeams = permissions.Allows(McpArea.Drive, McpAccessLevel.Read);
+ var wantNotebooks = permissions.Allows(McpArea.OneNote, McpAccessLevel.Read);
+ if (!wantTeams)
+ skipped.Add(new { source = "teams", reason = "needs Drive read" });
+ if (!wantNotebooks)
+ skipped.Add(new { source = "onenote", reason = "needs OneNote read" });
+ if (wantTeams || wantNotebooks)
+ {
+ try
+ {
+ var account = await AccountAsync(accountId, ct);
+ names = await LoadMicrosoftNamesAsync(account, wantTeams, wantNotebooks, errors, ct);
+ partial = names.Partial;
+ }
+ catch (McpException ex)
+ {
+ skipped.Add(new { source = "microsoft", reason = ex.Message });
+ }
+ }
+ }
+ partial |= errors.Count > 0;
+
+ var today = TodayInSwitzerland();
+ var subjects = rows.Select(r => SubjectOutput(r, exams, lessons, names, gradesAllowed, today)).ToList();
+ return Serialize(new
+ {
+ query = string.IsNullOrWhiteSpace(subject) ? null : new { input = subject.Trim(), subject = SubjectAliases.Resolve(subject)?.Name, aliases = SubjectAliases.AliasesOf(subject).Take(14) },
+ partial,
+ skipped = skipped.Count == 0 ? null : skipped,
+ errors = errors.Count == 0 ? null : errors.Take(10).ToList(),
+ subjects,
+ note = rows.Count == 0 ? (all.Count == 0 ? "No school classes are connected to this account." : "None of the user's subjects matches that name. Search with query.aliases as terms instead.") : null,
+ });
+ }
+
+ private static object SubjectOutput(SubjectRow r, List exams, List lessons, MicrosoftNames names, bool gradesAllowed, DateOnly today)
+ {
+ var known = SubjectAliases.Resolve(r.Name);
+ var mine = exams.Where(e => e.ClassId == r.ClassId).ToList();
+ var next = mine.Where(e => e.Date is { } d && d >= today).OrderBy(e => e.Date).FirstOrDefault();
+ var last = mine.Where(e => e.Date is { } d && d < today).OrderByDescending(e => e.Date).FirstOrDefault();
+ object? lastOutput = last is null ? null : ExamOutput(last, "exam");
+ if (last is null && gradesAllowed)
+ {
+ var graded = mine.Where(e => e.Grades.Any(g => g.SchoolUserId == r.SchoolUserId)).OrderByDescending(e => e.Date ?? DateOnly.MinValue).FirstOrDefault();
+ if (graded is not null)
+ lastOutput = new { source = "grade", examId = graded.Id, name = graded.Name, date = graded.Date, score = graded.Grades.First(g => g.SchoolUserId == r.SchoolUserId).Score };
+ }
+
+ var teachers = TeachersFromLessons(lessons.Where(a => a.ClassId == r.ClassId || (a.ClassId is null && known is not null && SubjectAliases.Resolve(a.Title)?.Key == known.Key)).Select(a => a.Description));
+ return new
+ {
+ subjectId = r.ClassId,
+ name = r.Name,
+ canonical = known?.Name,
+ school = r.SchoolName,
+ schoolUserId = r.SchoolUserId,
+ aliases = SubjectAliases.AliasesOf(r.Name).Take(14),
+ teachers = teachers.Count == 0 ? null : teachers,
+ teams = names.MatchTeams(r.Name).Take(MaxMatchesPerKind).ToList(),
+ notebooks = names.MatchNotebooks(r.Name).Take(MaxMatchesPerKind).ToList(),
+ nextAssessment = next is null ? null : ExamOutput(next, "exam"),
+ lastAssessment = lastOutput,
+ };
+ }
+
+ private static object ExamOutput(ExamDto e, string source) => new { source, examId = e.Id, name = e.Name, date = e.Date, type = e.Type };
+
+ ///
+ /// Teacher names as far as the school passes them on: Schulnetz lessons carry them in the lesson note, so a note that looks like a short list of names
+ /// (no digits, no sentence punctuation) counts, anything else is a comment and is ignored. The most frequent names come first, at most four.
+ ///
+ public static List TeachersFromLessons(IEnumerable notes)
+ {
+ var counts = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var note in notes)
+ {
+ if (string.IsNullOrWhiteSpace(note) || note.Length > 80)
+ continue;
+ var parts = note.Split([',', ';', '/'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (parts.Length == 0 || parts.Any(p => p.Length > 40 || p.Split(' ').Length > 4 || p.Any(c => char.IsDigit(c) || c is '.' or '!' or '?' or ':') || !char.IsUpper(p[0])))
+ continue;
+ foreach (var part in parts)
+ counts[part] = counts.GetValueOrDefault(part) + 1;
+ }
+ return counts.OrderByDescending(c => c.Value).ThenBy(c => c.Key, StringComparer.OrdinalIgnoreCase).Take(4).Select(c => c.Key).ToList();
+ }
+
+ private static DateOnly TodayInSwitzerland()
+ {
+ try
+ {
+ return DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(DateTime.UtcNow, TimeZoneInfo.FindSystemTimeZoneById("Europe/Zurich")));
+ }
+ catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
+ {
+ return DateOnly.FromDateTime(DateTime.UtcNow);
+ }
+ }
+
+ /// Team, channel, notebook and section names of the user's Microsoft account, fetched with a cap on calls and a timeout.
+ private async Task LoadMicrosoftNamesAsync(Guid account, bool teams, bool notebooks, List errors, CancellationToken ct)
+ {
+ var result = new MicrosoftNames();
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeout.CancelAfter(SubjectsTimeout);
+ using var gate = new SemaphoreSlim(SubjectsConcurrency);
+ var failures = new ConcurrentBag();
+ var teamRows = new ConcurrentBag<(string Id, string Name, List<(string Id, string Name)> Channels)>();
+ var notebookRows = new ConcurrentBag<(string Id, string Name, List<(string Id, string Name)> Sections)>();
+
+ async Task Guarded(string label, Func work)
+ {
+ await gate.WaitAsync(timeout.Token);
+ try
+ {
+ await work(timeout.Token);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ failures.Add($"{label}: timed out");
+ }
+ catch (McpException ex)
+ {
+ failures.Add($"{label}: {ex.Message}");
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ var tasks = new List();
+ if (teams)
+ tasks.Add(Guarded("teams", async c =>
+ {
+ var list = Items(await ReadAsync($"{Base}/accounts/{account}/teams", c)).Select(t => (Id: Str(t, "id"), Name: Str(t, "displayName"))).Where(t => t.Id is not null && t.Name is not null).Select(t => (Id: t.Id!, Name: t.Name!)).ToList();
+ if (list.Count > MaxChannelCalls)
+ result.Partial = true;
+ await Task.WhenAll(list.Take(MaxChannelCalls).Select(t => Guarded("channels", async c2 =>
+ {
+ var channels = Items(await ReadAsync($"{Base}/accounts/{account}/teams/{Uri.EscapeDataString(t.Id)}/channels", c2)).Select(x => (Id: Str(x, "id"), Name: Str(x, "displayName"))).Where(x => x.Id is not null && x.Name is not null).Select(x => (Id: x.Id!, Name: x.Name!)).ToList();
+ teamRows.Add((t.Id, t.Name, channels));
+ })));
+ foreach (var t in list.Skip(MaxChannelCalls))
+ teamRows.Add((t.Id, t.Name, []));
+ }));
+ if (notebooks)
+ tasks.Add(Guarded("notebooks", async c =>
+ {
+ var list = Items(await ReadAsync($"{Base}/accounts/{account}/onenote/notebooks?teams=true", c)).Select(n => (Id: Str(n, "id"), Name: Str(n, "displayName"))).Where(n => n.Id is not null && n.Name is not null).Select(n => (Id: n.Id!, Name: n.Name!)).ToList();
+ if (list.Count > MaxNotebookSectionCalls)
+ result.Partial = true;
+ await Task.WhenAll(list.Take(MaxNotebookSectionCalls).Select(n => Guarded("sections", async c2 =>
+ {
+ var tree = await ReadAsync($"{Base}/accounts/{account}/onenote/notebooks/{Uri.EscapeDataString(n.Id)}/sections", c2) ?? default;
+ var sections = new List<(string Id, string Name)>();
+ CollectSections(Prop(tree, "children"), sections, 0);
+ notebookRows.Add((n.Id, n.Name, sections));
+ })));
+ foreach (var n in list.Skip(MaxNotebookSectionCalls))
+ notebookRows.Add((n.Id, n.Name, []));
+ }));
+ try
+ {
+ await Task.WhenAll(tasks);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ failures.Add("microsoft: timed out");
+ }
+ ct.ThrowIfCancellationRequested();
+
+ errors.AddRange(failures.Distinct());
+ result.Teams.AddRange(teamRows.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase));
+ result.Notebooks.AddRange(notebookRows.OrderBy(n => n.Name, StringComparer.OrdinalIgnoreCase));
+ return result;
+ }
+
+ private static void CollectSections(JsonElement? nodes, List<(string Id, string Name)> into, int depth)
+ {
+ if (depth > 8)
+ return;
+ foreach (var node in Items(nodes))
+ {
+ if (Str(node, "id") is { } id && Str(node, "displayName") is { } name)
+ into.Add((id, name));
+ if (Str(node, "kind") == "group")
+ CollectSections(Prop(node, "children"), into, depth + 1);
+ }
+ }
+
+ private sealed record SubjectRow(Guid ClassId, string Name, Guid SchoolUserId, string? SchoolName);
+
+ private sealed class MicrosoftNames
+ {
+ private static readonly string[] GeneralChannels = ["general", "allgemein"];
+
+ public bool Partial { get; set; }
+ public List<(string Id, string Name, List<(string Id, string Name)> Channels)> Teams { get; } = [];
+ public List<(string Id, string Name, List<(string Id, string Name)> Sections)> Notebooks { get; } = [];
+
+ public IEnumerable MatchTeams(string subject) => Teams
+ .SelectMany(t => new[] { (Team: t, Channel: ((string Id, string Name)?)null) }.Concat(t.Channels.Where(c => !GeneralChannels.Contains(SubjectAliases.Normalize(c.Name))).Select(c => (Team: t, Channel: ((string Id, string Name)?)c))))
+ .Select(x => (x.Team, x.Channel, Match: SubjectAliases.Match(subject, x.Channel?.Name ?? x.Team.Name)))
+ .Where(x => x.Match != SubjectMatch.None)
+ .OrderByDescending(x => x.Match)
+ .Select(x => (object)new { teamId = x.Team.Id, team = x.Team.Name, channelId = x.Channel?.Id, channel = x.Channel?.Name, confidence = Confidence(x.Match) });
+
+ public IEnumerable MatchNotebooks(string subject) => Notebooks
+ .SelectMany(n => new[] { (Notebook: n, Section: ((string Id, string Name)?)null) }.Concat(n.Sections.Select(s => (Notebook: n, Section: ((string Id, string Name)?)s))))
+ .Select(x => (x.Notebook, x.Section, Match: SubjectAliases.Match(subject, x.Section?.Name ?? x.Notebook.Name)))
+ .Where(x => x.Match != SubjectMatch.None)
+ .OrderByDescending(x => x.Match)
+ .Select(x => (object)new { notebookId = x.Notebook.Id, notebook = x.Notebook.Name, sectionId = x.Section?.Id, section = x.Section?.Name, confidence = Confidence(x.Match) });
+
+ private static string Confidence(SubjectMatch match) => match switch { SubjectMatch.Exact => "exact", SubjectMatch.Alias => "alias", _ => "fuzzy" };
+ }
+ }
+}
diff --git a/src/Schuly.API/Mcp/Tools/SubjectAliases.cs b/src/Schuly.API/Mcp/Tools/SubjectAliases.cs
new file mode 100644
index 00000000..d60ccc38
--- /dev/null
+++ b/src/Schuly.API/Mcp/Tools/SubjectAliases.cs
@@ -0,0 +1,209 @@
+using System.Globalization;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace Schuly.API.Mcp.Tools
+{
+ /// How well two names for a subject agree. : the same name once case, accents, punctuation and year or class prefixes are ignored. : two names of the same subject (Mathe / Mathematik / maths). : similar, not known to be the same.
+ public enum SubjectMatch
+ {
+ None = 0,
+ Fuzzy = 1,
+ Alias = 2,
+ Exact = 3,
+ }
+
+ /// A subject from the built-in table: a stable key, the name to show and every name it goes by in German, French, Italian and English.
+ public sealed record SubjectInfo(string Key, string Name, IReadOnlyList Aliases);
+
+ ///
+ /// Names and abbreviations of common Swiss school subjects, with the normalisation that makes "Mathe 4. LJ", "BM-Mathematik Grundlagen" and "maths" land on the same subject.
+ /// Short abbreviations (up to three letters, such as MA or WR) only count when they are the whole name, so "de" in a sentence is never a subject.
+ ///
+ public static partial class SubjectAliases
+ {
+ private static readonly (string Key, string Name, string[] Aliases)[] Table =
+ [
+ ("math", "Mathematik", ["Mathematik", "Mathe", "Math", "Maths", "Mathematics", "MA", "MAT", "Mathématiques", "Matematica"]),
+ ("wirtschaft-recht", "Wirtschaft und Recht", ["Wirtschaft und Recht", "Wirtschaft & Recht", "W&R", "WR", "WuR", "Wirtschaft", "Recht", "Economie et droit", "Économie et droit", "E&D", "Economia e diritto", "Economics and Law", "Business and Law"]),
+ ("geschichte-politik", "Geschichte und Politik", ["Geschichte und Politik", "Geschichte & Politik", "G&P", "GP", "GuP", "Geschichte", "Politik", "Histoire et politique", "Histoire", "Storia e politica", "Storia", "History and Politics", "History"]),
+ ("deutsch", "Deutsch", ["Deutsch", "DE", "DEU", "German", "Allemand", "Tedesco"]),
+ ("englisch", "Englisch", ["Englisch", "EN", "ENG", "English", "Anglais", "Inglese"]),
+ ("franzoesisch", "Französisch", ["Französisch", "Franzoesisch", "FR", "FRA", "Français", "Francais", "French", "Francese"]),
+ ("italienisch", "Italienisch", ["Italienisch", "ITA", "Italiano", "Italian", "Italien"]),
+ ("spanisch", "Spanisch", ["Spanisch", "ES", "ESP", "Español", "Espanol", "Spanish", "Espagnol", "Spagnolo"]),
+ ("physik", "Physik", ["Physik", "PH", "PHY", "Phys", "Physics", "Physique", "Fisica"]),
+ ("chemie", "Chemie", ["Chemie", "CH", "CHE", "Chemistry", "Chimie", "Chimica"]),
+ ("biologie", "Biologie", ["Biologie", "BIO", "BI", "Biology", "Biologia"]),
+ ("naturwissenschaften", "Naturwissenschaften", ["Naturwissenschaften", "Naturwissenschaft", "NW", "NaWi", "Natural Sciences", "Sciences naturelles", "Scienze naturali"]),
+ ("abu", "Allgemeinbildung (ABU)", ["Allgemeinbildung", "Allgemeinbildender Unterricht", "ABU", "General Education", "Culture générale", "Cultura generale"]),
+ ("sprache-kommunikation", "Sprache und Kommunikation", ["Sprache und Kommunikation", "Sprache & Kommunikation", "S&K", "SuK", "Langue et communication", "Lingua e comunicazione"]),
+ ("gesellschaft", "Gesellschaft", ["Gesellschaft", "Society", "Société", "Societe", "Società", "Societa"]),
+ ("sport", "Sport", ["Sport", "SP", "Sports", "Turnen", "Bewegung und Sport", "EPS", "Education physique", "Éducation physique", "Educazione fisica", "Physical Education", "PE"]),
+ ("informatik", "Informatik", ["Informatik", "INF", "ICT", "IKT", "Computer Science", "Informatique", "Informatica"]),
+ ("geografie", "Geografie", ["Geografie", "Geographie", "GEO", "Geography", "Géographie", "Geographie", "Geografia"]),
+ ("musik", "Musik", ["Musik", "MU", "Music", "Musique", "Musica"]),
+ ("gestalten", "Gestalten", ["Gestalten", "Bildnerisches Gestalten", "BG", "Kunst", "Art", "Arts visuels", "Arte"]),
+ ("ethik-religion", "Ethik und Religion", ["Ethik und Religion", "Ethik", "Religion", "Éthique", "Etica", "Ethics"]),
+ ("philosophie", "Philosophie", ["Philosophie", "Philosophy", "Filosofia"]),
+ ("psychologie", "Psychologie", ["Psychologie", "Psychology", "Psicologia"]),
+ ("berufskunde", "Berufskunde", ["Berufskunde", "Fachkunde", "BK", "Connaissances professionnelles", "Conoscenze professionali"]),
+ ];
+
+ private static readonly string[] NoiseWords = ["lj", "lehrjahr", "jahr", "sem", "semester", "semestre", "klasse", "kl", "year", "bm", "bms", "bm1", "bm2", "berufsmatura", "berufsmaturitat", "maturite", "maturita", "fach", "kurs"];
+
+ private static readonly Dictionary ByKey = Table.ToDictionary(t => t.Key, t => new SubjectInfo(t.Key, t.Name, t.Aliases.Distinct(StringComparer.OrdinalIgnoreCase).ToList()));
+
+ // Aliases that are a single short token only match a whole name; the rest are phrases that may appear anywhere in a name, longest first.
+ private static readonly Dictionary ShortAliases = BuildShort();
+ private static readonly List<(string[] Tokens, string Key)> Phrases = BuildPhrases();
+
+ public static IReadOnlyCollection All => ByKey.Values;
+
+ /// Lower case, no accents, "&" as "und", punctuation as single spaces: "Wirtschaft & Recht" and "wirtschaft und recht" are equal.
+ public static string Normalize(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ return string.Empty;
+ var decomposed = text.Trim().Normalize(NormalizationForm.FormD);
+ var sb = new StringBuilder(decomposed.Length);
+ foreach (var c in decomposed)
+ {
+ var category = CharUnicodeInfo.GetUnicodeCategory(c);
+ if (category == UnicodeCategory.NonSpacingMark)
+ continue;
+ if (c == '&')
+ sb.Append(" und ");
+ else if (c == 'ß')
+ sb.Append("ss");
+ else if (char.IsLetterOrDigit(c))
+ sb.Append(char.ToLowerInvariant(c));
+ else
+ sb.Append(' ');
+ }
+ return Spaces().Replace(sb.ToString(), " ").Trim();
+ }
+
+ /// The normalised name without what only says which class or year it is: "Mathe 4. LJ", "BM23d Mathe" and "BM-Mathe" all become "mathe".
+ public static string Clean(string? text)
+ {
+ var normalized = Normalize(text);
+ var kept = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(t => !IsNoise(t)).ToList();
+ return kept.Count == 0 ? normalized : string.Join(' ', kept);
+ }
+
+ /// The subject from the table this name stands for, or null when it is not a known one.
+ public static SubjectInfo? Resolve(string? text)
+ {
+ var clean = Clean(text);
+ if (clean.Length == 0)
+ return null;
+ if (ShortAliases.TryGetValue(clean, out var shortKey))
+ return ByKey[shortKey];
+ var tokens = clean.Split(' ');
+ foreach (var (phrase, key) in Phrases)
+ if (Contains(tokens, phrase))
+ return ByKey[key];
+ return null;
+ }
+
+ /// Every name the subject goes by, for searching: the table entry when the name is known, otherwise just the name itself.
+ public static IReadOnlyList AliasesOf(string? text) => Resolve(text)?.Aliases ?? (string.IsNullOrWhiteSpace(text) ? [] : [text.Trim()]);
+
+ /// How well two names fit as the same subject.
+ public static SubjectMatch Match(string? a, string? b)
+ {
+ var cleanA = Clean(a);
+ var cleanB = Clean(b);
+ if (cleanA.Length == 0 || cleanB.Length == 0)
+ return SubjectMatch.None;
+ if (cleanA == cleanB)
+ return SubjectMatch.Exact;
+ var subjectA = Resolve(a);
+ var subjectB = Resolve(b);
+ if (subjectA is not null && subjectB is not null)
+ return subjectA.Key == subjectB.Key ? SubjectMatch.Alias : SubjectMatch.None;
+ if (subjectA is not null || subjectB is not null)
+ {
+ var known = (subjectA ?? subjectB)!;
+ var other = subjectA is null ? cleanA : cleanB;
+ return known.Aliases.Select(Clean).Any(alias => alias.Length >= 4 && Close(alias, other)) ? SubjectMatch.Fuzzy : SubjectMatch.None;
+ }
+ return Close(cleanA, cleanB) ? SubjectMatch.Fuzzy : SubjectMatch.None;
+ }
+
+ private static bool Close(string a, string b)
+ {
+ if (a.Length < 4 || b.Length < 4)
+ return false;
+ if (Contains(a.Split(' '), b.Split(' ')) || Contains(b.Split(' '), a.Split(' ')))
+ return true;
+ return Distance(a, b) <= (Math.Max(a.Length, b.Length) >= 8 ? 2 : 1);
+ }
+
+ private static bool IsNoise(string token) => token.All(char.IsDigit) || NoiseWords.Contains(token) || ClassCode().IsMatch(token) || Ordinal().IsMatch(token);
+
+ private static bool Contains(string[] tokens, string[] phrase)
+ {
+ for (var i = 0; i + phrase.Length <= tokens.Length; i++)
+ {
+ var j = 0;
+ while (j < phrase.Length && tokens[i + j] == phrase[j])
+ j++;
+ if (j == phrase.Length)
+ return true;
+ }
+ return false;
+ }
+
+ private static Dictionary BuildShort()
+ {
+ var result = new Dictionary();
+ foreach (var (key, name, aliases) in Table)
+ foreach (var alias in aliases.Append(name))
+ {
+ var normalized = Normalize(alias);
+ if (normalized.Length is > 0 and <= 3 && !normalized.Contains(' ') && !result.ContainsKey(normalized))
+ result[normalized] = key;
+ }
+ return result;
+ }
+
+ private static List<(string[] Tokens, string Key)> BuildPhrases()
+ {
+ var result = new List<(string[] Tokens, string Key)>();
+ foreach (var (key, name, aliases) in Table)
+ foreach (var alias in aliases.Append(name))
+ {
+ var normalized = Normalize(alias);
+ if (normalized.Length > 3 || normalized.Contains(' '))
+ result.Add((normalized.Split(' '), key));
+ }
+ return result.OrderByDescending(p => p.Tokens.Length).ThenByDescending(p => p.Tokens.Sum(t => t.Length)).ToList();
+ }
+
+ private static int Distance(string a, string b)
+ {
+ var previous = Enumerable.Range(0, b.Length + 1).ToArray();
+ for (var i = 1; i <= a.Length; i++)
+ {
+ var current = new int[b.Length + 1];
+ current[0] = i;
+ for (var j = 1; j <= b.Length; j++)
+ current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1));
+ previous = current;
+ }
+ return previous[b.Length];
+ }
+
+ [GeneratedRegex(" +")]
+ private static partial Regex Spaces();
+
+ // Class codes such as bm23d, fr23a, ap24b, and school-year ordinals such as 4th, 2e, 3eme.
+ [GeneratedRegex(@"^[a-z]{1,5}\d{1,2}[a-z]?$|^\d{2}[a-z]$")]
+ private static partial Regex ClassCode();
+
+ [GeneratedRegex(@"^\d+(st|nd|rd|th|e|er|eme|ere|re)$")]
+ private static partial Regex Ordinal();
+ }
+}
diff --git a/src/Schuly.Tests/Helply/HelplyPolicyTests.cs b/src/Schuly.Tests/Helply/HelplyPolicyTests.cs
index 60f33dfc..68b52689 100644
--- a/src/Schuly.Tests/Helply/HelplyPolicyTests.cs
+++ b/src/Schuly.Tests/Helply/HelplyPolicyTests.cs
@@ -104,5 +104,14 @@ public async Task The_prompt_teaches_the_search_workflow_with_real_tool_names()
foreach (var tool in new[] { "list_exams", "list_grades", "search_files", "search_pages", "list_notebooks", "list_sections", "list_pages", "read_page", "list_teams", "search_channel_messages", "list_team_files", "list_mail", "read_mail" })
await Assert.That(prompt).Contains(tool);
}
+
+ [Test]
+ public async Task The_prompt_resolves_the_subject_with_list_subjects_and_keeps_the_manual_fallback()
+ {
+ var prompt = HelplyPrompt.System(DateTimeOffset.UtcNow);
+
+ await Assert.That(prompt).Contains("call list_subjects with the user's word as subject");
+ await Assert.That(prompt).Contains("If it is unavailable or finds nothing, collect subject names yourself from list_grades");
+ }
}
}
diff --git a/src/Schuly.Tests/Mcp/McpSubjectTests.cs b/src/Schuly.Tests/Mcp/McpSubjectTests.cs
new file mode 100644
index 00000000..5b396f64
--- /dev/null
+++ b/src/Schuly.Tests/Mcp/McpSubjectTests.cs
@@ -0,0 +1,334 @@
+using System.Collections.Concurrent;
+using System.Text.Json;
+using Mediator;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.ApplicationParts;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using Schuly.API.Mcp.Tools;
+using Schuly.API.Plugins;
+using Schuly.Application.Dtos;
+using Schuly.Application.Models;
+using Schuly.Application.Queries.Agenda;
+using Schuly.Application.Queries.Exam;
+using Schuly.Application.Queries.LinkedAccount;
+using Schuly.Application.Queries.SchoolUser;
+using Schuly.Domain.Enums;
+using Schuly.Domain.Mcp;
+using Schuly.Infrastructure;
+using static Schuly.Tests.Mcp.McpTestSupport;
+
+namespace Schuly.Tests.Mcp
+{
+ /// The subject name table and the list_subjects tool: aliases, matching Teams and OneNote names, assessments, permissions and limits.
+ [NotInParallel]
+ public class McpSubjectTests
+ {
+ private static readonly Guid AccountId = Guid.Parse("11111111-2222-3333-4444-555555555555");
+ private static readonly Guid SchoolUserId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001");
+ private static readonly Guid MathId = Guid.Parse("c0000000-0000-0000-0000-000000000001");
+ private static readonly Guid WrId = Guid.Parse("c0000000-0000-0000-0000-000000000002");
+ private static readonly Guid PhysicsId = Guid.Parse("c0000000-0000-0000-0000-000000000003");
+
+ private sealed record Seen(string Method, string Path, string Query);
+
+ private static SchoolData _data = new();
+
+ private sealed class SchoolData
+ {
+ public List SchoolUsers { get; set; } = [];
+ public List Exams { get; set; } = [];
+ public List Agenda { get; set; } = [];
+ }
+
+ private static DateOnly Today(int days) => DateOnly.FromDateTime(DateTime.UtcNow).AddDays(days);
+
+ private static SchoolData Sample() => new()
+ {
+ SchoolUsers =
+ [
+ new SchoolUserDto
+ {
+ Id = SchoolUserId, SchoolId = Guid.NewGuid(), SchoolName = "GIBB", FirstName = "Sam", LastName = "Student", Email = "s@x", Role = Roles.Student,
+ Classes = [new UserClassDto { ClassId = MathId, ClassName = "Mathematik 4. LJ" }, new UserClassDto { ClassId = WrId, ClassName = "Wirtschaft und Recht" }, new UserClassDto { ClassId = PhysicsId, ClassName = "Physik" }],
+ },
+ ],
+ Exams =
+ [
+ new ExamDto { Id = Guid.NewGuid(), Name = "Test 4", ClassId = MathId, Date = Today(10), ClassAverage = 0 },
+ new ExamDto { Id = Guid.NewGuid(), Name = "Test 3", ClassId = MathId, Date = Today(-20), ClassAverage = 0 },
+ new ExamDto { Id = Guid.NewGuid(), Name = "Test 1", ClassId = MathId, Date = Today(-90), ClassAverage = 0 },
+ new ExamDto { Id = Guid.NewGuid(), Name = "Recht Test", ClassId = WrId, Date = Today(-5), ClassAverage = 0 },
+ new ExamDto { Id = Guid.NewGuid(), Name = "Mechanik", ClassId = PhysicsId, Date = null, ClassAverage = 0, Grades = [new GradeDto { Score = 5.5m, Weighting = 1, SchoolUserId = SchoolUserId }] },
+ ],
+ Agenda =
+ [
+ new AgendaEntryDto { EntryType = AgendaEntryType.Lesson, Title = "Mathematik", Description = "Hans Muster", Date = DateTime.UtcNow, ClassId = MathId },
+ new AgendaEntryDto { EntryType = AgendaEntryType.Lesson, Title = "Mathematik", Description = "Hans Muster", Date = DateTime.UtcNow.AddDays(1), ClassId = MathId },
+ new AgendaEntryDto { EntryType = AgendaEntryType.Lesson, Title = "Wirtschaft und Recht", Description = "Bitte Laptop mitnehmen!", Date = DateTime.UtcNow, ClassId = WrId },
+ ],
+ };
+
+ private static McpPermissionSet Only(params (McpArea Area, McpAccessLevel Level)[] areas)
+ {
+ var set = McpPermissionSet.None();
+ foreach (var (area, level) in areas)
+ set.Areas[area] = level;
+ return set;
+ }
+
+ private static readonly (McpArea, McpAccessLevel)[] Everything = [(McpArea.SchoolAccounts, McpAccessLevel.Read), (McpArea.Exams, McpAccessLevel.Read), (McpArea.Grades, McpAccessLevel.Read), (McpArea.Calendar, McpAccessLevel.Read), (McpArea.Drive, McpAccessLevel.Read), (McpArea.OneNote, McpAccessLevel.Read)];
+
+ private static string DefaultAnswer(string path) => path switch
+ {
+ "teams" => """[{"id":"t1","displayName":"BM23d"}]""",
+ "teams/t1/channels" => """[{"id":"c-gen","displayName":"General"},{"id":"c-math","displayName":"Mathe"},{"id":"c-wr","displayName":"W&R"},{"id":"c-ph","displayName":"Physique"},{"id":"c-deu","displayName":"Deutsch"}]""",
+ "onenote/notebooks" => """[{"id":"nb1","displayName":"Klassennotizbuch"}]""",
+ "onenote/notebooks/nb1/sections" => """{"children":[{"id":"s1","displayName":"Mathematik","kind":"section"},{"id":"s2","displayName":"Deutsch","kind":"section"},{"id":"g1","displayName":"Naturwissenschaften","kind":"group","children":[{"id":"s3","displayName":"Physik","kind":"section"}]}]}""",
+ _ => "[]",
+ };
+
+ private static async Task<(McpHttpHost Host, McpClient Client, ConcurrentQueue Seen)> StartAsync(string name, McpPermissionSet permissions, Func? answer = null)
+ {
+ var seen = new ConcurrentQueue();
+ var host = await McpHttpHost.StartAsync(
+ name,
+ services =>
+ {
+ services.AddScoped();
+ services.AddSingleton(sp => new PluginHost(sp, sp.GetRequiredService(), new ApplicationPartManager(), new PluginEndpointDataSource(), new PluginAssemblyMap(), null!, NullLogger.Instance));
+ },
+ app => app.MapMethods("/api/plugins/microsoft/accounts/{accountId}/{**rest}", ["GET", "POST"], (HttpContext context, string rest) =>
+ {
+ seen.Enqueue(new Seen(context.Request.Method, rest, context.Request.QueryString.Value ?? string.Empty));
+ var json = (answer ?? DefaultAnswer)(rest);
+ return json is null ? Results.NotFound() : Results.Content(json, "application/json");
+ }));
+ var userId = host.Scoped(sp => NewUser(sp.GetRequiredService()).User.Id);
+ var key = await host.ApiKeyAsync(userId, permissions);
+ return (host, await host.ConnectAsync(key), seen);
+ }
+
+ private static async Task CallAsync(McpClient client, string tool, Dictionary? args = null)
+ {
+ var result = await client.CallToolAsync(tool, args ?? []);
+ await Assert.That(result.IsError ?? false).IsFalse().Because(tool);
+ return JsonDocument.Parse(((TextContentBlock)result.Content[0]).Text).RootElement;
+ }
+
+ private static JsonElement Subject(JsonElement result, string name) => result.GetProperty("subjects").EnumerateArray().Single(s => s.GetProperty("name").GetString() == name);
+
+ // --- the name table ---
+
+ [Test]
+ [Arguments("Mathe 4. LJ", "Mathematik")]
+ [Arguments("BM23d Mathematik Grundlagen", "Mathematik")]
+ [Arguments("maths", "Mathematik")]
+ [Arguments("MA", "Mathématiques")]
+ [Arguments("W&R", "Wirtschaft und Recht")]
+ [Arguments("Wirtschaft & Recht 2. Semester", "WuR")]
+ [Arguments("Economie et droit", "Wirtschaft und Recht")]
+ [Arguments("physique", "Physik")]
+ [Arguments("Fisica", "Physik 3. LJ")]
+ [Arguments("Français", "Französisch")]
+ [Arguments("Franzoesisch", "français")]
+ [Arguments("G&P", "Geschichte und Politik")]
+ [Arguments("Histoire et politique", "GP")]
+ [Arguments("Education physique", "Sport")]
+ [Arguments("Éducation physique et sportive", "EPS")]
+ [Arguments("ABU", "Allgemeinbildung")]
+ [Arguments("Culture générale", "Allgemeinbildender Unterricht")]
+ [Arguments("NW", "Naturwissenschaften")]
+ [Arguments("Inglese", "Englisch")]
+ public async Task Names_of_the_same_subject_match_as_aliases(string a, string b)
+ {
+ await Assert.That(SubjectAliases.Match(a, b) >= SubjectMatch.Alias).IsTrue();
+ await Assert.That(SubjectAliases.Match(b, a) >= SubjectMatch.Alias).IsTrue();
+ }
+
+ [Test]
+ public async Task Normalisation_ignores_case_accents_punctuation_and_year_or_class_prefixes()
+ {
+ await Assert.That(SubjectAliases.Normalize("Wirtschaft & Recht")).IsEqualTo("wirtschaft und recht");
+ await Assert.That(SubjectAliases.Normalize("Français")).IsEqualTo("francais");
+ await Assert.That(SubjectAliases.Clean("Mathe 4. LJ")).IsEqualTo("mathe");
+ await Assert.That(SubjectAliases.Clean("BM23d Mathe")).IsEqualTo("mathe");
+ await Assert.That(SubjectAliases.Clean("BM-Mathematik (2. Sem.)")).IsEqualTo("mathematik");
+ await Assert.That(SubjectAliases.Match("Mathematik 4. LJ", "mathematik")).IsEqualTo(SubjectMatch.Exact);
+ await Assert.That(SubjectAliases.Match("Mathe", "Mathematik")).IsEqualTo(SubjectMatch.Alias);
+ await Assert.That(SubjectAliases.Match("Biologi", "Biologie")).IsEqualTo(SubjectMatch.Fuzzy);
+ }
+
+ [Test]
+ public async Task Different_subjects_and_short_abbreviations_inside_other_words_do_not_match()
+ {
+ await Assert.That(SubjectAliases.Match("Physik", "Chemie")).IsEqualTo(SubjectMatch.None);
+ await Assert.That(SubjectAliases.Match("Sport", "Education physique")).IsEqualTo(SubjectMatch.Alias);
+ await Assert.That(SubjectAliases.Match("Physik", "Education physique")).IsEqualTo(SubjectMatch.None);
+ await Assert.That(SubjectAliases.Resolve("Fotografie und Design")).IsNull();
+ await Assert.That(SubjectAliases.Resolve("Kunde de Lehre")).IsNull();
+ await Assert.That(SubjectAliases.Match("", "Mathematik")).IsEqualTo(SubjectMatch.None);
+ await Assert.That(SubjectAliases.AliasesOf("Mathe")).Contains("Mathematik");
+ await Assert.That(SubjectAliases.AliasesOf("Fotografie")).IsEquivalentTo(new[] { "Fotografie" });
+ }
+
+ [Test]
+ public async Task Teacher_names_are_taken_from_lesson_notes_that_look_like_names_only()
+ {
+ var teachers = MicrosoftTools.TeachersFromLessons(["Hans Muster", "Hans Muster, Anna Frei", "Bitte Laptop mitnehmen!", "Prüfung Test 3", null, ""]);
+ await Assert.That(teachers).IsEquivalentTo(new[] { "Hans Muster", "Anna Frei" });
+ await Assert.That(teachers[0]).IsEqualTo("Hans Muster");
+ }
+
+ // --- list_subjects ---
+
+ [Test]
+ public async Task List_subjects_merges_school_data_aliases_channels_notebooks_and_assessments()
+ {
+ _data = Sample();
+ var (host, client, _) = await StartAsync(nameof(List_subjects_merges_school_data_aliases_channels_notebooks_and_assessments), Only(Everything));
+ await using var _h = host;
+ await using var _c = client;
+
+ var result = await CallAsync(client, "list_subjects");
+ await Assert.That(result.GetProperty("subjects").GetArrayLength()).IsEqualTo(3);
+ await Assert.That(result.TryGetProperty("skipped", out _)).IsFalse();
+
+ var math = Subject(result, "Mathematik 4. LJ");
+ await Assert.That(math.GetProperty("canonical").GetString()).IsEqualTo("Mathematik");
+ await Assert.That(math.GetProperty("school").GetString()).IsEqualTo("GIBB");
+ await Assert.That(math.GetProperty("aliases").EnumerateArray().Select(a => a.GetString())).Contains("Mathe");
+ await Assert.That(math.GetProperty("teachers").EnumerateArray().Select(a => a.GetString()!)).IsEquivalentTo(new[] { "Hans Muster" });
+ var channel = math.GetProperty("teams").EnumerateArray().Single();
+ await Assert.That(channel.GetProperty("channelId").GetString()).IsEqualTo("c-math");
+ await Assert.That(channel.GetProperty("teamId").GetString()).IsEqualTo("t1");
+ await Assert.That(channel.GetProperty("confidence").GetString()).IsEqualTo("alias");
+ var section = math.GetProperty("notebooks").EnumerateArray().Single();
+ await Assert.That(section.GetProperty("sectionId").GetString()).IsEqualTo("s1");
+ await Assert.That(section.GetProperty("confidence").GetString()).IsEqualTo("exact");
+ await Assert.That(math.GetProperty("nextAssessment").GetProperty("name").GetString()).IsEqualTo("Test 4");
+ await Assert.That(math.GetProperty("nextAssessment").GetProperty("date").GetString()).IsEqualTo(Today(10).ToString("yyyy-MM-dd"));
+ await Assert.That(math.GetProperty("lastAssessment").GetProperty("name").GetString()).IsEqualTo("Test 3");
+
+ var wr = Subject(result, "Wirtschaft und Recht");
+ await Assert.That(wr.GetProperty("teams").EnumerateArray().Single().GetProperty("channelId").GetString()).IsEqualTo("c-wr");
+ await Assert.That(wr.TryGetProperty("teachers", out _)).IsFalse();
+ await Assert.That(wr.TryGetProperty("nextAssessment", out _)).IsFalse();
+ await Assert.That(wr.GetProperty("lastAssessment").GetProperty("name").GetString()).IsEqualTo("Recht Test");
+
+ var physics = Subject(result, "Physik");
+ await Assert.That(physics.GetProperty("teams").EnumerateArray().Single().GetProperty("channelId").GetString()).IsEqualTo("c-ph");
+ await Assert.That(physics.GetProperty("notebooks").EnumerateArray().Single().GetProperty("sectionId").GetString()).IsEqualTo("s3");
+ var fallback = physics.GetProperty("lastAssessment");
+ await Assert.That(fallback.GetProperty("source").GetString()).IsEqualTo("grade");
+ await Assert.That(fallback.GetProperty("score").GetDecimal()).IsEqualTo(5.5m);
+ }
+
+ [Test]
+ public async Task List_subjects_leaves_out_what_the_connection_may_not_read_and_says_so()
+ {
+ _data = Sample();
+ var (host, client, seen) = await StartAsync(nameof(List_subjects_leaves_out_what_the_connection_may_not_read_and_says_so), Only((McpArea.SchoolAccounts, McpAccessLevel.Read)));
+ await using var _h = host;
+ await using var _c = client;
+
+ var result = await CallAsync(client, "list_subjects");
+ await Assert.That(seen.IsEmpty).IsTrue();
+ var skipped = result.GetProperty("skipped").EnumerateArray().Select(s => s.GetProperty("source").GetString() + ":" + s.GetProperty("reason").GetString()).ToList();
+ await Assert.That(skipped).IsEquivalentTo(new[] { "exams:needs Exams read", "grades:needs Grades read", "teachers:needs Calendar read", "teams:needs Drive read", "onenote:needs OneNote read" });
+ var math = Subject(result, "Mathematik 4. LJ");
+ await Assert.That(math.GetProperty("teams").GetArrayLength()).IsEqualTo(0);
+ await Assert.That(math.TryGetProperty("nextAssessment", out _)).IsFalse();
+ await Assert.That(math.TryGetProperty("teachers", out _)).IsFalse();
+
+ var (teamsHost, teamsClient, teamsSeen) = await StartAsync(nameof(List_subjects_leaves_out_what_the_connection_may_not_read_and_says_so) + "B", Only((McpArea.SchoolAccounts, McpAccessLevel.Read), (McpArea.Drive, McpAccessLevel.Read), (McpArea.Exams, McpAccessLevel.Read)));
+ await using var _h2 = teamsHost;
+ await using var _c2 = teamsClient;
+ var partialAccess = await CallAsync(teamsClient, "list_subjects");
+ await Assert.That(teamsSeen.Any(s => s.Path.StartsWith("onenote"))).IsFalse();
+ await Assert.That(teamsSeen.Any(s => s.Path == "teams")).IsTrue();
+ await Assert.That(Subject(partialAccess, "Physik").TryGetProperty("lastAssessment", out _)).IsFalse().Because("the grade fallback needs Grades read");
+ await Assert.That(Subject(partialAccess, "Mathematik 4. LJ").GetProperty("nextAssessment").GetProperty("name").GetString()).IsEqualTo("Test 4");
+ }
+
+ [Test]
+ public async Task List_subjects_with_a_subject_returns_only_matching_subjects_and_the_search_names()
+ {
+ _data = Sample();
+ var (host, client, _) = await StartAsync(nameof(List_subjects_with_a_subject_returns_only_matching_subjects_and_the_search_names), Only(Everything));
+ await using var _h = host;
+ await using var _c = client;
+
+ var maths = await CallAsync(client, "list_subjects", new() { ["subject"] = "maths" });
+ await Assert.That(maths.GetProperty("subjects").EnumerateArray().Select(s => s.GetProperty("name").GetString()!)).IsEquivalentTo(new[] { "Mathematik 4. LJ" });
+ await Assert.That(maths.GetProperty("query").GetProperty("aliases").EnumerateArray().Select(a => a.GetString())).Contains("Mathematik");
+
+ var none = await CallAsync(client, "list_subjects", new() { ["subject"] = "Chemie" });
+ await Assert.That(none.GetProperty("subjects").GetArrayLength()).IsEqualTo(0);
+ await Assert.That(none.GetProperty("note").GetString()).Contains("aliases");
+ }
+
+ [Test]
+ public async Task List_subjects_reports_a_failing_source_and_caps_the_Microsoft_calls()
+ {
+ _data = Sample();
+ var teams = "[" + string.Join(",", Enumerable.Range(0, 15).Select(i => "{\"id\":\"t" + i + "\",\"displayName\":\"Team " + i + "\"}")) + "]";
+ var (host, client, seen) = await StartAsync(nameof(List_subjects_reports_a_failing_source_and_caps_the_Microsoft_calls), Only(Everything), path => path switch
+ {
+ "teams" => teams,
+ "onenote/notebooks" => null,
+ _ => path.EndsWith("/channels") ? """[{"id":"x","displayName":"Mathe"}]""" : "[]",
+ });
+ await using var _h = host;
+ await using var _c = client;
+
+ var result = await CallAsync(client, "list_subjects");
+ await Assert.That(result.GetProperty("partial").GetBoolean()).IsTrue();
+ await Assert.That(seen.Count(s => s.Path.EndsWith("/channels"))).IsEqualTo(12);
+ await Assert.That(result.GetProperty("errors").EnumerateArray().Any(e => e.GetString()!.StartsWith("notebooks:"))).IsTrue();
+ await Assert.That(Subject(result, "Mathematik 4. LJ").GetProperty("teams").GetArrayLength()).IsEqualTo(5);
+ }
+
+ [Test]
+ public async Task List_subjects_is_hidden_without_school_account_read()
+ {
+ var (host, client, _) = await StartAsync(nameof(List_subjects_is_hidden_without_school_account_read), Only((McpArea.Exams, McpAccessLevel.Read)));
+ await using var _h = host;
+ await using var _c = client;
+ await Assert.That((await client.ListToolsAsync()).Select(t => t.Name)).DoesNotContain("list_subjects");
+ }
+
+ private sealed class SchoolMediator : IMediator
+ {
+ private readonly EmptyMediator _inner = new();
+
+ public ValueTask Send(IQuery query, CancellationToken cancellationToken = default)
+ {
+ object? answer = query switch
+ {
+ GetMyLinkedAccountsQuery => Result>.Success([new LinkedAccountDto { Id = AccountId, Provider = "microsoft", Email = "student@school.test" }]),
+ GetSchoolUsersQuery => Result>.Success(_data.SchoolUsers),
+ GetExamsQuery => Result>.Success(_data.Exams),
+ GetAgendasQuery => Result>.Success(_data.Agenda),
+ _ => null,
+ };
+ return answer is null ? _inner.Send(query, cancellationToken) : new((TResponse)answer);
+ }
+
+ public ValueTask Send(IRequest request, CancellationToken cancellationToken = default) => _inner.Send(request, cancellationToken);
+ public ValueTask Send(ICommand command, CancellationToken cancellationToken = default) => _inner.Send(command, cancellationToken);
+ public ValueTask Send(object message, CancellationToken cancellationToken = default) => _inner.Send(message, cancellationToken);
+ public IAsyncEnumerable CreateStream(IStreamQuery query, CancellationToken cancellationToken = default) => _inner.CreateStream(query, cancellationToken);
+ public IAsyncEnumerable CreateStream(IStreamRequest request, CancellationToken cancellationToken = default) => _inner.CreateStream(request, cancellationToken);
+ public IAsyncEnumerable CreateStream(IStreamCommand command, CancellationToken cancellationToken = default) => _inner.CreateStream(command, cancellationToken);
+ public IAsyncEnumerable CreateStream(object message, CancellationToken cancellationToken = default) => _inner.CreateStream(message, cancellationToken);
+ public ValueTask Publish(TNotification notification, CancellationToken cancellationToken = default) where TNotification : INotification => _inner.Publish(notification, cancellationToken);
+ public ValueTask Publish(object notification, CancellationToken cancellationToken = default) => _inner.Publish(notification, cancellationToken);
+ }
+ }
+}
From 61e1b43baa1fba2b92798ce636b292b123f02afb Mon Sep 17 00:00:00 2001
From: PianoNic <79938743+Pianonic@users.noreply.github.com>
Date: Thu, 12 Nov 2026 10:32:08 +0100
Subject: [PATCH 2/2] Share teacher and Swiss date helpers, keep the prompt
test with the subject tests
---
.../Mcp/Tools/MicrosoftSubjectTools.cs | 36 ++-----------------
src/Schuly.API/Mcp/Tools/SubjectTeachers.cs | 26 ++++++++++++++
src/Schuly.API/Mcp/Tools/SwissTime.cs | 18 ++++++++++
src/Schuly.Tests/Helply/HelplyPolicyTests.cs | 9 -----
src/Schuly.Tests/Mcp/McpSubjectTests.cs | 12 ++++++-
5 files changed, 57 insertions(+), 44 deletions(-)
create mode 100644 src/Schuly.API/Mcp/Tools/SubjectTeachers.cs
create mode 100644 src/Schuly.API/Mcp/Tools/SwissTime.cs
diff --git a/src/Schuly.API/Mcp/Tools/MicrosoftSubjectTools.cs b/src/Schuly.API/Mcp/Tools/MicrosoftSubjectTools.cs
index a6563b24..7f25a1bc 100644
--- a/src/Schuly.API/Mcp/Tools/MicrosoftSubjectTools.cs
+++ b/src/Schuly.API/Mcp/Tools/MicrosoftSubjectTools.cs
@@ -80,7 +80,7 @@ public sealed partial class MicrosoftTools
}
partial |= errors.Count > 0;
- var today = TodayInSwitzerland();
+ var today = SwissTime.Today();
var subjects = rows.Select(r => SubjectOutput(r, exams, lessons, names, gradesAllowed, today)).ToList();
return Serialize(new
{
@@ -107,7 +107,7 @@ private static object SubjectOutput(SubjectRow r, List exams, List g.SchoolUserId == r.SchoolUserId).Score };
}
- var teachers = TeachersFromLessons(lessons.Where(a => a.ClassId == r.ClassId || (a.ClassId is null && known is not null && SubjectAliases.Resolve(a.Title)?.Key == known.Key)).Select(a => a.Description));
+ var teachers = SubjectTeachers.FromLessons(lessons.Where(a => a.ClassId == r.ClassId || (a.ClassId is null && known is not null && SubjectAliases.Resolve(a.Title)?.Key == known.Key)).Select(a => a.Description));
return new
{
subjectId = r.ClassId,
@@ -126,38 +126,6 @@ private static object SubjectOutput(SubjectRow r, List exams, List new { source, examId = e.Id, name = e.Name, date = e.Date, type = e.Type };
- ///
- /// Teacher names as far as the school passes them on: Schulnetz lessons carry them in the lesson note, so a note that looks like a short list of names
- /// (no digits, no sentence punctuation) counts, anything else is a comment and is ignored. The most frequent names come first, at most four.
- ///
- public static List TeachersFromLessons(IEnumerable notes)
- {
- var counts = new Dictionary(StringComparer.OrdinalIgnoreCase);
- foreach (var note in notes)
- {
- if (string.IsNullOrWhiteSpace(note) || note.Length > 80)
- continue;
- var parts = note.Split([',', ';', '/'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
- if (parts.Length == 0 || parts.Any(p => p.Length > 40 || p.Split(' ').Length > 4 || p.Any(c => char.IsDigit(c) || c is '.' or '!' or '?' or ':') || !char.IsUpper(p[0])))
- continue;
- foreach (var part in parts)
- counts[part] = counts.GetValueOrDefault(part) + 1;
- }
- return counts.OrderByDescending(c => c.Value).ThenBy(c => c.Key, StringComparer.OrdinalIgnoreCase).Take(4).Select(c => c.Key).ToList();
- }
-
- private static DateOnly TodayInSwitzerland()
- {
- try
- {
- return DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(DateTime.UtcNow, TimeZoneInfo.FindSystemTimeZoneById("Europe/Zurich")));
- }
- catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
- {
- return DateOnly.FromDateTime(DateTime.UtcNow);
- }
- }
-
/// Team, channel, notebook and section names of the user's Microsoft account, fetched with a cap on calls and a timeout.
private async Task LoadMicrosoftNamesAsync(Guid account, bool teams, bool notebooks, List errors, CancellationToken ct)
{
diff --git a/src/Schuly.API/Mcp/Tools/SubjectTeachers.cs b/src/Schuly.API/Mcp/Tools/SubjectTeachers.cs
new file mode 100644
index 00000000..62abcaa2
--- /dev/null
+++ b/src/Schuly.API/Mcp/Tools/SubjectTeachers.cs
@@ -0,0 +1,26 @@
+namespace Schuly.API.Mcp.Tools
+{
+ /// Teacher names out of Schulnetz lesson notes.
+ public static class SubjectTeachers
+ {
+ ///
+ /// Teacher names as far as the school passes them on: Schulnetz lessons carry them in the lesson note, so a note that looks like a short list of names
+ /// (no digits, no sentence punctuation) counts, anything else is a comment and is ignored. The most frequent names come first, at most four.
+ ///
+ public static List FromLessons(IEnumerable notes)
+ {
+ var counts = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var note in notes)
+ {
+ if (string.IsNullOrWhiteSpace(note) || note.Length > 80)
+ continue;
+ var parts = note.Split([',', ';', '/'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (parts.Length == 0 || parts.Any(p => p.Length > 40 || p.Split(' ').Length > 4 || p.Any(c => char.IsDigit(c) || c is '.' or '!' or '?' or ':') || !char.IsUpper(p[0])))
+ continue;
+ foreach (var part in parts)
+ counts[part] = counts.GetValueOrDefault(part) + 1;
+ }
+ return counts.OrderByDescending(c => c.Value).ThenBy(c => c.Key, StringComparer.OrdinalIgnoreCase).Take(4).Select(c => c.Key).ToList();
+ }
+ }
+}
diff --git a/src/Schuly.API/Mcp/Tools/SwissTime.cs b/src/Schuly.API/Mcp/Tools/SwissTime.cs
new file mode 100644
index 00000000..9e9911f5
--- /dev/null
+++ b/src/Schuly.API/Mcp/Tools/SwissTime.cs
@@ -0,0 +1,18 @@
+namespace Schuly.API.Mcp.Tools
+{
+ /// "Today" the way a Swiss student sees it, so a test tomorrow morning is still upcoming at 23:30 UTC.
+ public static class SwissTime
+ {
+ public static DateOnly Today()
+ {
+ try
+ {
+ return DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(DateTime.UtcNow, TimeZoneInfo.FindSystemTimeZoneById("Europe/Zurich")));
+ }
+ catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
+ {
+ return DateOnly.FromDateTime(DateTime.UtcNow);
+ }
+ }
+ }
+}
diff --git a/src/Schuly.Tests/Helply/HelplyPolicyTests.cs b/src/Schuly.Tests/Helply/HelplyPolicyTests.cs
index 68b52689..60f33dfc 100644
--- a/src/Schuly.Tests/Helply/HelplyPolicyTests.cs
+++ b/src/Schuly.Tests/Helply/HelplyPolicyTests.cs
@@ -104,14 +104,5 @@ public async Task The_prompt_teaches_the_search_workflow_with_real_tool_names()
foreach (var tool in new[] { "list_exams", "list_grades", "search_files", "search_pages", "list_notebooks", "list_sections", "list_pages", "read_page", "list_teams", "search_channel_messages", "list_team_files", "list_mail", "read_mail" })
await Assert.That(prompt).Contains(tool);
}
-
- [Test]
- public async Task The_prompt_resolves_the_subject_with_list_subjects_and_keeps_the_manual_fallback()
- {
- var prompt = HelplyPrompt.System(DateTimeOffset.UtcNow);
-
- await Assert.That(prompt).Contains("call list_subjects with the user's word as subject");
- await Assert.That(prompt).Contains("If it is unavailable or finds nothing, collect subject names yourself from list_grades");
- }
}
}
diff --git a/src/Schuly.Tests/Mcp/McpSubjectTests.cs b/src/Schuly.Tests/Mcp/McpSubjectTests.cs
index 5b396f64..825517c9 100644
--- a/src/Schuly.Tests/Mcp/McpSubjectTests.cs
+++ b/src/Schuly.Tests/Mcp/McpSubjectTests.cs
@@ -10,6 +10,7 @@
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
+using Schuly.API.Helply;
using Schuly.API.Mcp.Tools;
using Schuly.API.Plugins;
using Schuly.Application.Dtos;
@@ -180,7 +181,7 @@ public async Task Different_subjects_and_short_abbreviations_inside_other_words_
[Test]
public async Task Teacher_names_are_taken_from_lesson_notes_that_look_like_names_only()
{
- var teachers = MicrosoftTools.TeachersFromLessons(["Hans Muster", "Hans Muster, Anna Frei", "Bitte Laptop mitnehmen!", "Prüfung Test 3", null, ""]);
+ var teachers = SubjectTeachers.FromLessons(["Hans Muster", "Hans Muster, Anna Frei", "Bitte Laptop mitnehmen!", "Prüfung Test 3", null, ""]);
await Assert.That(teachers).IsEquivalentTo(new[] { "Hans Muster", "Anna Frei" });
await Assert.That(teachers[0]).IsEqualTo("Hans Muster");
}
@@ -303,6 +304,15 @@ public async Task List_subjects_is_hidden_without_school_account_read()
await Assert.That((await client.ListToolsAsync()).Select(t => t.Name)).DoesNotContain("list_subjects");
}
+ [Test]
+ public async Task The_prompt_resolves_the_subject_with_list_subjects_and_keeps_the_manual_fallback()
+ {
+ var prompt = HelplyPrompt.System(DateTimeOffset.UtcNow);
+
+ await Assert.That(prompt).Contains("call list_subjects with the user's word as subject");
+ await Assert.That(prompt).Contains("If it is unavailable or finds nothing, collect subject names yourself from list_grades");
+ }
+
private sealed class SchoolMediator : IMediator
{
private readonly EmptyMediator _inner = new();