From f89f81cb644926b71cb9b9dbf53d3307d4e5a2ae Mon Sep 17 00:00:00 2001
From: PianoNic <79938743+Pianonic@users.noreply.github.com>
Date: Thu, 12 Nov 2026 10:30:11 +0100
Subject: [PATCH] Add list_assessments MCP tool
---
src/Schuly.API/Helply/HelplyPrompt.cs | 2 +-
src/Schuly.API/Helply/HelplyToolLabels.cs | 1 +
src/Schuly.API/Mcp/Tools/AssessmentKinds.cs | 47 +++
.../Mcp/Tools/SchulyAssessmentTools.cs | 131 ++++++++
src/Schuly.API/Mcp/Tools/SchulyTools.cs | 2 +-
src/Schuly.API/Mcp/Tools/SubjectAliases.cs | 209 +++++++++++++
src/Schuly.API/Mcp/Tools/SubjectTeachers.cs | 26 ++
src/Schuly.API/Mcp/Tools/SwissTime.cs | 18 ++
src/Schuly.Tests/Mcp/McpAssessmentTests.cs | 282 ++++++++++++++++++
src/Schuly.Tests/Mcp/McpEndpointTests.cs | 2 +-
10 files changed, 717 insertions(+), 3 deletions(-)
create mode 100644 src/Schuly.API/Mcp/Tools/AssessmentKinds.cs
create mode 100644 src/Schuly.API/Mcp/Tools/SchulyAssessmentTools.cs
create mode 100644 src/Schuly.API/Mcp/Tools/SubjectAliases.cs
create mode 100644 src/Schuly.API/Mcp/Tools/SubjectTeachers.cs
create mode 100644 src/Schuly.API/Mcp/Tools/SwissTime.cs
create mode 100644 src/Schuly.Tests/Mcp/McpAssessmentTests.cs
diff --git a/src/Schuly.API/Helply/HelplyPrompt.cs b/src/Schuly.API/Helply/HelplyPrompt.cs
index cebd0c15..4667d79b 100644
--- a/src/Schuly.API/Helply/HelplyPrompt.cs
+++ b/src/Schuly.API/Helply/HelplyPrompt.cs
@@ -27,7 +27,7 @@ How you work
Finding tests, dates, learning objectives and prep material
- Schools, and even teachers at the same school, publish this in different places. Never assume where something is: find out, and check every source you have before saying it doesn't exist.
- - 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.
+ - First pin down the assessment. Use list_assessments with a date range (it merges list_exams with agenda entries that announce a test, presentation or hand-in; fall back to list_exams and list_agenda if it is unavailable), 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.
diff --git a/src/Schuly.API/Helply/HelplyToolLabels.cs b/src/Schuly.API/Helply/HelplyToolLabels.cs
index fa6d6ce1..68faa773 100644
--- a/src/Schuly.API/Helply/HelplyToolLabels.cs
+++ b/src/Schuly.API/Helply/HelplyToolLabels.cs
@@ -24,6 +24,7 @@ public static class HelplyToolLabels
["list_semester_reports"] = ("Looking up your semester reports", "Looked up your semester reports"),
["list_exams"] = ("Checking your exams", "Checked your exams"),
["list_agenda"] = ("Checking your agenda", "Checked your agenda"),
+ ["list_assessments"] = ("Looking up your tests and deadlines", "Looked up your tests and deadlines"),
["list_absences"] = ("Checking your absences", "Checked your absences"),
["list_documents"] = ("Looking through your documents", "Looked through your documents"),
["read_document"] = ("Reading a document", "Read a document"),
diff --git a/src/Schuly.API/Mcp/Tools/AssessmentKinds.cs b/src/Schuly.API/Mcp/Tools/AssessmentKinds.cs
new file mode 100644
index 00000000..7e656c8b
--- /dev/null
+++ b/src/Schuly.API/Mcp/Tools/AssessmentKinds.cs
@@ -0,0 +1,47 @@
+namespace Schuly.API.Mcp.Tools
+{
+ /// Recognises tests, presentations and hand-ins in the free text of an agenda entry, in German, French, Italian and English.
+ public static class AssessmentKinds
+ {
+ public const string Exam = "exam";
+ public const string Presentation = "presentation";
+ public const string HandIn = "hand-in";
+ public const string Other = "other";
+
+ // Words a token may merely contain: long and specific enough that German compounds (Mathetest, Projektabgabe, Schlusspruefung) are caught too.
+ private static readonly string[] PresentationParts = ["prasentation", "praesentation", "presentation", "presentazione", "vortrag", "referat", "expose", "exposition"];
+ private static readonly string[] ExamParts = ["prufung", "pruefung", "lernkontrolle", "klausur", "epreuve", "controle", "interrogation", "mundlich", "muendlich", "verific"];
+ private static readonly string[] HandInParts = ["abgabe", "abgeben", "einreichen", "projekt", "project", "projet", "progetto", "hausarbeit", "deadline", "submission", "consegna", "portfolio"];
+
+ // Short words only count as whole tokens, so "test" is found but "latest" and "contest" are not.
+ private static readonly string[] ExamWords = ["test", "tests", "lk", "lks", "exam", "exams", "examen", "examens", "esame", "esami", "quiz", "oral", "orale", "orali"];
+ private static readonly string[] HandInWords = ["rendu", "hand", "handin"];
+ private static readonly string[] NotATest = ["protest", "kontest", "contest", "attest", "latest", "detest"];
+ private static readonly string[] Negations = ["kein", "keine", "keiner", "keinen", "ohne", "no", "not", "pas", "sans", "nessun", "nessuna", "senza"];
+
+ ///
+ /// The kind of assessment the text announces (exam, presentation, hand-in), or null when it announces none. A presentation wins over an exam, an exam over a hand-in
+ /// ("Projektprüfung" is an exam, "Abgabe Projekt" a hand-in). A word right after "kein", "ohne", "no" and the like does not count.
+ ///
+ public static string? Classify(string? title, string? description)
+ {
+ var tokens = SubjectAliases.Normalize(title + " " + (description is { Length: > 300 } ? description[..300] : description)).Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var presentation = false;
+ var exam = false;
+ var handIn = false;
+ for (var i = 0; i < tokens.Length; i++)
+ {
+ if (i > 0 && Negations.Contains(tokens[i - 1]))
+ continue;
+ var t = tokens[i];
+ if (PresentationParts.Any(t.Contains))
+ presentation = true;
+ else if (ExamParts.Any(t.Contains) || ExamWords.Contains(t) || (t.Length >= 7 && t.EndsWith("test", StringComparison.Ordinal) && !NotATest.Contains(t)))
+ exam = true;
+ else if (HandInParts.Any(t.Contains) || HandInWords.Contains(t) && (t != "hand" || (i + 1 < tokens.Length && tokens[i + 1] == "in")))
+ handIn = true;
+ }
+ return presentation ? Presentation : exam ? Exam : handIn ? HandIn : null;
+ }
+ }
+}
diff --git a/src/Schuly.API/Mcp/Tools/SchulyAssessmentTools.cs b/src/Schuly.API/Mcp/Tools/SchulyAssessmentTools.cs
new file mode 100644
index 00000000..ef3b7796
--- /dev/null
+++ b/src/Schuly.API/Mcp/Tools/SchulyAssessmentTools.cs
@@ -0,0 +1,131 @@
+using System.ComponentModel;
+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
+{
+ /// Assessments in one list: the exams the school entered, plus the agenda entries that announce a test, presentation or hand-in, without the same test twice.
+ public sealed partial class SchulyTools
+ {
+ private const int DefaultAssessmentDays = 30;
+
+ [McpServerTool(Name = "list_assessments", Title = "List assessments", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)]
+ [Description("Lists the user's assessments (tests, exams, presentations, hand-ins, oral exams) in a date range in ONE list, newest dates last: the exams from list_exams merged with agenda entries that look like assessments (words such as Test, Prüfung, LK, Lernkontrolle, Vortrag, Präsentation, Abgabe, Projekt, mündlich, examen, épreuve, contrôle, verifica, esame, presentation, hand-in in German, French, Italian and English). Each item has date, time if known, subject, title, kind (exam, presentation, hand-in, other), source (exams or agenda) and the teacher if known. The same test in exams and agenda is listed once. Default range: today to 30 days ahead. subject accepts any name of the subject in any language (Mathe, Mathematik, maths). It does NOT read schedule documents such as Masterplan, Semesterplan or Prüfungsplan, and teachers often enter tests late, so an empty list does not mean there is none: for those documents use search and read_file.")]
+ [McpAccess(McpArea.Exams, McpAccessLevel.Read)]
+ public async Task ListAssessments([Description("First day, yyyy-MM-dd. Default today.")] DateOnly? from = null, [Description("Last day, yyyy-MM-dd. Default 30 days after from.")] DateOnly? to = null, [Description("Only this subject, by any of its names or abbreviations.")] string? subject = null, [Description("Items to skip, for paging.")] int offset = 0, [Description("Items per page, 1-200.")] int limit = 100, CancellationToken ct = default)
+ {
+ var start = from ?? SwissTime.Today();
+ var end = to ?? start.AddDays(DefaultAssessmentDays);
+ if (end < start)
+ throw new McpException("to must not be before from.");
+
+ var permissions = Caller(accessor).Permissions;
+ var schoolUsers = Unwrap(await mediator.Send(new GetSchoolUsersQuery(), ct));
+ var classNames = schoolUsers.SelectMany(s => s.Classes).GroupBy(c => c.ClassId).ToDictionary(g => g.Key, g => g.First().ClassName);
+ var exams = Unwrap(await mediator.Send(new GetExamsQuery(), ct));
+ var searched = new List { "exams" };
+ var skipped = new List();
+ List agenda = [];
+ if (permissions.Allows(McpArea.Calendar, McpAccessLevel.Read))
+ {
+ searched.Add("agenda");
+ agenda = Unwrap(await mediator.Send(new GetAgendasQuery(), ct));
+ }
+ else
+ skipped.Add(new { source = "agenda", reason = "needs Calendar read" });
+
+ var items = Assessments(exams, agenda, classNames, start, end, subject);
+ return Serialize(new
+ {
+ from = start,
+ to = end,
+ subject = string.IsNullOrWhiteSpace(subject) ? null : subject.Trim(),
+ searched,
+ skipped = skipped.Count == 0 ? null : skipped,
+ note = "Only exams and agenda entries are read, not schedule documents (Masterplan, Semesterplan, Prüfungsplan). If something is missing, search for those with search and read them with read_file.",
+ assessments = Page(items.Select(i => new { date = i.Date, time = i.Time?.ToString("HH:mm"), subject = i.Subject, title = i.Title, kind = i.Kind, source = i.Source, teachers = i.Teachers.Count == 0 ? null : i.Teachers, alsoInAgenda = i.AlsoInAgenda ? true : (bool?)null, examId = i.ExamId, agendaEntryId = i.AgendaEntryId }), offset, limit, "assessments"),
+ });
+ }
+
+ /// The merged, de-duplicated assessments in the range, ordered by date and time. Public so the rules can be tested without a server.
+ public static List Assessments(IReadOnlyList exams, IReadOnlyList agenda, IReadOnlyDictionary classNames, DateOnly from, DateOnly to, string? subject)
+ {
+ string? ClassName(Guid? classId) => classId is { } id ? classNames.GetValueOrDefault(id) : null;
+ string Key(string? name) => string.IsNullOrWhiteSpace(name) ? string.Empty : SubjectAliases.Resolve(name)?.Key ?? SubjectAliases.Clean(name);
+
+ var lessons = agenda.Where(a => a.EntryType == AgendaEntryType.Lesson && AssessmentKinds.Classify(null, a.Description) is null).ToList();
+ var teachersByClass = lessons.Where(a => a.ClassId is not null).GroupBy(a => a.ClassId!.Value).ToDictionary(g => g.Key, g => SubjectTeachers.FromLessons(g.Select(a => a.Description)));
+ var teachersByKey = lessons.GroupBy(a => Key(ClassName(a.ClassId) ?? a.Title)).Where(g => g.Key.Length > 0).ToDictionary(g => g.Key, g => SubjectTeachers.FromLessons(g.Select(a => a.Description)));
+ List TeachersOf(Guid? classId, string? name) => classId is { } id && teachersByClass.TryGetValue(id, out var byClass) && byClass.Count > 0 ? byClass : teachersByKey.GetValueOrDefault(Key(name)) ?? [];
+
+ bool Wanted(string? name, string? title)
+ {
+ if (string.IsNullOrWhiteSpace(subject))
+ return true;
+ if (name is not null)
+ return SubjectAliases.Match(subject, name) != SubjectMatch.None;
+ return SubjectAliases.Resolve(subject) is { } wanted && SubjectAliases.Resolve(title)?.Key == wanted.Key;
+ }
+
+ var items = new List();
+ foreach (var exam in exams.Where(e => e.Date is { } d && d >= from && d <= to))
+ {
+ var name = ClassName(exam.ClassId);
+ if (!Wanted(name, exam.Name))
+ continue;
+ items.Add(new AssessmentItem(exam.Date!.Value, null, name, exam.Name, AssessmentKinds.Classify(exam.Name, exam.Description) ?? AssessmentKinds.Exam, "exams", TeachersOf(exam.ClassId, name), exam.Id, null) { Key = Key(name) });
+ }
+
+ var seen = new HashSet<(string Key, DateOnly Date, string Title)>();
+ foreach (var entry in agenda.Where(a => a.EntryType != AgendaEntryType.Holiday).OrderBy(a => a.Date))
+ {
+ var day = DateOnly.FromDateTime(entry.Date);
+ if (day < from || day > to)
+ continue;
+ var kind = AssessmentKinds.Classify(entry.Title, entry.Description) ?? (entry.EntryType == AgendaEntryType.Test ? AssessmentKinds.Other : null);
+ if (kind is null)
+ continue;
+ var name = ClassName(entry.ClassId) ?? SubjectAliases.Resolve(entry.Title)?.Name;
+ if (!Wanted(name, entry.Title))
+ continue;
+ var key = Key(name);
+ TimeOnly? time = entry.Date.TimeOfDay == TimeSpan.Zero ? null : TimeOnly.FromTimeSpan(entry.Date.TimeOfDay);
+
+ var title = AssessmentKinds.Classify(entry.Title, null) is null && AssessmentKinds.Classify(null, entry.Description) is not null ? $"{entry.Title}: {Cap(entry.Description!.Trim(), 120)}" : entry.Title;
+ var twin = items.FirstOrDefault(i => i.Source == "exams" && i.Date == day && (key.Length > 0 ? i.Key == key : SubjectAliases.Normalize(entry.Title + " " + entry.Description).Contains(SubjectAliases.Normalize(i.Title))));
+ if (twin is not null)
+ {
+ twin.AlsoInAgenda = true;
+ twin.Time ??= time;
+ continue;
+ }
+ if (!seen.Add((key.Length > 0 ? key : SubjectAliases.Clean(entry.Title), day, SubjectAliases.Normalize(title))))
+ continue;
+ items.Add(new AssessmentItem(day, time, name, title, kind, "agenda", TeachersOf(entry.ClassId, name), null, entry.Id) { Key = key });
+ }
+ return items.OrderBy(i => i.Date).ThenBy(i => i.Time ?? TimeOnly.MinValue).ThenBy(i => i.Subject, StringComparer.OrdinalIgnoreCase).ToList();
+ }
+ }
+
+ public sealed class AssessmentItem(DateOnly date, TimeOnly? time, string? subject, string title, string kind, string source, List teachers, Guid? examId, Guid? agendaEntryId)
+ {
+ public DateOnly Date { get; } = date;
+ public TimeOnly? Time { get; set; } = time;
+ public string? Subject { get; } = subject;
+ public string Title { get; } = title;
+ public string Kind { get; } = kind;
+ public string Source { get; } = source;
+ public List Teachers { get; } = teachers;
+ public Guid? ExamId { get; } = examId;
+ public Guid? AgendaEntryId { get; } = agendaEntryId;
+ public string Key { get; init; } = string.Empty;
+ public bool AlsoInAgenda { get; set; }
+ }
+}
diff --git a/src/Schuly.API/Mcp/Tools/SchulyTools.cs b/src/Schuly.API/Mcp/Tools/SchulyTools.cs
index 2cfd71e0..e6185a4d 100644
--- a/src/Schuly.API/Mcp/Tools/SchulyTools.cs
+++ b/src/Schuly.API/Mcp/Tools/SchulyTools.cs
@@ -19,7 +19,7 @@ namespace Schuly.API.Mcp.Tools
{
/// The user's own school data, read through the same Mediator queries as the REST API and so under the same checks.
[McpServerToolType]
- public sealed class SchulyTools(IMediator mediator, IHttpContextAccessor accessor, InProcessPluginApi plugins)
+ public sealed partial class SchulyTools(IMediator mediator, IHttpContextAccessor accessor, InProcessPluginApi plugins)
{
private const int MaxDocumentBytes = 2 * 1024 * 1024;
private const int MaxBase64Bytes = 256 * 1024;
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.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/Mcp/McpAssessmentTests.cs b/src/Schuly.Tests/Mcp/McpAssessmentTests.cs
new file mode 100644
index 00000000..4bd1a5a0
--- /dev/null
+++ b/src/Schuly.Tests/Mcp/McpAssessmentTests.cs
@@ -0,0 +1,282 @@
+using System.Text.Json;
+using Mediator;
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using Schuly.API.Helply;
+using Schuly.API.Mcp.Tools;
+using Schuly.Application.Dtos;
+using Schuly.Application.Models;
+using Schuly.Application.Queries.Agenda;
+using Schuly.Application.Queries.Exam;
+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
+{
+ /// Assessment keyword detection and the list_assessments tool: merging exams and agenda, de-duplication, subject aliases, date range and permissions.
+ [NotInParallel]
+ public class McpAssessmentTests
+ {
+ 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 PhysicsId = Guid.Parse("c0000000-0000-0000-0000-000000000003");
+
+ 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 Day(int days) => DateOnly.FromDateTime(DateTime.UtcNow).AddDays(days);
+
+ private static DateTime At(int days, int hour = 0, int minute = 0) => DateTime.UtcNow.Date.AddDays(days).AddHours(hour).AddMinutes(minute);
+
+ private static AgendaEntryDto Entry(AgendaEntryType type, string title, DateTime date, Guid? classId = null, string? description = null) => new() { EntryType = type, Title = title, Description = description, Date = date, ClassId = classId };
+
+ 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 = PhysicsId, ClassName = "Physik" }],
+ },
+ ],
+ Exams =
+ [
+ new ExamDto { Id = Guid.NewGuid(), Name = "Test 4", ClassId = MathId, Date = Day(5), ClassAverage = 0 },
+ new ExamDto { Id = Guid.NewGuid(), Name = "Schlusspruefung", ClassId = PhysicsId, Date = Day(40), ClassAverage = 0 },
+ new ExamDto { Id = Guid.NewGuid(), Name = "Test 2", ClassId = MathId, Date = Day(-30), ClassAverage = 0 },
+ new ExamDto { Id = Guid.NewGuid(), Name = "Ohne Datum", ClassId = MathId, Date = null, ClassAverage = 0 },
+ ],
+ Agenda =
+ [
+ Entry(AgendaEntryType.Lesson, "Mathematik", At(1, 8), MathId, "Hans Muster"),
+ Entry(AgendaEntryType.Lesson, "Mathematik", At(2, 8), MathId, "Hans Muster"),
+ Entry(AgendaEntryType.Test, "Mathe Test", At(5, 8, 15), MathId),
+ Entry(AgendaEntryType.Event, "Präsentation Marketing", At(7, 14)),
+ Entry(AgendaEntryType.Lesson, "Mathematik", At(9, 10), MathId, "Abgabe Projekt"),
+ Entry(AgendaEntryType.Lesson, "Mathematik", At(9, 11), MathId, "Abgabe Projekt"),
+ Entry(AgendaEntryType.Test, "Biologie", At(3)),
+ Entry(AgendaEntryType.Holiday, "Prüfungsfrei", At(4)),
+ Entry(AgendaEntryType.Test, "Physik Test", At(-3), PhysicsId),
+ ],
+ };
+
+ 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 async Task<(McpHttpHost Host, McpClient Client)> StartAsync(string name, McpPermissionSet permissions)
+ {
+ var host = await McpHttpHost.StartAsync(name, services => services.AddScoped());
+ var userId = host.Scoped(sp => NewUser(sp.GetRequiredService()).User.Id);
+ return (host, await host.ConnectAsync(await host.ApiKeyAsync(userId, permissions)));
+ }
+
+ 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 List Items(JsonElement result) => result.GetProperty("assessments").GetProperty("assessments").EnumerateArray().ToList();
+
+ private static string[] Titles(JsonElement result) => Items(result).Select(i => i.GetProperty("title").GetString()!).ToArray();
+
+ [Test]
+ [Arguments("Test 3", "exam")]
+ [Arguments("Mathetest", "exam")]
+ [Arguments("Prüfung Kapitel 4", "exam")]
+ [Arguments("Schlusspruefung", "exam")]
+ [Arguments("LK Physik", "exam")]
+ [Arguments("Lernkontrolle Gleichungen", "exam")]
+ [Arguments("Mündliche Prüfung", "exam")]
+ [Arguments("Contrôle de maths", "exam")]
+ [Arguments("Épreuve de français", "exam")]
+ [Arguments("Verifica di storia", "exam")]
+ [Arguments("Esame finale", "exam")]
+ [Arguments("Vortrag Marketing", "presentation")]
+ [Arguments("Referat", "presentation")]
+ [Arguments("Präsentation Projekt", "presentation")]
+ [Arguments("Presentation of the report", "presentation")]
+ [Arguments("Abgabe Projektarbeit", "hand-in")]
+ [Arguments("Hand-in report", "hand-in")]
+ [Arguments("Rendu du dossier", "hand-in")]
+ [Arguments("Consegna progetto", "hand-in")]
+ [Arguments("Projektprüfung", "exam")]
+ public async Task Assessment_words_in_four_languages_are_recognised(string text, string kind)
+ {
+ await Assert.That(AssessmentKinds.Classify(text, null)).IsEqualTo(kind);
+ }
+
+ [Test]
+ [Arguments("Mathematik")]
+ [Arguments("Stundenplan")]
+ [Arguments("Protest gegen Lärm")]
+ [Arguments("latest news")]
+ [Arguments("Kein Test heute")]
+ [Arguments("Ohne Prüfung")]
+ [Arguments("example")]
+ [Arguments("Hand Training")]
+ public async Task Other_text_is_not_an_assessment(string text)
+ {
+ await Assert.That(AssessmentKinds.Classify(text, null)).IsNull();
+ }
+
+ [Test]
+ public async Task The_lesson_note_counts_too()
+ {
+ await Assert.That(AssessmentKinds.Classify("Mathematik", "Nächste Woche Prüfung zu Kapitel 3")).IsEqualTo("exam");
+ await Assert.That(AssessmentKinds.Classify("Mathematik", "Hans Muster")).IsNull();
+ }
+
+ [Test]
+ public async Task List_assessments_merges_exams_and_agenda_and_lists_the_same_test_once()
+ {
+ _data = Sample();
+ var (host, client) = await StartAsync(nameof(List_assessments_merges_exams_and_agenda_and_lists_the_same_test_once), Only((McpArea.Exams, McpAccessLevel.Read), (McpArea.Calendar, McpAccessLevel.Read)));
+ await using var _h = host;
+ await using var _c = client;
+
+ var result = await CallAsync(client, "list_assessments");
+
+ // Default range: today to +30 days. Past entries, the exam in 40 days, the holiday, plain lessons and the exam without a date are not in it.
+ await Assert.That(Titles(result)).IsEquivalentTo(new[] { "Biologie", "Test 4", "Präsentation Marketing", "Mathematik: Abgabe Projekt" }, TUnit.Assertions.Enums.CollectionOrdering.Matching);
+ var items = Items(result);
+
+ var exam = items[1];
+ await Assert.That(exam.GetProperty("source").GetString()).IsEqualTo("exams");
+ await Assert.That(exam.GetProperty("kind").GetString()).IsEqualTo("exam");
+ await Assert.That(exam.GetProperty("subject").GetString()).IsEqualTo("Mathematik 4. LJ");
+ await Assert.That(exam.GetProperty("date").GetString()).IsEqualTo(Day(5).ToString("yyyy-MM-dd"));
+ await Assert.That(exam.GetProperty("time").GetString()).IsEqualTo("08:15").Because("the time comes from the matching agenda entry");
+ await Assert.That(exam.GetProperty("alsoInAgenda").GetBoolean()).IsTrue();
+ await Assert.That(exam.GetProperty("teachers").EnumerateArray().Select(t => t.GetString()!)).Contains("Hans Muster");
+
+ var other = items[0];
+ await Assert.That(other.GetProperty("source").GetString()).IsEqualTo("agenda");
+ await Assert.That(other.GetProperty("kind").GetString()).IsEqualTo("other");
+ await Assert.That(other.GetProperty("subject").GetString()).IsEqualTo("Biologie");
+
+ var presentation = items[2];
+ await Assert.That(presentation.GetProperty("kind").GetString()).IsEqualTo("presentation");
+ await Assert.That(presentation.GetProperty("time").GetString()).IsEqualTo("14:00");
+ await Assert.That(presentation.TryGetProperty("subject", out _)).IsFalse();
+
+ var handIn = items[3];
+ await Assert.That(handIn.GetProperty("kind").GetString()).IsEqualTo("hand-in");
+ await Assert.That(handIn.GetProperty("subject").GetString()).IsEqualTo("Mathematik 4. LJ");
+
+ await Assert.That(result.GetProperty("searched").EnumerateArray().Select(s => s.GetString()!)).IsEquivalentTo(new[] { "exams", "agenda" });
+ await Assert.That(result.GetProperty("note").GetString()).Contains("not schedule documents");
+ }
+
+ [Test]
+ public async Task List_assessments_filters_by_subject_alias_and_by_date_range()
+ {
+ _data = Sample();
+ var (host, client) = await StartAsync(nameof(List_assessments_filters_by_subject_alias_and_by_date_range), Only((McpArea.Exams, McpAccessLevel.Read), (McpArea.Calendar, McpAccessLevel.Read)));
+ await using var _h = host;
+ await using var _c = client;
+
+ var mathe = await CallAsync(client, "list_assessments", new() { ["subject"] = "Mathe" });
+ await Assert.That(Titles(mathe)).IsEquivalentTo(new[] { "Test 4", "Mathematik: Abgabe Projekt" }, TUnit.Assertions.Enums.CollectionOrdering.Matching);
+
+ var bio = await CallAsync(client, "list_assessments", new() { ["subject"] = "biology" });
+ await Assert.That(Titles(bio)).IsEquivalentTo(new[] { "Biologie" });
+
+ var physique = await CallAsync(client, "list_assessments", new() { ["subject"] = "physique", ["to"] = Day(60).ToString("yyyy-MM-dd") });
+ await Assert.That(Titles(physique)).IsEquivalentTo(new[] { "Schlusspruefung" });
+
+ var past = await CallAsync(client, "list_assessments", new() { ["from"] = Day(-40).ToString("yyyy-MM-dd"), ["to"] = Day(-1).ToString("yyyy-MM-dd") });
+ await Assert.That(Titles(past)).IsEquivalentTo(new[] { "Test 2", "Physik Test" }, TUnit.Assertions.Enums.CollectionOrdering.Matching);
+
+ var bad = await client.CallToolAsync("list_assessments", new Dictionary { ["from"] = Day(5).ToString("yyyy-MM-dd"), ["to"] = Day(1).ToString("yyyy-MM-dd") });
+ await Assert.That(bad.IsError ?? false).IsTrue();
+ }
+
+ [Test]
+ public async Task List_assessments_reads_the_agenda_only_with_Calendar_read()
+ {
+ _data = Sample();
+ var (host, client) = await StartAsync(nameof(List_assessments_reads_the_agenda_only_with_Calendar_read), Only((McpArea.Exams, McpAccessLevel.Read)));
+ await using var _h = host;
+ await using var _c = client;
+
+ var result = await CallAsync(client, "list_assessments");
+ await Assert.That(Titles(result)).IsEquivalentTo(new[] { "Test 4" });
+ await Assert.That(Items(result)[0].TryGetProperty("teachers", out _)).IsFalse();
+ await Assert.That(result.GetProperty("searched").EnumerateArray().Select(s => s.GetString()!)).IsEquivalentTo(new[] { "exams" });
+ await Assert.That(result.GetProperty("skipped")[0].GetProperty("reason").GetString()).IsEqualTo("needs Calendar read");
+
+ var (hiddenHost, hiddenClient) = await StartAsync(nameof(List_assessments_reads_the_agenda_only_with_Calendar_read) + "B", Only((McpArea.Calendar, McpAccessLevel.Read)));
+ await using var _h2 = hiddenHost;
+ await using var _c2 = hiddenClient;
+ await Assert.That((await hiddenClient.ListToolsAsync()).Select(t => t.Name)).DoesNotContain("list_assessments");
+ }
+
+ [Test]
+ public async Task The_tool_description_says_it_does_not_read_schedule_documents()
+ {
+ _data = Sample();
+ var (host, client) = await StartAsync(nameof(The_tool_description_says_it_does_not_read_schedule_documents), Only((McpArea.Exams, McpAccessLevel.Read)));
+ await using var _h = host;
+ await using var _c = client;
+
+ var tool = (await client.ListToolsAsync()).Single(t => t.Name == "list_assessments");
+ await Assert.That(tool.Description).Contains("does NOT read schedule documents");
+ await Assert.That(tool.Description).Contains("search and read_file");
+ }
+
+ [Test]
+ public async Task The_prompt_finds_dates_with_list_assessments_and_keeps_the_fallbacks()
+ {
+ var prompt = HelplyPrompt.System(DateTimeOffset.UtcNow);
+
+ await Assert.That(prompt).Contains("Use list_assessments with a date range");
+ await Assert.That(prompt).Contains("fall back to list_exams and list_agenda");
+ await Assert.That(prompt).Contains("an empty result does not mean there is no test");
+ }
+
+ private sealed class SchoolMediator : IMediator
+ {
+ private readonly EmptyMediator _inner = new();
+
+ public ValueTask Send(IQuery query, CancellationToken cancellationToken = default)
+ {
+ object? answer = query switch
+ {
+ 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);
+ }
+ }
+}
diff --git a/src/Schuly.Tests/Mcp/McpEndpointTests.cs b/src/Schuly.Tests/Mcp/McpEndpointTests.cs
index faf84eb3..edf682ad 100644
--- a/src/Schuly.Tests/Mcp/McpEndpointTests.cs
+++ b/src/Schuly.Tests/Mcp/McpEndpointTests.cs
@@ -336,7 +336,7 @@ public async Task An_internal_token_for_helply_works_through_the_official_client
var names = (await client.ListToolsAsync()).Select(t => t.Name).ToList();
await Assert.That(endpoint.ToString()).IsEqualTo("http://localhost/mcp");
- await Assert.That(names).IsEquivalentTo(new[] { "list_exams" });
+ await Assert.That(names).IsEquivalentTo(new[] { "list_exams", "list_assessments" });
// Internal tokens never show up as a connected app.
await Assert.That(await host.ScopedAsync(sp => sp.GetRequiredService().ListGrantsAsync(userId))).IsEmpty();
}