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, noteSections, noteSubjects, normalizeRelative, 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 — a note is often a whole school day with a heading per ' + 'lesson, so the subject filter looks at those headings too. Returns titles and a preview; get_note ' + 'opens one. search finds notes by their contents as well, and names the lesson it matched in.', 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, ...noteSubjects(note), 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 }) => { // A search hit inside a day's note carries `#2`; opening the note // is the right answer, so the anchor is dropped rather than 404ing on a // filename nobody has. const wanted = normalizeRelative(path).replace(/#\d+$/, ''); try { return text(renderNote(await readNoteAt(root, wanted))); } catch (error) { if (error instanceof NoteNotFound) { return failure( `There is no note at "${wanted}". Paths come from list_notes or search and include the folder and ` + 'the .md ending.', ); } return toToolError(error, `read the note "${wanted}"`); } }, ); 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 subjects = noteSubjects(note); const where = subjects.length > 0 ? ` · ${subjects.join(', ')}` : ''; const tags = note.tags.length > 0 ? ` · ${note.tags.map((tag) => `#${tag}`).join(' ')}` : ''; // For a day's note the lessons are the useful preview; for a single piece of // prose the first line is. const sections = noteSections(note); const preview = sections.length > 0 ? `${sections.length} Stunde(n)` : firstLine(note.text); return [`- **${note.title}** — ${when}${where}${tags} \`${note.path}\``, preview ? ` ${preview}` : undefined] .filter(Boolean) .join('\n'); } function renderNote(note: NoteDoc): string { const subjects = noteSubjects(note); const facts = [ note.date ? `Datum: ${germanDay(note.date)}` : undefined, subjects.length > 0 ? `${subjects.length > 1 ? 'Fächer' : 'Fach'}: ${subjects.join(', ')}` : 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.', ]); }