Schulcloud holds the material for a lesson but not the lesson: this school's course `times` are empty and it publishes the schedule in WebUntis. So "what do I have today, and has anything been cancelled" was unanswerable, and the timetable cannot be typed into a prompt either — it changes daily. core/untis.ts talks to the API the Untis Mobile app uses, and three tools sit on it: untis_timetable (a day or a range, Entfall, Vertretung, room changes, the notes on each period, inline homework, the period id), untis_homework (the class register's list, which is not Schulcloud's tasks) and untis_lesson_topics (what earlier lessons of a series actually covered, which is what says where a subject got to). Read-only, but not by the Schulcloud client's rule: this API is JSON-RPC, so every call is a POST, reads included. READ_METHODS is the guarantee instead, enforced at the single choke point and asserted by a test. It matters because the key can do what the app can — the live account holds W_OWN_ABSENCE, so the same key could report the user absent. What the live instance taught us, all recorded in docs/API.md: - `startDateTime` ends in Z and is local time. The 08:00 lesson reports 08:00Z, so new Date() would move every lesson by an hour or two. - A substitution is two periods, the original CANCELLED and the replacement IRREGULAR beside it, not one period with a changed teacher. - Announced tests live in the period's info text. The exam module is unused here, so getExams2017 is always empty and that field carries the tests. - A day with no lessons is not a holiday: the weeks this account spends in the company simply have no periods. - `?v=i3.2` is required, or the call fails with a Java NPE reported as -8998. Errors arrive with HTTP 200 and an error member. -8504 is a rejected key and -8524 a drifting clock; the tools name both, because no retry fixes either. Configuration is all four UNTIS_* values or none — three are identifiers and the fourth is a credential, so a half-filled block is a paste that went wrong. Without them the tools are not registered at all, since a tool that can only fail is worse than a missing one. whoami reports the WebUntis identity and survives a dead Schulcloud session, so "is the server reachable" no longer gets a misleadingly total no. mcp-env.sh switches WebUntis off for a fixture run: that key belongs to the real school. 224 tests. All 10 WebUntis smoke checks pass, with a key and without one; the Schulcloud checks in those runs answer 401 because this machine's session is logged out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
124 lines
4.5 KiB
TypeScript
124 lines
4.5 KiB
TypeScript
import type { Config } from './config.ts';
|
|
import { SchulcloudClient } from './core/client.ts';
|
|
import { FileManager } from './core/legacy-files.ts';
|
|
import type { LegacyUser, MeResponse } from './core/types.ts';
|
|
import { UntisClient } from './core/untis.ts';
|
|
import type { Indexer } from './indexer/indexer.ts';
|
|
import type { Store } from './store/store.ts';
|
|
|
|
/**
|
|
* Per-process state shared by every tool.
|
|
*
|
|
* The only thing worth holding onto is the identity from `/api/v3/me`: the
|
|
* school id is a required path segment for every files-storage call, and it
|
|
* cannot change for a given JWT. Everything else is fetched live.
|
|
*/
|
|
export class ServerContext {
|
|
readonly config: Config;
|
|
readonly client: SchulcloudClient;
|
|
/** The "Dateien" file manager; shared across sessions when the process provides one. */
|
|
readonly files: FileManager;
|
|
/** Shared across sessions; undefined when running without an index. */
|
|
readonly store: Store | undefined;
|
|
readonly indexer: Indexer | undefined;
|
|
/** WebUntis — the timetable — or undefined when the server has no key for it. */
|
|
readonly untis: UntisClient | undefined;
|
|
private identity: Promise<MeResponse> | undefined;
|
|
/**
|
|
* id -> display name, for the whole session.
|
|
*
|
|
* Submission `submitters`, file `creatorId` and course `teacherIds` are all
|
|
* bare ids, and the only route that resolves one is `/api/v1/users/{id}` —
|
|
* one request per person. Names do not change within a session and the same
|
|
* handful of people recur across every course, so this is cached hard,
|
|
* including the misses: a lookup a student is not allowed to make would
|
|
* otherwise be retried for every row it appears in.
|
|
*/
|
|
private readonly userNames = new Map<string, Promise<string | undefined>>();
|
|
|
|
constructor(
|
|
config: Config,
|
|
shared?: {
|
|
client?: SchulcloudClient;
|
|
files?: FileManager;
|
|
store?: Store;
|
|
indexer?: Indexer;
|
|
untis?: UntisClient;
|
|
},
|
|
) {
|
|
this.config = config;
|
|
this.client = shared?.client ?? new SchulcloudClient(config);
|
|
this.files = shared?.files ?? new FileManager(this.client);
|
|
this.store = shared?.store;
|
|
this.indexer = shared?.indexer;
|
|
this.untis = shared?.untis ?? (config.untis ? new UntisClient(config.untis, config.requestTimeoutMs) : undefined);
|
|
}
|
|
|
|
/** Cached `/me`. Shared promise, so concurrent first calls make one request. */
|
|
me(): Promise<MeResponse> {
|
|
this.identity ??= this.client.me().catch((error: unknown) => {
|
|
// Don't cache a failure — a replaced JWT should be able to recover.
|
|
this.identity = undefined;
|
|
throw error;
|
|
});
|
|
return this.identity;
|
|
}
|
|
|
|
async schoolId(): Promise<string> {
|
|
return (await this.me()).school.id;
|
|
}
|
|
|
|
/**
|
|
* Display name for a user id, or undefined when it cannot be resolved.
|
|
*
|
|
* Never throws: a 403 here is ordinary — a student may read their own
|
|
* classmates but not every id that appears on a board — and a row that
|
|
* falls back to the bare id is far better than a tool that fails.
|
|
*/
|
|
userName(userId: string): Promise<string | undefined> {
|
|
let pending = this.userNames.get(userId);
|
|
if (!pending) {
|
|
pending = this.client
|
|
.getLegacyUser(userId)
|
|
.then((user: LegacyUser) => {
|
|
const name = user.fullName ?? user.displayName ?? [user.firstName, user.lastName].filter(Boolean).join(' ');
|
|
return name.trim() || undefined;
|
|
})
|
|
.catch(() => undefined);
|
|
this.userNames.set(userId, pending);
|
|
}
|
|
return pending;
|
|
}
|
|
|
|
/** Resolves several ids at once, falling back to the id itself. */
|
|
async userNamesFor(userIds: string[]): Promise<string[]> {
|
|
const unique = [...new Set(userIds)];
|
|
const names = await Promise.all(unique.map(async (id) => (await this.userName(id)) ?? id));
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* Resolves several ids, reporting how many could not be read.
|
|
*
|
|
* `/api/v1/users/{id}` answers 403 for anyone but yourself unless the account
|
|
* has permission over them: a teacher can read their students, a student
|
|
* cannot read their teachers. Printing the raw id in that case is noise, so
|
|
* callers that would show a name to a human use this and say "2 others"
|
|
* instead of pasting two 24-character ids.
|
|
*/
|
|
async resolveNames(userIds: string[]): Promise<{ names: string[]; unresolved: number }> {
|
|
const unique = [...new Set(userIds)];
|
|
const resolved = await Promise.all(unique.map((id) => this.userName(id)));
|
|
return {
|
|
names: resolved.filter((name): name is string => Boolean(name)),
|
|
unresolved: resolved.filter((name) => !name).length,
|
|
};
|
|
}
|
|
|
|
/** Drops the cached identity so the next call re-reads it. */
|
|
reset(): void {
|
|
this.identity = undefined;
|
|
this.userNames.clear();
|
|
}
|
|
}
|