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>
70 lines
2.7 KiB
TypeScript
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();
|
|
}
|