Files
Schulcloud-MCP/src/mcp/tools/untis.ts
MechaCat02 af4464decb Read the user's own lesson notes, and the class register behind them
Schulcloud says what was uploaded and WebUntis says what was scheduled.
Neither says what was *taught* — which point the teacher laboured, which
example landed, what "will definitely come up". That lives in two places
this server could not reach: the notes the user takes in the lesson, and
WebUntis' class register.

Notes are a directory of Markdown files (NOTES_DIR), not a table. They
have to be writable from a phone in a classroom, readable when Postgres
is down, and outlive this project, and files are the only shape that is
all three — so the files are the truth and the index is a view of them,
the same split as file_texts and the mirror. list_notes and get_note read
disk, so they answer before the first crawl; search, what_changed and all
three German prompts read them alongside the Schulcloud material.

add_note writes one, and is the only thing in this server that writes
anything. That is not a hole in the read-only invariant but a different
store: it is bounded to NOTES_DIR by the same safeComponent/resolveWithin
pair that stops a hostile Schulcloud filename escaping the mirror, so a
note titled ../../.ssh/authorized_keys becomes a filename. Schulcloud and
WebUntis stay GET-only and allowlisted respectively. NOTES_READONLY
refuses writes outright.

Appending targets the *lesson*, not the title: "halt das auch noch fest"
mid-lesson carries a new title, and deriving the path from it would start
a second note every time, which is the one thing append exists to prevent.

Notes.app has no export — its bodies are compressed protobuf and the
iCloud copy is encrypted — so scripting the app is not the clumsy route
to the notes but the only one. scripts/export-apple-notes.js reads them
through AppleScript into one JSON object per line, and `schulcloud note
import` converts the HTML to Markdown, takes the Notes folder as the
subject and the *creation* date as the lesson's date. Attachments cannot
come across; a note that was a photo of the board imports as a line
saying so, because importing it empty would hide the loss.

The class register needed one API property to become cheap:
getLessonTopic2017 answers per *series*, not per period, so a term is
reconstructed by asking about the latest period of each lesson series and
merging back by id — a few dozen calls for a school year rather than one
per lesson. untis_lesson_topics now takes a subject as well as a period
id, and UNTIS_HISTORY_DAYS of register goes into the index under a kind
of its own, so "what did we actually do before the test" is searchable.

Sharing the snapshot rather than duplicating it caught one thing on the
way: the search tool's live path had to learn notes too, or fresh=true
would have quietly disagreed with the index.

305 tests; 88/89 smoke against the local instance, the one failure being
the H5P service that instance does not run. The live smoke could not be
retaken: that session has lapsed and needs a fresh jwt cookie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:46:26 +02:00

