Files
Schulcloud-MCP/src/services.ts
MechaCat02 af4464decb 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>
2026-09-18 21:46:26 +02:00

70 lines
2.7 KiB
TypeScript

import type { Config } from './config.ts';
import { SchulcloudClient } from './core/client.ts';
import type { SessionKeepalive } from './core/keepalive.ts';
import { FileManager } from './core/legacy-files.ts';
import { SessionToken } from './core/session-token.ts';
import { UntisClient } from './core/untis.ts';
import { Indexer } from './indexer/indexer.ts';
import { Store } from './store/store.ts';
/**
* Process-wide singletons.
*
* The store and indexer are shared across MCP sessions — one index, one crawl
* at a time — whereas each session gets its own `ServerContext`. Both are
* optional: without `DATABASE_URL` the server runs in live-only mode, and
* every feature that needs the index says so rather than failing.
*/
export interface Services {
config: Config;
client: SchulcloudClient;
/**
* The "Dateien" file manager. Process-wide so its short listing cache is
* shared: an `ls` in one MCP session and a `schulcloud fs get` from the CLI
* then cost one page fetch between them, not two.
*/
files: FileManager;
store: Store | undefined;
indexer: Indexer | undefined;
/** The Schulcloud token, which `/api/token` can replace without a restart. */
session: SessionToken;
/**
* WebUntis, when configured. Process-wide so its master data — 140 subjects,
* 216 teachers, every holiday of the school year — is fetched once rather
* than per MCP session.
*/
untis: UntisClient | undefined;
/**
* Set by the entry point that runs one, for status reports. Created there
* rather than here because each entry point logs to a different stream.
*/
keepalive?: SessionKeepalive;
}
export async function createServices(config: Config): Promise<Services> {
const client = new SchulcloudClient(config);
// Before anything else reads config.jwt: a token replaced at runtime and
// saved may be newer than the one in the environment.
const session = new SessionToken(config, client, config.stateDir);
await session.load();
const files = new FileManager(client);
const store = await Store.open(config.databaseUrl);
const untis = config.untis ? new UntisClient(config.untis, config.requestTimeoutMs) : undefined;
// The indexer gets the same client, so the class register is read with the
// master data the untis_* tools have already paid for.
const indexer = store ? new Indexer(client, store, config, { untis }) : undefined;
if (!store) {
console.warn(
'[schulcloud-mcp] no index: search will crawl live on every call, and ' +
'/files, /manifest and refresh_index are unavailable. Set DATABASE_URL to enable them.',
);
}
return { config, client, files, store, indexer, session, untis };
}
export async function closeServices(services: Services): Promise<void> {
await services.store?.close();
}