Files
Schulcloud-MCP/src/core/client.ts
MechaCat02 521c21f7ae Reach tasks attached to topics, and read Etherpad pads
Testing against a local instance turned up four things the server was
getting wrong, all of them invisible against the live account because the
data that exposes them had never been produced there.

`GET /lessons/{id}/tasks` returns a bare array, not the `{data,total}`
envelope every sibling endpoint uses, so `.data` was undefined and a
topic's tasks silently vanished. Its items also carry no id at all —
`LessonLinkedTaskResponse` has no id property — which leaves a
topic-attached task unidentifiable: it is not a task element on the
course page, and once past due it is in neither task list. So its
submission, and its grade, could not be reached by any route. That is 18
of 60 tasks on the real account, now reachable: the ids come off the
legacy topic page, where each task is linked as `/homework/{id}`.

The types said `id: string` and `status: TaskStatus` on something that
has neither, which is what let this stay quiet; `LessonLinkedTask` and
`ResolvedTask` now say what is actually there.

Collaborative text editor elements come back with `content: {}`, and the
tool said their contents were unavailable. They are available: the
content-element endpoint returns the pad url *and* an Etherpad session
cookie, and the pad exports itself as text to whoever holds it. No API
key needed. Pads are now shown by get_board and indexed for search.

The store's file digest covered id and size on the grounds that file
records are immutable. `PATCH /file/rename/{id}` renames one in place,
so a rename was reported as nothing at all.

Finally, get_board reported an unpublished board as "no permission",
which sends the reader hunting for an access problem that is not there.

smoke gains checks for topic tasks and for pads, and no longer assumes a
populated index or a search term that happens to match. 39/39 live-only
and 41/41 index-backed, against both the live instance and a local one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 15:39:51 +02:00

449 lines
16 KiB
TypeScript

