Files
Schulcloud-MCP/src/mcp/tools/notes.ts
MechaCat02 dc50b4bcd5 Write the notes in an app, a school day at a time
The notes existed but there was nowhere to write them: a CLI command on a
laptop, a tool call through Claude, or a file in a Docker volume. None of
those is reachable from a phone in a lesson, which is where notes are
actually taken.

So: `/app`, served only when WEB_PASSWORD is set. A login, the day's
notes, and a settings page for the Schulcloud token — the one surface
here meant for a person rather than a program.

The shape follows how the notes are written: one note per school day,
one `##` heading per lesson, prose and lists and tables beneath. That
turns out to be the design decision that matters, twice over.

First, it is what lets WebUntis earn its keep. Opening a day with no
note fills in that day's lessons — numbered, with times, teacher and
room, cancellations dropped and substitutions marked. Retyping the
timetable is exactly the work the second upstream exists to avoid, and
"Stunden ergänzen" tops up a note started before the day ended without
touching what is already written.

Second, it changes how notes are indexed. A day note is indexed per
lesson, not whole: search answers "my own note, Deutsch, 18.09.2026"
rather than "my own note, Friday", and `list_notes subject=Deutsch`
finds a day whose frontmatter names no subject at all. Indexed whole,
every hit would read as a weekday and "what did we do in Deutsch" would
match notes whose other five lessons were something else. `lessonHeading`
and `subjectFromHeading` are a loop — the app writes the heading, the
indexer reads the subject back out — and a test holds them to it.

Notes taken in a lesson cannot be retaken, so the editor is built
around not losing them: autosave, every keystroke mirrored to local
storage, a save when the phone locks, and a fallback to the local copy
when the request never arrives. A save that would overwrite a version
the editor never saw is refused and the choice handed back — the notes
folder is synced and open in more than one place, and a phone must not
silently win over a laptop. `replaceNote` is separate from `writeNote`
for that reason: never-overwrite is right for `add_note` and exactly
wrong for an editor.

WEB_PASSWORD is the first credential here a human types, so it is the
first that can be guessed: scrypt at startup, never stored or compared
in the clear, per-address rate limiting — which is not decoration, since
the scrypt cost is itself a denial-of-service vector without it. The
session is a signed HttpOnly SameSite=Strict cookie whose key is derived
from the password, so changing it logs everyone out and there is no
second secret to keep. It opens /api, because a session is the user, and
never /mcp, because nothing in a browser speaks MCP.

Also here, because the app made them matter: frontmatter now reads the
indented `- item` list form editors write, so an Obsidian vault
round-trips its tags; and a four-digit folder is a filing scheme, not a
subject, so `2026/` does not file a school year under one.

357 tests; 106/107 smoke against the local instance, the one failure
being the H5P service that instance does not run.

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

269 lines
11 KiB
TypeScript

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 `<path>#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.',
]);
}