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>
This commit is contained in:
MechaCat02
2026-09-18 21:45:39 +02:00
parent ad8ba28313
commit af4464decb
34 changed files with 3078 additions and 61 deletions

253
src/mcp/tools/notes.ts Normal file
View File

@@ -0,0 +1,253 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../../context.ts';
import { germanDay, isCalendarDate, schoolToday } from '../../core/dates.ts';
import {
filterNotes,
NoteNotFound,
readNoteAt,
readNotes,
writeNote,
type NoteDoc,
} from '../../core/notes.ts';
import { heading, joinSections, matchesAll, tokenize } from '../../core/text.ts';
import { failure, text, toToolError } from './result.ts';
/**
* The user's own lesson notes.
*
* Registered only when NOTES_DIR is set, on the same principle as the untis_*
* tools: a note tool with nowhere to read from can only ever fail, and a model
* offered one will keep trying it.
*
* These are the only tools in this server that write anything, and what they
* write is the user's own notes directory — never Schulcloud, which stays
* read-only in the strict sense the invariant in CLAUDE.md describes. The write
* is bounded by the same two functions the file mirror uses: every path
* component is reduced by `safeComponent` and the result is checked by
* `resolveWithin`, so a title of `../../.ssh/authorized_keys` becomes a
* filename and not a path.
*/
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
/** Notes listed before the tool starts summarising instead of listing. */
const MAX_LISTED = 200;
const dateArgument = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD.')
.describe('A date as YYYY-MM-DD.');
export function registerNoteTools(server: McpServer, context: ServerContext): void {
const root = context.config.notesDir;
if (!root) return;
server.registerTool(
'list_notes',
{
title: 'My lesson notes',
description:
"The user's own notes from lessons — what they wrote down themselves, which is neither in Schulcloud " +
'nor in WebUntis and is often the only record of what a teacher actually said. **Read these before ' +
'answering anything about what was covered in class, and before preparing for a test**: they say what ' +
'was emphasised, which the uploaded material does not. Filter by subject ("Deutsch", "LF07") or by ' +
'date to get the lessons around a topic. Returns titles and first lines; get_note opens one. ' +
'search finds notes by their contents as well.',
inputSchema: {
subject: z
.string()
.optional()
.describe('Only notes for this subject, matched as a fragment. The user\'s own wording, not a course id.'),
since: dateArgument.optional().describe('Only notes from this day onwards.'),
until: dateArgument.optional().describe('Only notes up to and including this day.'),
query: z
.string()
.optional()
.describe('Only notes whose title, subject or tags contain every word given. For full text, use search.'),
limit: z.number().int().min(1).max(MAX_LISTED).default(50).describe('Maximum notes to list.'),
},
annotations: READ_ONLY,
},
async ({ subject, since, until, query, limit }) => {
const unreal = [since, until].filter((value): value is string => Boolean(value) && !isCalendarDate(value!));
if (unreal.length > 0) return failure(`Not a date in the calendar: ${unreal.join(', ')}. Use YYYY-MM-DD.`);
try {
const all = await readNotes(root);
if (all.length === 0) return text(emptyStore(root, context.config.notesWritable));
const terms = query ? tokenize(query) : [];
const matched = filterNotes(all, { subject, since, until }).filter(
(note) => terms.length === 0 || matchesAll([note.title, note.subject, note.tags.join(' ')].join(' '), terms),
);
if (matched.length === 0) {
return text(
`None of the ${all.length} note(s) match${describeFilter({ subject, since, until, query })}. ` +
'Drop a filter, or use search to look inside the text.',
);
}
return text(
joinSections([
heading(2, `Notizen (${Math.min(limit, matched.length)} of ${matched.length})`),
matched.slice(0, limit).map(listLine).join('\n'),
matched.length > limit ? `_${matched.length - limit} more — narrow it down with subject or since._` : undefined,
]),
);
} catch (error) {
return toToolError(error, 'read the notes directory');
}
},
);
server.registerTool(
'get_note',
{
title: 'Read one note',
description:
'The full text of one of the user\'s own notes, by the path list_notes and search print. Quote from it ' +
'the way you would quote a course file — it is a primary source for what happened in the lesson.',
inputSchema: {
path: z
.string()
.min(1)
.describe('The note\'s path, e.g. "Deutsch/2026-09-15 Erörterung.md", exactly as it was listed.'),
},
annotations: READ_ONLY,
},
async ({ path }) => {
try {
return text(renderNote(await readNoteAt(root, path)));
} catch (error) {
if (error instanceof NoteNotFound) {
return failure(
`There is no note at "${path}". Paths come from list_notes or search and include the folder and ` +
'the .md ending.',
);
}
return toToolError(error, `read the note "${path}"`);
}
},
);
if (!context.config.notesWritable) return;
server.registerTool(
'add_note',
{
title: 'Write a lesson note',
description:
'Saves a note into the user\'s own notes, so it is there next time — during a lesson ("halte fest, ' +
'dass …"), or when writing up what was just discussed. Give the subject as the user says it ' +
'("Deutsch", "LF07") and the day the lesson was on; both are what makes the note findable later. ' +
'Pass append=true to add to the note already written for that subject and day rather than starting a ' +
'second one — that is the right choice during a lesson. This writes **only** to the notes directory; ' +
'it cannot change anything in Schulcloud or WebUntis. Do not use it to store things the user did not ' +
'ask to keep.',
inputSchema: {
title: z.string().min(1).max(200).describe('A short title — the topic of the lesson, not a sentence.'),
text: z.string().min(1).describe('The note itself, as Markdown. Write it in the language the user used.'),
subject: z
.string()
.max(80)
.optional()
.describe('Subject as the user names it, e.g. "Deutsch" or "LF07". Becomes the folder.'),
date: dateArgument.optional().describe('The day of the lesson. Defaults to today.'),
tags: z.array(z.string().max(40)).max(12).optional().describe('Optional keywords, e.g. ["klausur"].'),
courseId: z
.string()
.optional()
.describe('The Schulcloud course id, when it is known — it links the note to the course in search.'),
append: z
.boolean()
.default(false)
.describe('Add to an existing note for that subject and day instead of creating another one.'),
},
// Writes — to the notes directory, and to nothing else.
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
},
async ({ title, text: body, subject, date, tags, courseId, append }) => {
if (date && !isCalendarDate(date)) return failure(`Not a date in the calendar: ${date}. Use YYYY-MM-DD.`);
try {
const { note, appended } = await writeNote(root, {
title,
text: body,
date: date ?? schoolToday(),
...(subject ? { subject } : {}),
...(courseId ? { courseId } : {}),
...(tags && tags.length > 0 ? { tags } : {}),
source: 'add_note',
append,
});
return text(
joinSections([
`${appended ? 'Added to' : 'Saved'} **${note.title}** — \`${note.path}\``,
'_It is searchable after the next refresh_index; get_note reads it now._',
]),
);
} catch (error) {
return toToolError(error, `save the note "${title}"`);
}
},
);
}
// --- formatting ----------------------------------------------------------
function listLine(note: NoteDoc): string {
const when = note.date ? germanDay(note.date) : 'ohne Datum';
const where = note.subject ? ` · ${note.subject}` : '';
const tags = note.tags.length > 0 ? ` · ${note.tags.map((tag) => `#${tag}`).join(' ')}` : '';
const first = firstLine(note.text);
return [`- **${note.title}** — ${when}${where}${tags} \`${note.path}\``, first ? ` ${first}` : undefined]
.filter(Boolean)
.join('\n');
}
function renderNote(note: NoteDoc): string {
const facts = [
note.date ? `Datum: ${germanDay(note.date)}` : undefined,
note.subject ? `Fach: ${note.subject}` : undefined,
note.tags.length > 0 ? `Tags: ${note.tags.join(', ')}` : undefined,
note.courseId ? `Kurs: \`${note.courseId}\`` : undefined,
].filter(Boolean);
return joinSections([
heading(2, note.title),
facts.length > 0 ? `_${facts.join(' · ')}_` : undefined,
note.text || '_This note is empty._',
`_Own note: \`${note.path}\`_`,
]);
}
function firstLine(body: string): string | undefined {
const line = body
.split('\n')
.map((entry) => entry.replace(/^#+\s*/, '').trim())
.find((entry) => entry.length > 0);
if (!line) return undefined;
return line.length > 160 ? `${line.slice(0, 157)}…` : line;
}
function describeFilter(filter: { subject?: string; since?: string; until?: string; query?: string }): string {
const parts = [
filter.subject ? `subject "${filter.subject}"` : undefined,
filter.query ? `"${filter.query}"` : undefined,
filter.since ? `from ${germanDay(filter.since)}` : undefined,
filter.until ? `to ${germanDay(filter.until)}` : undefined,
].filter(Boolean);
return parts.length > 0 ? ` ${parts.join(', ')}` : '';
}
/**
* The empty case, which is the normal one on a fresh install.
*
* It says where the directory is because the usual next step is to put files
* there by hand or with the import script, not to call a tool.
*/
function emptyStore(root: string, writable: boolean): string {
return joinSections([
`There are no notes yet. The notes directory is \`${root}\`.`,
writable
? 'Notes are Markdown files; add_note writes one, and anything dropped in that directory is picked up too.'
: 'This server was started with NOTES_READONLY, so notes have to be put there by hand or synced in.',
]);
}