import type { Config } from '../config.ts';
import type {
BoardContext,
BoardSkeleton,
CardResponse,
CourseBoardResponse,
CourseMetadata,
DashboardResponse,
FileParentType,
FileRecord,
LessonResponse,
MeResponse,
NewsResponse,
Paginated,
SubmissionStatus,
LessonLinkedTask,
TaskContent,
} from './types.ts';
/**
* Maximum repeated query parameters the API's parser will still treat as an
* array — the `qs` default. See `getCards` for what happens above it.
*/
export const MAX_IDS_PER_QUERY = 20;
/** Statuses worth retrying: transient by definition, and every call here is a GET. */
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
const MAX_RETRIES = 3;
/** Exponential backoff with jitter, so parallel workers do not retry in lockstep. */
function backoffMs(attempt: number): number {
const base = 400 * 2 ** (attempt - 1);
return Math.round(base + Math.random() * base * 0.5);
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** An API response outside the 2xx range, carrying the status for callers to branch on. */
export class SchulcloudApiError extends Error {
readonly status: number;
readonly path: string;
readonly body: string;
constructor(status: number, path: string, body: string) {
super(`Schulcloud API ${status} for ${path}${body ? `: ${truncate(body, 400)}` : ''}`);
this.name = 'SchulcloudApiError';
this.status = status;
this.path = path;
this.body = body;
}
/** True when the instance rejected our JWT — the one error the user must act on. */
get isAuthFailure(): boolean {
return this.status === 401;
}
}
function truncate(value: string, max: number): string {
return value.length > max ? `${value.slice(0, max)}` : value;
}
export interface DownloadedFile {
bytes: Buffer;
mimeType: string;
fileName: string;
/** True when the file was longer than `maxDownloadBytes` and got cut short. */
truncated: boolean;
}
/**
* HTTP client for a Schulcloud instance. Read-only apart from `extendSession`,
* which touches only the caller's own session — see its doc comment.
*
* Two services sit behind the same origin and both accept the same bearer
* token: the main server under `/api/v3/*`, and the files-storage service
* under `/api/v3/file/*`. The JWT from the browser's `jwt` cookie works
* verbatim as `Authorization: Bearer` — no cookie jar or session refresh is
* involved, and the token is valid for 30 days (see docs/AUTH.md).
*
* Every method that touches user data is a GET. Keeping the client incapable of
* writing is the main safety property of this server: whoever reaches the MCP
* endpoint can read this account's data but cannot act as the user inside
* Schulcloud. `extendSession` is the single exception and is not exposed as a
* tool, so no model-driven call can ever be a POST.
*/
export class SchulcloudClient {
private readonly config: Config;
constructor(config: Config) {
this.config = config;
}
// --- transport -------------------------------------------------------
private url(path: string, query?: Record<string, string | number | string[] | undefined>): URL {
const url = new URL(`${this.config.baseUrl}${path}`);
for (const [key, value] of Object.entries(query ?? {})) {
if (value === undefined) continue;
if (Array.isArray(value)) for (const v of value) url.searchParams.append(key, v);
else url.searchParams.set(key, String(value));
}
return url;
}
private async request(url: URL, accept: string): Promise<Response> {
let lastError: unknown;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (attempt > 0) await delay(backoffMs(attempt));
let response: Response;
try {
response = await fetch(url, {
headers: { Authorization: `Bearer ${this.config.jwt}`, Accept: accept },
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
redirect: 'follow',
});
} catch (error) {
// Connection reset or timeout: worth one more try, since every call
// here is an idempotent GET.
lastError = error;
if (attempt === MAX_RETRIES) throw error;
continue;
}
if (response.ok) return response;
const body = await response.text().catch(() => '');
const error = new SchulcloudApiError(response.status, url.pathname + url.search, body);
// A crawl issues hundreds of requests and the instance answers some of
// them with a 503 front-page when it decides we are going too fast.
// Observed live: 4 of 26 course pages failed that way in one crawl, and
// all of them succeeded on a retry. 429 and the other gateway errors
// are the same kind of "come back shortly".
if (!RETRYABLE.has(response.status) || attempt === MAX_RETRIES) throw error;
const retryAfter = Number(response.headers.get('retry-after'));
if (Number.isFinite(retryAfter) && retryAfter > 0) await delay(retryAfter * 1000);
lastError = error;
}
throw lastError instanceof Error ? lastError : new Error('request failed');
}
/** Authenticated GET returning JSON. `path` is absolute, e.g. `/api/v3/courses`. */
async getJson<T>(path: string, query?: Record<string, string | number | string[] | undefined>): Promise<T> {
const response = await this.request(this.url(path, query), 'application/json');
return (await response.json()) as T;
}
/**
* Authenticated GET returning bytes, capped at `maxDownloadBytes`.
*
* The cap is enforced while streaming rather than via Content-Length, so a
* mis-declared or chunked response still can't exhaust memory.
*/
async getBytes(path: string, fallbackName: string): Promise<DownloadedFile> {
const url = this.url(path);
const response = await this.request(url, '*/*');
const limit = this.config.maxDownloadBytes;
const chunks: Buffer[] = [];
let total = 0;
let truncated = false;
if (response.body) {
const reader = response.body.getReader();
try {
while (total < limit) {
const { done, value } = await reader.read();
if (done) break;
const chunk = Buffer.from(value);
const room = limit - total;
if (chunk.length > room) {
chunks.push(chunk.subarray(0, room));
total = limit;
truncated = true;
break;
}
chunks.push(chunk);
total += chunk.length;
}
if (total >= limit) {
// Anything still queued is beyond the cap; drop the rest.
const { done } = await reader.read();
if (!done) truncated = true;
}
} finally {
await reader.cancel().catch(() => {});
}
}
return {
bytes: Buffer.concat(chunks),
mimeType: response.headers.get('content-type')?.split(';')[0]?.trim() || 'application/octet-stream',
fileName: filenameFromDisposition(response.headers.get('content-disposition')) ?? fallbackName,
truncated,
};
}
// --- identity --------------------------------------------------------
me(): Promise<MeResponse> {
return this.getJson<MeResponse>('/api/v3/me');
}
// --- session ---------------------------------------------------------
/**
* Extends the current session and reports its remaining budget.
*
* **The only non-GET request in this server, and deliberately so.** It is
* what the web UI's "Sitzung verlängern" button calls. It takes no body,
* touches nothing but the caller's own session, and cannot read or change
* any user data — so it does not weaken the property that matters: nobody
* reaching this server can act as the user inside Schulcloud. No MCP tool
* exposes it, so Claude can never cause a POST; only the keepalive calls it.
*
* Using a plain GET here was tried and does not work — see docs/AUTH.md for
* the endurance test that ruled it out.
*/
async extendSession(): Promise<{ expiresInSeconds: number }> {
const url = this.url('/api/v3/authentication/refresh-session');
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.config.jwt}`,
Accept: 'application/json',
'Content-Length': '0',
},
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new SchulcloudApiError(response.status, url.pathname, body);
}
return (await response.json()) as { expiresInSeconds: number };
}
// --- courses and the classic course board ----------------------------
listCourses(params: { skip?: number; limit?: number } = {}): Promise<Paginated<CourseMetadata>> {
return this.getJson<Paginated<CourseMetadata>>('/api/v3/courses', {
skip: params.skip,
limit: clampPageSize(params.limit),
});
}
/** Every course the account can see, paging past the API's per-page ceiling. */
listAllCourses(max = 500): Promise<CourseMetadata[]> {
return collectPages((skip, limit) => this.listCourses({ skip, limit }), max);
}
/**
* The contents of one course, as the course page shows them: lessons,
* tasks and column boards interleaved. The route is `course-rooms`, and
* its `:roomId` is the *course* id.
*/
getCourseBoard(courseId: string): Promise<CourseBoardResponse> {
return this.getJson<CourseBoardResponse>(`/api/v3/course-rooms/${encodeURIComponent(courseId)}/board`);
}
getDashboard(): Promise<DashboardResponse> {
return this.getJson<DashboardResponse>('/api/v3/dashboard');
}
// --- tasks -----------------------------------------------------------
listTasks(params: { skip?: number; limit?: number } = {}): Promise<Paginated<TaskContent>> {
return this.getJson<Paginated<TaskContent>>('/api/v3/tasks', {
skip: params.skip,
limit: clampPageSize(params.limit),
});
}
listFinishedTasks(params: { skip?: number; limit?: number } = {}): Promise<Paginated<TaskContent>> {
return this.getJson<Paginated<TaskContent>>('/api/v3/tasks/finished', {
skip: params.skip,
limit: clampPageSize(params.limit),
});
}
// --- submissions -----------------------------------------------------
/**
* Submission statuses for one task.
*
* The only way to obtain a submission id: there is no `GET /submissions` and
* no `GET /submissions/{id}`. What comes back depends on the account — a
* student sees their own submission, a teacher sees the whole class's.
*/
async listSubmissionStatuses(taskId: string): Promise<SubmissionStatus[]> {
const page = await this.getJson<{ data: SubmissionStatus[] }>(
`/api/v3/submissions/status/task/${encodeURIComponent(taskId)}`,
);
return page.data ?? [];
}
// --- lessons ---------------------------------------------------------
getLesson(lessonId: string): Promise<LessonResponse> {
return this.getJson<LessonResponse>(`/api/v3/lessons/${encodeURIComponent(lessonId)}`);
}
/**
* A lesson's tasks.
*
* Returns a bare array, not the `{data, total}` envelope every other list
* endpoint uses — checked against both the live instance and a local 33.40.
* Typing it as `Paginated` made `.data` undefined, which silently dropped
* every task attached to a topic: they vanished from get_lesson, get_task
* reported them as non-existent, and their submissions — grades included —
* could not be reached at all. The envelope branch is kept in case the
* endpoint is ever normalised to match its siblings.
*/
async getLessonTasks(lessonId: string): Promise<LessonLinkedTask[]> {
const body = await this.getJson<LessonLinkedTask[] | Paginated<LessonLinkedTask>>(
`/api/v3/lessons/${encodeURIComponent(lessonId)}/tasks`,
);
return Array.isArray(body) ? body : (body.data ?? []);
}
// --- column boards ---------------------------------------------------
getBoardSkeleton(boardId: string): Promise<BoardSkeleton> {
return this.getJson<BoardSkeleton>(`/api/v3/boards/${encodeURIComponent(boardId)}`);
}
getBoardContext(boardId: string): Promise<BoardContext> {
return this.getJson<BoardContext>(`/api/v3/boards/${encodeURIComponent(boardId)}/context`);
}
/**
* Card bodies for the given ids.
*
* **Never request more than 20 at once.** Express/NestJS parse the query
* string with `qs`, whose default `arrayLimit` is 20: past that, repeated
* `ids=` params stop becoming an array and become an object keyed `"0"`,
* `"1"`, … `@IsMongoId({ each: true })` then iterates something that is not
* an array and rejects every value, so the API answers
* `"each value in ids must be a mongodb id"` — blaming the ids when the real
* problem is how many there are. Verified against the live instance: 20 ids
* return 200, 21 return 400, with identical ids.
*
* Nothing in the controller or its DTO says this; the limit lives in the
* query parser underneath them.
*/
async getCards(cardIds: string[]): Promise<CardResponse[]> {
const CHUNK = MAX_IDS_PER_QUERY;
const out: CardResponse[] = [];
for (let i = 0; i < cardIds.length; i += CHUNK) {
const chunk = cardIds.slice(i, i + CHUNK);
const page = await this.getJson<{ data: CardResponse[] }>('/api/v3/cards', { ids: chunk });
out.push(...page.data);
}
return out;
}
// --- files -----------------------------------------------------------
/**
* Files attached to one parent entity.
*
* `storageLocationId` is the school id for `storageLocation: 'school'`,
* which is what every parent type in normal use resolves to. Board file
* elements are addressed with `parentType: 'boardnodes'` and the *element*
* id as `parentId`.
*/
listFiles(args: {
storageLocationId: string;
parentType: FileParentType;
parentId: string;
storageLocation?: 'school' | 'instance';
}): Promise<Paginated<FileRecord>> {
const location = args.storageLocation ?? 'school';
const path =
`/api/v3/file/list/${location}/${encodeURIComponent(args.storageLocationId)}` +
`/${args.parentType}/${encodeURIComponent(args.parentId)}`;
return this.getJson<Paginated<FileRecord>>(path);
}
getFileRecord(fileRecordId: string): Promise<FileRecord> {
return this.getJson<FileRecord>(`/api/v3/file/${encodeURIComponent(fileRecordId)}`);
}
downloadFile(record: Pick<FileRecord, 'id' | 'name'>): Promise<DownloadedFile> {
const path = `/api/v3/file/download/${encodeURIComponent(record.id)}/${encodeURIComponent(record.name)}`;
return this.getBytes(path, record.name);
}
// --- misc ------------------------------------------------------------
listNews(params: { skip?: number; limit?: number } = {}): Promise<Paginated<NewsResponse>> {
return this.getJson<Paginated<NewsResponse>>('/api/v3/news', {
skip: params.skip,
limit: clampPageSize(params.limit),
});
}
}
/**
* The list endpoints reject `limit` above 100 and document a maximum of 99, so
* page at 99 and let `collectPages` stitch the results back together.
*/
export const MAX_PAGE_SIZE = 99;
function clampPageSize(limit: number | undefined): number | undefined {
if (limit === undefined) return undefined;
return Math.min(Math.max(1, Math.trunc(limit)), MAX_PAGE_SIZE);
}
/** Follows `skip`/`limit` paging until `max` items or the server runs out. */
async function collectPages<T>(
fetchPage: (skip: number, limit: number) => Promise<Paginated<T>>,
max: number,
): Promise<T[]> {
const items: T[] = [];
let skip = 0;
while (items.length < max) {
const page = await fetchPage(skip, Math.min(MAX_PAGE_SIZE, max - items.length));
items.push(...page.data);
skip += page.data.length;
// Stop on an empty page too, so a server that ignores `skip` can't loop forever.
if (page.data.length === 0 || skip >= page.total) break;
}
return items.slice(0, max);
}
function filenameFromDisposition(header: string | null): string | undefined {
if (!header) return undefined;
// Prefer RFC 5987 `filename*`, which carries the encoding explicitly.
const extended = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(header);
if (extended?.[1]) return safeDecode(extended[1].trim());
const plain = /filename="?([^";]+)"?/.exec(header);
if (plain?.[1]) return safeDecode(plain[1].trim());
return undefined;
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}