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>
241 lines
10 KiB
TypeScript
241 lines
10 KiB
TypeScript
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
import { z } from 'zod';
|
|
import type { ServerContext } from '../../context.ts';
|
|
import { FILE_AREAS } from '../../core/legacy-files.ts';
|
|
import { crawl } from '../../core/crawl.ts';
|
|
import { searchSnapshot, type Hit } from '../../core/match.ts';
|
|
import { formatDate, heading, joinSections } from '../../core/text.ts';
|
|
import type { NodeKind, SearchResult } from '../../store/store.ts';
|
|
import { text, toToolError } from './result.ts';
|
|
|
|
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
|
|
const TOOL_FOR: Record<string, string> = {
|
|
course: 'get_course',
|
|
board: 'get_board',
|
|
lesson: 'get_lesson',
|
|
task: 'get_task',
|
|
file: 'download_file',
|
|
// A submission is reached through its task, not by an id of its own: there
|
|
// is no get_submission because the API has no route to one.
|
|
submission: 'get_task',
|
|
note: 'get_note',
|
|
// A class-register hit is followed up by its series, not by the single
|
|
// period: untis_lesson_topics with that periodId returns the lessons around it.
|
|
untis: 'untis_lesson_topics',
|
|
};
|
|
|
|
export function registerSearchTool(server: McpServer, context: ServerContext): void {
|
|
server.registerTool(
|
|
'search',
|
|
{
|
|
title: 'Search across courses',
|
|
description:
|
|
'Finds material by keyword across every course: titles, board and card text, lessons, tasks, file ' +
|
|
'names — and, unlike anything else here, **the text inside PDFs, Word, PowerPoint and OpenDocument ' +
|
|
'files**. Use it whenever the user names a topic rather than a course ("where is the stuff about ' +
|
|
'encryption?"). Matching is case- and accent-insensitive and understands German word forms. ' +
|
|
'It covers three sources at once: the Schulcloud material, **the user\'s own lesson notes** and ' +
|
|
'**the WebUntis class register** — so one query answers "what do we have on this, what did I write ' +
|
|
'down, and when did we do it". Restrict with kinds to just one of them. ' +
|
|
'Results come from a local index; if they look stale, refresh_index re-reads Schulcloud.',
|
|
inputSchema: {
|
|
query: z.string().min(2).describe('What to look for. German and English both work.'),
|
|
courseId: z.string().optional().describe('Restrict the search to a single course.'),
|
|
kinds: z
|
|
.array(z.enum(['course', 'room', 'board', 'lesson', 'task', 'file', 'submission', 'note', 'untis']))
|
|
.optional()
|
|
.describe(
|
|
'Restrict to certain kinds of thing: ["file"] for documents only, ["note"] for the user\'s own ' +
|
|
'notes, ["untis"] for what the class register says was taught.',
|
|
),
|
|
limit: z.number().int().min(1).max(100).default(30).describe('Maximum number of hits to return.'),
|
|
fresh: z
|
|
.boolean()
|
|
.default(false)
|
|
.describe(
|
|
'Bypass the index and read Schulcloud live. Pair it with courseId: one course takes ~2s and ' +
|
|
'includes file names. Without courseId it reads every course text-only (~5s) and skips ' +
|
|
'attachments, because resolving those needs a request per element and takes minutes. ' +
|
|
'To bring file names up to date instead, use refresh_index.',
|
|
),
|
|
},
|
|
annotations: READ_ONLY,
|
|
},
|
|
async ({ query, courseId, kinds, limit, fresh }) => {
|
|
try {
|
|
if (fresh || !context.store) {
|
|
return text(await liveSearch(context, query, courseId, limit, fresh));
|
|
}
|
|
|
|
const hits = await context.store.search(query, { limit, kinds: kinds as NodeKind[] | undefined });
|
|
const filtered = courseId ? hits.filter((hit) => hit.courseId === courseId) : hits;
|
|
const stats = await context.store.stats();
|
|
|
|
if (stats.crawlId === undefined) {
|
|
return text(
|
|
'The index is empty, so there is nothing to search yet. Run refresh_index to populate it, ' +
|
|
'or call search again with fresh=true to read Schulcloud directly.',
|
|
);
|
|
}
|
|
|
|
if (filtered.length === 0) {
|
|
return text(
|
|
joinSections([
|
|
`No matches for "${query}" in the index (${freshness(stats.crawledAt)}).`,
|
|
'If this was added recently, try refresh_index, or search again with fresh=true.',
|
|
]),
|
|
);
|
|
}
|
|
|
|
return text(
|
|
joinSections([
|
|
heading(2, `${filtered.length} match(es) for "${query}"`),
|
|
`_Index ${freshness(stats.crawledAt)}._`,
|
|
filtered.map(formatIndexed).join('\n\n'),
|
|
]),
|
|
);
|
|
} catch (error) {
|
|
return toToolError(error, `search for "${query}"`);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The live path: crawl now and match in memory.
|
|
*
|
|
* Attachments are resolved only when a single course was named. Resolving them
|
|
* costs one files-storage request per board element — measured against the live
|
|
* instance, that is the difference between 2s for one course and **325s for all
|
|
* of them**, which no client will wait for. So an unscoped live search reads
|
|
* text only and says so.
|
|
*/
|
|
async function liveSearch(
|
|
context: ServerContext,
|
|
query: string,
|
|
courseId: string | undefined,
|
|
limit: number,
|
|
explicit: boolean,
|
|
): Promise<string> {
|
|
const scoped = Boolean(courseId);
|
|
const snapshot = await crawl(context.client, {
|
|
schoolId: await context.schoolId(),
|
|
courseIds: courseId ? [courseId] : undefined,
|
|
includeLessonContents: true,
|
|
includeFiles: scoped,
|
|
// Same trade as files: worth two extra requests per pad when the caller
|
|
// named a course, too slow to do across every course they can see.
|
|
config: scoped ? context.config : undefined,
|
|
// Notes are local files, so the live path can afford them and must: a
|
|
// fresh search that silently dropped them would disagree with the index.
|
|
...(context.config.notesDir ? { notesDir: context.config.notesDir } : {}),
|
|
});
|
|
const hits = searchSnapshot(snapshot, query, limit);
|
|
|
|
const scopeNote = scoped
|
|
? ''
|
|
: ' File names and attachments were not re-read — name a courseId, or use refresh_index, to include them.';
|
|
const note = explicit
|
|
? `_Read live from Schulcloud, bypassing the index.${scopeNote}_`
|
|
: `_No index configured; read live from Schulcloud. File contents are not searched this way.${scopeNote}_`;
|
|
|
|
if (hits.length === 0) {
|
|
return `No matches for "${query}" across ${snapshot.courses.length} course(s).\n\n${note}`;
|
|
}
|
|
return joinSections([
|
|
heading(2, `${hits.length} match(es) for "${query}"`),
|
|
note,
|
|
hits.map(formatLive).join('\n\n'),
|
|
]);
|
|
}
|
|
|
|
function targetIdFor(hit: SearchResult): string {
|
|
if (hit.kind === 'submission') {
|
|
const taskId = hit.meta?.taskId;
|
|
if (typeof taskId === 'string') return taskId;
|
|
}
|
|
return hit.nodeId;
|
|
}
|
|
|
|
/** Where to go next for a hit. File-manager files are read by path, not by download_file. */
|
|
function nextStep(hit: SearchResult): string {
|
|
if (hit.kind === 'file' && hit.meta?.source === 'file-manager') {
|
|
const fsPath = typeof hit.meta.fsPath === 'string' ? hit.meta.fsPath : undefined;
|
|
return fsPath
|
|
? ` → \`fs_read\` with path \`${fsPath}\``
|
|
: ` → \`fs_read\` with fileId \`${hit.nodeId}\` and name \`${hit.title}\``;
|
|
}
|
|
// A note is addressed by its path, not by an id — and a lesson inside a day's
|
|
// note is reached by opening the note, since `#3` is an index into this
|
|
// generation and means nothing to get_note.
|
|
if (hit.kind === 'note') {
|
|
const notePath = typeof hit.meta?.notePath === 'string' ? hit.meta.notePath : hit.nodeId.replace(/#\d+$/, '');
|
|
return ` → \`get_note\` with path \`${notePath}\``;
|
|
}
|
|
if (hit.kind === 'untis') {
|
|
const periodId = hit.meta?.periodId;
|
|
return ` → \`untis_lesson_topics\` with periodId \`${typeof periodId === 'number' ? periodId : hit.nodeId}\``;
|
|
}
|
|
// A submission has no id of its own that any tool takes: get_task is
|
|
// reached through the *task*, so point at that rather than at the
|
|
// submission id, which would simply 404.
|
|
return ` → \`${TOOL_FOR[hit.kind] ?? 'api_get'}\` with id \`${targetIdFor(hit)}\``;
|
|
}
|
|
|
|
/** "Kurs-Dateien, <course>" or the area's own name, from the file's fs path. */
|
|
function fileManagerPlace(hit: SearchResult): string {
|
|
const area = typeof hit.meta?.fsPath === 'string' ? hit.meta.fsPath.split('/')[1] : undefined;
|
|
const known = FILE_AREAS.find((entry) => entry.area === area);
|
|
if (!known) return 'the file manager';
|
|
return known.area === 'courses' && hit.courseTitle ? `${known.label}, ${hit.courseTitle}` : known.label;
|
|
}
|
|
|
|
/** What kind of thing a hit is, in words rather than in the store's vocabulary. */
|
|
function placeOf(hit: SearchResult): string {
|
|
if (hit.kind === 'file' && hit.meta?.source === 'file-manager') return `file in ${fileManagerPlace(hit)}`;
|
|
if (hit.kind === 'note') {
|
|
const date = typeof hit.meta?.date === 'string' ? formatDate(hit.meta.date) : undefined;
|
|
const subject = typeof hit.meta?.subject === 'string' ? hit.meta.subject : undefined;
|
|
const heading = typeof hit.meta?.heading === 'string' ? hit.meta.heading : undefined;
|
|
// Named as the user's own writing, so it is never quoted as if the school
|
|
// had published it. A lesson within a day's note says which lesson.
|
|
return ['my own note', subject ?? heading, date].filter(Boolean).join(', ');
|
|
}
|
|
if (hit.kind === 'untis') {
|
|
const date = typeof hit.meta?.date === 'string' ? formatDate(hit.meta.date) : undefined;
|
|
return ['class register (WebUntis)', date].filter(Boolean).join(', ');
|
|
}
|
|
return `${hit.kind} in ${hit.courseTitle || hit.path}`;
|
|
}
|
|
|
|
function formatIndexed(hit: SearchResult): string {
|
|
const where = placeOf(hit);
|
|
return [
|
|
`- **${hit.title}** — ${where}`,
|
|
hit.snippet && hit.snippet !== hit.title ? ` ${hit.snippet}` : undefined,
|
|
nextStep(hit),
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
}
|
|
|
|
function formatLive(hit: Hit): string {
|
|
// A note is addressed by path; everything else by id.
|
|
const next =
|
|
hit.targetKind === 'note'
|
|
? ` → \`get_note\` with path \`${hit.targetId}\``
|
|
: ` → \`${TOOL_FOR[hit.targetKind]}\` with id \`${hit.targetId}\``;
|
|
return [`- **${hit.courseTitle}** — ${hit.where}`, ` ${hit.snippet}`, next].join('\n');
|
|
}
|
|
|
|
function freshness(crawledAt: string | undefined): string {
|
|
if (!crawledAt) return 'freshness unknown';
|
|
const minutes = Math.round((Date.now() - new Date(crawledAt).getTime()) / 60_000);
|
|
if (minutes < 1) return 'just refreshed';
|
|
if (minutes < 60) return `last refreshed ${minutes} min ago`;
|
|
const hours = Math.round(minutes / 60);
|
|
if (hours < 48) return `last refreshed ${hours}h ago (${formatDate(crawledAt)})`;
|
|
return `last refreshed ${formatDate(crawledAt)}`;
|
|
}
|