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:
253
src/mcp/tools/notes.ts
Normal file
253
src/mcp/tools/notes.ts
Normal 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.',
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user