433 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The WebUntis tools: the school day itself.
*
* Registered only when the server is configured for WebUntis, so a deployment
* without a key does not offer the model tools that can only fail.
*/
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../../context.ts';
import { addDays, daysBetween, germanDay, germanWeekday, isCalendarDate, schoolToday } from '../../core/dates.ts';
import { heading, joinSections } from '../../core/text.ts';
import {
UntisApiError,
type UntisClient,
type UntisDay,
type UntisHomework,
type UntisLesson,
} from '../../core/untis.ts';
import { collectLessonLog, type LessonLogEntry } from '../../core/untis-history.ts';
import { failure, text, toToolError } from './result.ts';
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
/** How far past the requested range to look when it turns out to be empty. */
const LOOKAHEAD_DAYS = 14;
/**
* Longest range either tool will fetch.
*
* A school week is about 40 periods, so a term's worth of timetable is
* thousands of lines of Markdown — past the point where it helps anyone, and
* the answer to "what do I have" is never three months of it.
*/
const MAX_RANGE_DAYS = 92;
/** How far back a subject's class register is read when no range is given. */
const DEFAULT_HISTORY_DAYS = 120;
/**
* Longest class-register range.
*
* Wider than the timetable's limit on purpose — the point of the subject form
* is to cover a term or a year, and the payload is one line per lesson that
* recorded something, not per period. It still needs a ceiling: the range is
* fetched in 90-day windows plus a call per lesson series, so "since 2019"
* would be a few hundred requests against a server that rate-limits.
*/
const MAX_HISTORY_DAYS = 400;
const dateArgument = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD.')
.describe('A date as YYYY-MM-DD.');
export function registerUntisTools(server: McpServer, context: ServerContext): void {
const untis = context.untis;
if (!untis) return;
server.registerTool(
'untis_timetable',
{
title: 'Timetable (WebUntis)',
description:
'The lessons of a school day or a date range from WebUntis ("Stundenplan"), with cancellations ' +
'("Entfall"), substitutions ("Vertretung"), room changes, the notes teachers attach to a period — ' +
'announced tests are usually in there — plus homework due and any exam. This is the tool for "what ' +
'do I have today/tomorrow", "has anything been cancelled" and for finding which subjects to prepare. ' +
'Omit both dates for today. The material for those lessons is in Schulcloud, not here: match the ' +
'subject to a course with list_courses. Every lesson line carries its period id for untis_lesson_topics.',
inputSchema: {
from: dateArgument.optional().describe('First day. Omit for today.'),
to: dateArgument.optional().describe('Last day, inclusive. Omit for a single day.'),
changesOnly: z
.boolean()
.default(false)
.describe('Only lessons that are cancelled or changed, for "what is different this week".'),
},
annotations: READ_ONLY,
},
async ({ from, to, changesOnly }) => {
const start = from ?? schoolToday();
const end = to ?? start;
const unreal = [...new Set([start, end])].filter((value) => !isCalendarDate(value));
if (unreal.length > 0) return failure(`Not a date in the calendar: ${unreal.join(', ')}. Use YYYY-MM-DD.`);
if (end < start) return failure(`The range ends before it starts: ${start} to ${end}.`);
if (daysBetween(start, end) > MAX_RANGE_DAYS) {
return failure(
`That is ${daysBetween(start, end)} days. Ask for at most ${MAX_RANGE_DAYS} at a time — a term of ` +
'timetable is thousands of lines.',
);
}
try {
return text(await readTimetable(untis, { from: start, to: end, changesOnly }));
} catch (error) {
return untisError(error, `read the timetable for ${start}${start === end ? '' : ` to ${end}`}`);
}
},
);
server.registerTool(
'untis_homework',
{
title: 'Homework (WebUntis)',
description:
'Homework recorded in WebUntis\' class register ("Hausaufgaben"), with the day it was set and the day ' +
'it is due. Separate from Schulcloud tasks ("Aufgaben") — a teacher uses one or the other, so check ' +
'both when the user asks what they have to do: list_tasks covers the Schulcloud side. Defaults to the ' +
'next two weeks.',
inputSchema: {
from: dateArgument.optional().describe('Earliest date to include. Omit for today.'),
to: dateArgument.optional().describe('Latest date to include. Omit for two weeks ahead.'),
includeCompleted: z.boolean().default(false).describe('Also list homework already ticked off.'),
},
annotations: READ_ONLY,
},
async ({ from, to, includeCompleted }) => {
const start = from ?? schoolToday();
const end = to ?? addDays(start, 14);
const unreal = [...new Set([start, end])].filter((value) => !isCalendarDate(value));
if (unreal.length > 0) return failure(`Not a date in the calendar: ${unreal.join(', ')}. Use YYYY-MM-DD.`);
if (end < start) return failure(`The range ends before it starts: ${start} to ${end}.`);
if (daysBetween(start, end) > MAX_RANGE_DAYS) {
return failure(
`That is ${daysBetween(start, end)} days. Ask for at most ${MAX_RANGE_DAYS} at a time — a term of ` +
'timetable is thousands of lines.',
);
}
try {
const all = await untis.homework(start, end);
const items = includeCompleted ? all : all.filter((item) => !item.completed);
if (items.length === 0) {
return text(
`No homework in WebUntis between ${germanDay(start)} and ${germanDay(end)}` +
`${includeCompleted ? '' : ' that is still open'}. Schulcloud tasks are separate — try list_tasks.`,
);
}
return text(
joinSections([
heading(2, `Hausaufgaben ${germanDay(start)} ${germanDay(end)} (${items.length})`),
items.map(formatHomework).join('\n'),
]),
);
} catch (error) {
return untisError(error, 'read homework from WebUntis');
}
},
);
server.registerTool(
'untis_lesson_topics',
{
title: 'What was taught (WebUntis)',
description:
'The class register\'s record of what lessons actually covered ("Unterrichtsinhalt"), newest first — ' +
'the teacher\'s own account of each lesson, which exists nowhere in Schulcloud. Two ways in: pass a ' +
'**subject** ("Deutsch", "LF07") to read back over a whole term, which is how to reconstruct what a ' +
'course has done and what a test will cover; or pass the **periodId** of one upcoming lesson from ' +
'untis_timetable to answer "where did we get to" for that series. With a subject it also returns the ' +
'notes teachers left on those lessons and the homework they set. Says nothing about the material ' +
'itself — that is Schulcloud — and nothing about what the user wrote down, which is list_notes.',
inputSchema: {
subject: z
.string()
.optional()
.describe('Subject name or code, matched as a fragment against both, e.g. "Deutsch" or "LF07".'),
periodId: z
.number()
.int()
.optional()
.describe('The period id of one lesson, as untis_timetable prints it in backticks. Covers that series only.'),
from: dateArgument.optional().describe(`With a subject: earliest day. Defaults to ${DEFAULT_HISTORY_DAYS} days back.`),
to: dateArgument.optional().describe('With a subject: latest day. Defaults to today.'),
limit: z.number().int().min(1).max(100).default(20).describe('How many lessons to list.'),
},
annotations: READ_ONLY,
},
async ({ subject, periodId, from, to, limit }) => {
if (subject === undefined && periodId === undefined) {
return failure(
'Give either a subject ("Deutsch") to read a whole term of the class register, or the periodId of ' +
'one lesson from untis_timetable to read just its series.',
);
}
if (subject !== undefined && periodId !== undefined) {
return failure('Give a subject or a periodId, not both: they are two different ways of choosing lessons.');
}
if (periodId !== undefined) {
try {
const topics = await untis.lessonTopics(periodId);
if (topics.length === 0) {
return text(
`No lesson contents recorded for period ${periodId}. Either the class register is empty for this ` +
'series or the teacher does not fill it in. Try the subject instead — another series of the same ' +
'subject may be filled in.',
);
}
return text(
joinSections([
heading(2, `Unterrichtsinhalte (${Math.min(limit, topics.length)} of ${topics.length})`),
topics
.slice(0, limit)
.map((topic) => `- ${germanDay(topic.date)} ${topic.start}${topic.end}: ${topic.text}`)
.join('\n'),
]),
);
} catch (error) {
return untisError(error, `read what was taught before period ${periodId}`);
}
}
const end = to ?? schoolToday();
const start = from ?? addDays(end, -DEFAULT_HISTORY_DAYS);
const unreal = [...new Set([start, end])].filter((value) => !isCalendarDate(value));
if (unreal.length > 0) return failure(`Not a date in the calendar: ${unreal.join(', ')}. Use YYYY-MM-DD.`);
if (end < start) return failure(`The range ends before it starts: ${start} to ${end}.`);
if (daysBetween(start, end) > MAX_HISTORY_DAYS) {
return failure(
`That is ${daysBetween(start, end)} days of class register. Ask for at most ${MAX_HISTORY_DAYS}` +
'a longer range is fetched in 90-day windows plus a call per lesson series.',
);
}
try {
const log = await collectLessonLog(untis, { from: start, to: end, subject: subject! });
if (log.periodsSeen === 0) {
return text(
`No lessons of "${subject}" between ${germanDay(start)} and ${germanDay(end)}. Check the subject ` +
'against untis_timetable — the register uses the school\'s own codes.',
);
}
if (log.entries.length === 0) {
return text(
`${log.periodsSeen} lesson(s) of "${subject}" took place between ${germanDay(start)} and ` +
`${germanDay(end)}, but nothing was recorded for any of them — this teacher does not fill in the ` +
'class register. The material in Schulcloud is then the only record; try get_course or search.',
);
}
return text(
joinSections([
heading(2, `Unterricht „${subject}“ — ${germanDay(start)} bis ${germanDay(end)}`),
`_${log.entries.length} of ${log.periodsSeen} lesson(s) have an entry in the class register._`,
log.entries.slice(0, limit).map(formatLogEntry).join('\n'),
log.entries.length > limit
? `_${log.entries.length - limit} older lesson(s) not shown — raise limit or narrow the range._`
: undefined,
log.failures.length > 0
? `_${log.failures.length} lesson series could not be read, so some entries may be missing._`
: undefined,
]),
);
} catch (error) {
return untisError(error, `read the class register for "${subject}"`);
}
},
);
}
/** One class-register entry: the topic, what the teacher noted, and what was set. */
function formatLogEntry(entry: LessonLogEntry): string {
const teachers = entry.teachers.length > 0 ? ` · ${entry.teachers.join(', ')}` : '';
const extra = [
entry.notes.info,
entry.notes.lesson,
entry.notes.substitution ? `Vertretungstext: ${entry.notes.substitution}` : undefined,
entry.exam ? `**Prüfung:** ${entry.exam}` : undefined,
...entry.homework.map((item) => `Hausaufgabe bis ${germanDay(item.due)}: ${item.text}`),
].filter((value): value is string => Boolean(value));
const head = `- **${germanDay(entry.date)}** ${entry.start}${entry.end}${teachers} \`${entry.periodId}\`` +
`${entry.topic ? `: ${entry.topic}` : ''}`;
return extra.length > 0 ? `${head}\n${extra.map((line) => ` - ${line}`).join('\n')}` : head;
}
/**
* The timetable for a range as Markdown: what `untis_timetable` returns, and
* what the Tagesvorbereitung prompt attaches, so an attached day reads exactly
* like a fetched one.
*/
export async function readTimetable(
untis: UntisClient,
options: { from: string; to: string; changesOnly?: boolean },
): Promise<string> {
const { from: start, to: end, changesOnly = false } = options;
// One request covers the range plus a lookahead, so an empty range can still
// answer "your next lessons are on …" without a second call.
const table = await untis.timetable(start, addDays(end, LOOKAHEAD_DAYS));
const asked = table.days.filter((day) => day.date <= end);
// With changesOnly a week of untouched days is noise, so only the days that
// changed are listed at all.
const shown = changesOnly
? asked
.map((day) => ({ ...day, lessons: day.lessons.filter((l) => l.cancelled || l.changed) }))
.filter((day) => day.lessons.length > 0)
: asked;
const total = shown.reduce((sum, day) => sum + day.lessons.length, 0);
// The Nachrichten des Tages belong to a single date; skip the call for a
// range, and never let it cost the timetable.
const messages = start === end && !changesOnly ? await untis.messagesOfDay(start).catch(() => []) : [];
const nextWithLessons = table.days.find((day) => day.date > end && day.lessons.length > 0);
return joinSections([
heading(2, start === end ? `${germanWeekday(start)}, ${germanDay(start)}` : `${germanDay(start)} ${germanDay(end)}`),
changesOnly && total > 0 ? '_Only cancellations and changes._' : undefined,
changesOnly && total === 0
? `_Nothing cancelled or changed${start === end ? '' : ' on any day of this range'}._`
: undefined,
...shown.map((day) => formatDay(day, start !== end)),
// Only about lessons: "your next change is on Friday" would be an odd thing
// to say, and misleading when nothing has changed.
total === 0 && !changesOnly && nextWithLessons
? `**Next lessons:** ${germanWeekday(nextWithLessons.date)}, ${germanDay(nextWithLessons.date)} ` +
`(${nextWithLessons.lessons.length} lesson(s)) — ask again with from="${nextWithLessons.date}".`
: undefined,
messages.length > 0
? joinSections([
heading(3, 'Nachrichten des Tages'),
messages.map((message) => `- ${[message.subject, message.text].filter(Boolean).join(': ')}`).join('\n'),
])
: undefined,
]);
}
// --- formatting ----------------------------------------------------------
function formatDay(day: UntisDay, withHeading: boolean): string {
const holidays = day.holidays.map((holiday) => `_${holiday.longName || holiday.name}_`).join(', ');
// A day with no lessons and no holiday is normal at a vocational school —
// the weeks in the company have no timetable — so say that rather than
// leaving a bare "nothing", which reads like a failed lookup.
const body =
day.lessons.length > 0
? day.lessons.map((lesson) => formatLesson(lesson, day.lessons)).join('\n')
: holidays
? '_No lessons._'
: '_No lessons. Not a holiday either — a company phase or a free day._';
return joinSections([
withHeading ? heading(3, `${germanWeekday(day.date)}, ${germanDay(day.date)}`) : undefined,
holidays || undefined,
body,
]);
}
/**
* One lesson as a line.
*
* A substitution arrives as two periods in the same slot — the original marked
* CANCELLED and the replacement marked IRREGULAR — rather than as one period
* with a changed teacher, so the replacement names what it stands in for by
* looking at what was cancelled beside it.
*/
function formatLesson(lesson: UntisLesson, sameDay: UntisLesson[]): string {
const names = (list: { name: string; longName?: string }[]): string => list.map((entry) => entry.name).join(', ');
const subject = lesson.subjects[0];
const subjectLabel = subject
? `**${subject.name}**${subject.longName ? ` (${subject.longName})` : ''}`
: '**(no subject)**';
const instead = [
...lesson.replaced.teachers.map((entry) => entry.name),
...(lesson.changed ? replacedBeside(lesson, sameDay) : []),
];
const status = lesson.cancelled
? ' — **Entfall**'
: lesson.changed
? ` — **Vertretung**${instead.length > 0 ? ` (statt ${[...new Set(instead)].join(', ')})` : ''}`
: '';
const room = lesson.rooms.length > 0 ? ` · Raum ${names(lesson.rooms)}` : '';
const replacedRoom = lesson.replaced.rooms.length > 0 ? ` (statt ${names(lesson.replaced.rooms)})` : '';
const teachers = lesson.teachers.length > 0 ? ` · ${lesson.teachers.map(withFullName).join(', ')}` : '';
const unknownStatus = lesson.statuses.filter((value) => !['REGULAR', 'CANCELLED', 'IRREGULAR'].includes(value));
const notes = [
lesson.exam ? `**Prüfung:** ${lesson.exam}` : undefined,
lesson.notes.info,
lesson.notes.substitution ? `Vertretungstext: ${lesson.notes.substitution}` : undefined,
lesson.notes.lesson,
...lesson.homework.map((item) => `Hausaufgabe bis ${germanDay(item.due)}: ${item.text}`),
unknownStatus.length > 0 ? `Status: ${unknownStatus.join(', ')}` : undefined,
lesson.online ? 'Online' : undefined,
].filter((note): note is string => Boolean(note));
const head =
`- ${lesson.start}${lesson.end} ${subjectLabel}${room}${replacedRoom}${teachers}${status} ` +
`\`${lesson.periodId}\``;
return notes.length > 0 ? `${head}\n${notes.map((note) => ` - ${note}`).join('\n')}` : head;
}
/** The teachers of a cancelled lesson in the same slot: who this one stands in for. */
function replacedBeside(lesson: UntisLesson, sameDay: UntisLesson[]): string[] {
return sameDay
.filter((other) => other.cancelled && other.start === lesson.start && other.periodId !== lesson.periodId)
.flatMap((other) => other.teachers.map((teacher) => teacher.name));
}
function withFullName(entry: { name: string; longName?: string }): string {
return entry.longName ? `${entry.name} (${entry.longName})` : entry.name;
}
function formatHomework(item: UntisHomework): string {
const subject = item.subject ? `${item.subject.name}${item.subject.longName ? ` (${item.subject.longName})` : ''}` : '';
const done = item.completed ? ' [erledigt]' : '';
const remark = item.remark ? `${item.remark}` : '';
const attachments = item.attachments > 0 ? ` (${item.attachments} attachment(s))` : '';
return `- **bis ${germanDay(item.due)}**${subject}: ${item.text}${remark}${attachments}${done} (set ${germanDay(item.assigned)})`;
}
/**
* WebUntis failures the user has to act on, separated from the rest.
*
* A rejected key and a wrong clock are the two that no retry fixes, and they
* are indistinguishable from "no lessons" unless they are named.
*/
function untisError(error: unknown, action: string) {
if (error instanceof UntisApiError) {
if (error.isAuthFailure) {
return failure(
`WebUntis rejected the server's key while trying to ${action}.\n\n` +
'The key from WebUntis → Profil → Freigaben → Untis Mobile has been regenerated or revoked. ' +
'Put the current one in UNTIS_SECRET and restart the server.',
);
}
if (error.isClockSkew) {
return failure(
`WebUntis refused the one-time code while trying to ${action}: the server's clock is too far off.\n\n` +
'The code is time-based, so the host needs a working NTP sync.',
);
}
}
return toToolError(error, action);
}