Serve rooms ("Räume"), which are not courses however the urls read

The account this was built against is in no rooms, so the whole space was
invisible and easy to dismiss as an empty endpoint. It is not empty in
general — the user had rooms until a teacher removed access — and the
UI's naming actively hides the distinction: the sidebar's *Kurse* entry
links to `/rooms/courses-overview` and lists courses, while *Räume* links
to `/rooms` and lists rooms. A url containing `/rooms` identifies neither.

list_rooms and get_room cover the latter. A room holds boards and nothing
else, so get_room lists boards for get_board (which already reports "in
room" from the board context) plus who else is in it. Room boards report
`isVisible`, which the course-page projection does not, so a draft is
named as a draft instead of being offered and then answering 403.

Rooms also go through the crawl, or they would have become the next
blind spot: their boards are indexed, searchable by both the index and
the live-crawl path, diffed by what_changed, and mirrored by the CLI
under the room's name. The board traversal and the snapshot matcher are
now shared between courses and rooms rather than duplicated, which also
fixed the live-crawl path silently not searching pad contents.

The CLI needed no new command — it is file-centric and inherits rooms
through the manifest — but `--course` now accepts a room id, and says so.

`kind` gains 'room'; the column is plain TEXT, so no migration. 112 tests.
Smoke: 42/42 and 44/44 local, 41/41 and 43/43 live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-13 19:24:53 +02:00
parent 3a168e37e5
commit 1de026ca43
19 changed files with 584 additions and 95 deletions

View File

@@ -111,7 +111,14 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
- Course contents are at `GET /api/v3/course-rooms/{courseId}/board`. There is - Course contents are at `GET /api/v3/course-rooms/{courseId}/board`. There is
no `GET /api/v3/courses/{id}`, and `:roomId` there is the *course* id. no `GET /api/v3/courses/{id}`, and `:roomId` there is the *course* id.
- `/api/v3/rooms` is an unrelated newer feature, not courses. Empty is normal. - **Rooms ("Räume") are a separate space from courses, and the UI's naming is a
trap**: the sidebar's *Kurse* entry links to `/rooms/courses-overview` and
lists courses; *Räume* links to `/rooms` and lists rooms. A `/rooms/...` url
says nothing about which. `list_rooms`/`get_room` cover the latter; a room
holds boards only — no lessons, no tasks. Empty is normal and is also what a
revoked membership looks like.
- Room boards report `isVisible`, which the course-page projection does not, so
a room's drafts can be named as drafts instead of being tried and 403ing.
- `limit` is rejected above 100 though the spec says 99. Page at 99; the client - `limit` is rejected above 100 though the spec says 99. Page at 99; the client
clamps and `listAllCourses` pages for you. clamps and `listAllCourses` pages for you.
- There is no `GET /tasks/{id}`, and the task lists omit `description` — it - There is no `GET /tasks/{id}`, and the task lists omit `description` — it

View File

@@ -89,10 +89,18 @@ does not exist. The route that returns a course's lessons/tasks/boards is
`GET /api/v3/course-rooms/{roomId}/board`, and its `:roomId` is the *course* id. `GET /api/v3/course-rooms/{roomId}/board`, and its `:roomId` is the *course* id.
Nothing in the naming suggests this. Nothing in the naming suggests this.
**`/api/v3/rooms` is a different feature.** "Rooms" are the newer standalone **`/api/v3/rooms` is a different feature, and the UI's naming hides it.** Rooms
collaboration spaces, unrelated to courses. On this instance the account has ("Räume") are the newer standalone collaboration spaces. The sidebar's *Kurse*
none, so `GET /api/v3/rooms` returns `{"data":[]}` — which reads like a broken entry links to `/rooms/courses-overview` and lists **courses** (served by
endpoint but is simply an empty feature. `/api/v3/dashboard` + `/api/v3/courses`), while *Räume* links to `/rooms` and
lists **rooms** — so a url containing `/rooms` identifies neither.
A room holds boards and nothing else: no lessons, no tasks. `GET /rooms`
answers `{"data":[]}` with no `total`, derived from real memberships, so an
empty result means the account is in no rooms — which is also what it looks
like after a teacher deletes a room or revokes access. `GET /rooms/{id}/boards`
does report `isVisible`, unlike the course-page projection, so a room's draft
boards can be identified without trying to open one.
**`limit` maxima are enforced and mis-documented.** The OpenAPI schema says **`limit` maxima are enforced and mis-documented.** The OpenAPI schema says
`maximum: 99`; the runtime validator rejects anything `> 100`. Page at 99 to `maximum: 99`; the runtime validator rejects anything `> 100`. Page at 99 to

View File

@@ -43,6 +43,9 @@ schulcloud refresh [--course <id>] [--force]
`ls --long` prints file ids, which is what `get` takes. `ls --long` prints file ids, which is what `get` takes.
`--course` accepts a **course or a room id** — rooms ("Räume") are mirrored
alongside courses, with their files under the room's name rather than a course's.
`refresh` asks the server to re-read Schulcloud. Pass `--course` when you know `refresh` asks the server to re-read Schulcloud. Pass `--course` when you know
what changed: that is a handful of requests, where a full re-crawl reads every what changed: that is a handful of requests, where a full re-crawl reads every
course. The server refuses a repeat within a minute unless you pass `--force`. course. The server refuses a repeat within a minute unless you pass `--force`.

View File

@@ -193,6 +193,12 @@ eval "$(./scripts/mcp-env.sh)" # as the demo student
cd .. && npm run smoke # 39 checks against the local instance cd .. && npm run smoke # 39 checks against the local instance
``` ```
It also builds a **room** ("Raum"): rooms are a separate space from courses,
and the naming misleads — the sidebar's *Kurse* entry points at
`/rooms/courses-overview` while *Räume* points at `/rooms`. The fixture adds the
demo student to one room and leaves a second room without them, so "only the
rooms I belong to" is testable rather than assumed.
Two things it does **not** do, because the API does not allow them: Two things it does **not** do, because the API does not allow them:
- **Teams cannot be created.** The legacy service registers - **Teams cannot be created.** The legacy service registers

View File

@@ -124,6 +124,52 @@ async function create() {
keep('roomId', room.id); keep('roomId', room.id);
log(`room ${s.roomId}`); log(`room ${s.roomId}`);
step('room contents');
// Rooms are the newer collaboration space, separate from courses: a room has
// its own members and its own boards, and appears under "Räume" in the UI
// while courses appear under "Kurse" at a /rooms/... url.
await v3('PATCH', `/rooms/${s.roomId}/members/add`, { userIds: [student] });
log('demo student added as a room member');
const roomBoard = await v3('POST', '/boards', {
title: `MCP-Test Raum-Board (${stamp})`,
parentId: s.roomId,
parentType: 'room',
layout: 'columns',
});
keep('roomBoardId', roomBoard.id);
const roomColumn = await v3('POST', `/boards/${s.roomBoardId}/columns`);
await v3('PATCH', `/columns/${roomColumn.id}/title`, { title: 'Projektarbeit' });
const roomCard = await v3('POST', `/columns/${roomColumn.id}/cards`);
await v3('PATCH', `/cards/${roomCard.id}/title`, { title: 'Aufgabenverteilung' });
const roomText = await v3('POST', `/cards/${roomCard.id}/elements`, { type: 'richText' });
await v3('PATCH', `/elements/${roomText.id}/content`, {
data: {
content: {
text: '<p>Raum-Inhalt, nicht Kurs-Inhalt. Suchbegriff: Projektsteuerung.</p>',
inputFormat: 'richTextCk5',
},
type: 'richText',
},
});
// A file in a room board, so the mirror path (room name, not course name) and
// the CLI manifest are exercised for rooms too.
const roomFileEl = await v3('POST', `/cards/${roomCard.id}/elements`, { type: 'file' });
keep('roomFileId', await upload(roomFileEl.id, 'projektplan.txt', 'text/plain',
'Projektplan\n\nMeilenstein 1: Anforderungen. Stichwort: Projektsteuerung.\n'));
await v3('PATCH', `/boards/${s.roomBoardId}/visibility`, { isVisible: true });
log(`room board ${s.roomBoardId} published, with one card`);
// A second room the student is NOT in, so "only rooms I belong to" is testable.
const other = await v3('POST', '/rooms', {
name: `MCP-Test Raum ohne Zugriff (${stamp})`,
color: 'red',
features: [],
});
keep('roomWithoutStudentId', other.id);
log(`second room ${other.id} left without the student on purpose`);
step('team'); step('team');
// Teams cannot be created through the API: the legacy service registers // Teams cannot be created through the API: the legacy service registers
// ['find','get','update','patch','remove'] and no 'create' // ['find','get','update','patch','remove'] and no 'create'
@@ -314,6 +360,7 @@ async function remove() {
step('deleting what was created'); step('deleting what was created');
const tries = [ const tries = [
['file', () => files('DELETE', `/delete/${s.folderFileId}`)], ['file', () => files('DELETE', `/delete/${s.folderFileId}`)],
['room file', () => files('DELETE', `/delete/${s.roomFileId}`)],
['file element', () => v3('DELETE', `/elements/${s.fileElementId}`)], ['file element', () => v3('DELETE', `/elements/${s.fileElementId}`)],
['fileFolder element', () => v3('DELETE', `/elements/${s.folderId}`)], ['fileFolder element', () => v3('DELETE', `/elements/${s.folderId}`)],
['etherpad element', () => v3('DELETE', `/elements/${s.padElementId}`)], ['etherpad element', () => v3('DELETE', `/elements/${s.padElementId}`)],
@@ -324,7 +371,9 @@ async function remove() {
['draft board', () => v3('DELETE', `/boards/${s.draftBoardId}`)], ['draft board', () => v3('DELETE', `/boards/${s.draftBoardId}`)],
['task', () => v3('DELETE', `/tasks/${s.taskId}`)], ['task', () => v3('DELETE', `/tasks/${s.taskId}`)],
['topic', () => v3('DELETE', `/lessons/${s.lessonId}`)], ['topic', () => v3('DELETE', `/lessons/${s.lessonId}`)],
['room board', () => v3('DELETE', `/boards/${s.roomBoardId}`)],
['room', () => v3('DELETE', `/rooms/${s.roomId}`)], ['room', () => v3('DELETE', `/rooms/${s.roomId}`)],
['second room', () => v3('DELETE', `/rooms/${s.roomWithoutStudentId}`)],
['course', () => v1('DELETE', `/courses/${s.courseId}`)], ['course', () => v1('DELETE', `/courses/${s.courseId}`)],
]; ];
for (const [what, fn] of tries) { for (const [what, fn] of tries) {

View File

@@ -198,6 +198,29 @@ if (fileId) {
check('download_file', false, 'no file id found to test with'); check('download_file', false, 'no file id found to test with');
} }
console.log('\n== rooms ==');
// Rooms ("Räume") are a separate space from courses. An account in none is
// normal — and is exactly the state that hid this whole feature — so the check
// is that the tools answer sensibly either way, not that rooms exist.
{
const listed = await call('list_rooms');
check('list_rooms responds', !listed.isError, listed.text.split('\n')[0]);
const roomId = listed.text.match(/\(`([0-9a-f]{24})`\)/)?.[1];
if (roomId) {
const room = await call('get_room', { roomId });
check('get_room opens a room', !room.isError && /Room id:/.test(room.text), roomId);
const roomBoardId = room.text.match(/### Boards[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
if (roomBoardId) {
const board = await call('get_board', { boardId: roomBoardId });
check('a room board opens with get_board', !board.isError && /in room/.test(board.text), roomBoardId);
} else {
check('a room board opens with get_board', true, 'the room has no boards');
}
} else {
check('get_room opens a room', true, 'this account is in no rooms — nothing to open');
}
}
console.log('\n== search =='); console.log('\n== search ==');
const searchTerm = process.env.SMOKE_SEARCH ?? 'Datenschutz'; const searchTerm = process.env.SMOKE_SEARCH ?? 'Datenschutz';
const search = await call('search', { query: searchTerm, fresh: true, courseId: courseIds[0] }); const search = await call('search', { query: searchTerm, fresh: true, courseId: courseIds[0] });

View File

@@ -26,6 +26,9 @@ const USAGE = `schulcloud — browse and mirror your Schulcloud files
schulcloud sync [--dry-run] [--full] [--prune] [--dir <path>] [--jobs <n>] schulcloud sync [--dry-run] [--full] [--prune] [--dir <path>] [--jobs <n>]
schulcloud refresh [--course <id>] [--force] schulcloud refresh [--course <id>] [--force]
--course takes a course or a room id: rooms ("Räume") mirror alongside courses
and their files sit under the room's name.
Options are also read from SCHULCLOUD_SERVER, SCHULCLOUD_TOKEN and Options are also read from SCHULCLOUD_SERVER, SCHULCLOUD_TOKEN and
SCHULCLOUD_SYNC_DIR. Config file: ${configPath()} SCHULCLOUD_SYNC_DIR. Config file: ${configPath()}
`; `;
@@ -107,6 +110,8 @@ async function list(flags: Flags): Promise<number> {
const api = new ApiClient(await loadCliConfig()); const api = new ApiClient(await loadCliConfig());
const manifest = await api.manifest(); const manifest = await api.manifest();
let entries = manifest.entries.filter((entry) => entry.status !== 'removed'); let entries = manifest.entries.filter((entry) => entry.status !== 'removed');
// A room id works here too: the manifest's courseId is the container id, and
// since rooms were added that container can be a room.
if (flags.course) entries = entries.filter((entry) => entry.courseId === flags.course); if (flags.course) entries = entries.filter((entry) => entry.courseId === flags.course);
if (entries.length === 0) { if (entries.length === 0) {

View File

@@ -14,6 +14,10 @@ import type {
Paginated, Paginated,
SubmissionStatus, SubmissionStatus,
LessonLinkedTask, LessonLinkedTask,
RoomBoardItem,
RoomDetails,
RoomItem,
RoomMember,
TaskContent, TaskContent,
} from './types.ts'; } from './types.ts';
@@ -323,6 +327,44 @@ export class SchulcloudClient {
return Array.isArray(body) ? body : (body.data ?? []); return Array.isArray(body) ? body : (body.data ?? []);
} }
// --- rooms ------------------------------------------------------------
/**
* The rooms this account belongs to.
*
* Returns `{data}` with no `total` — not the usual paginated envelope. The
* server derives the list from actual room memberships, so an empty result
* means exactly that, and a room a teacher revoked access to simply stops
* appearing.
*/
async listRooms(): Promise<RoomItem[]> {
const body = await this.getJson<{ data?: RoomItem[] }>('/api/v3/rooms');
return body.data ?? [];
}
getRoom(roomId: string): Promise<RoomDetails> {
return this.getJson<RoomDetails>(`/api/v3/rooms/${encodeURIComponent(roomId)}`);
}
async listRoomBoards(roomId: string): Promise<RoomBoardItem[]> {
const body = await this.getJson<Paginated<RoomBoardItem>>(
`/api/v3/rooms/${encodeURIComponent(roomId)}/boards`,
);
return body.data ?? [];
}
/**
* A room's members. Needs no special permission for a member to see who else
* is in the room, but it can still be refused — callers treat that as "not
* available" rather than as an error worth surfacing.
*/
async listRoomMembers(roomId: string): Promise<RoomMember[]> {
const body = await this.getJson<{ data?: RoomMember[] }>(
`/api/v3/rooms/${encodeURIComponent(roomId)}/members`,
);
return body.data ?? [];
}
// --- column boards --------------------------------------------------- // --- column boards ---------------------------------------------------
getBoardSkeleton(boardId: string): Promise<BoardSkeleton> { getBoardSkeleton(boardId: string): Promise<BoardSkeleton> {

View File

@@ -17,7 +17,14 @@ import type { CourseMetadata, FileRecord, TaskContent } from './types.ts';
* a separate pass keyed off the file records collected here. * a separate pass keyed off the file records collected here.
*/ */
/** Where an item sits, for building mirror paths and human-readable hits. */ /**
* Where an item sits, for building mirror paths and human-readable hits.
*
* `courseId`/`courseTitle` name the *container*, which since rooms were added
* is a course or a room. The names are kept because they are also the manifest
* wire format the CLI reads; renaming them would break older clients for no
* gain here.
*/
export interface Breadcrumb { export interface Breadcrumb {
courseId: string; courseId: string;
courseTitle: string; courseTitle: string;
@@ -70,10 +77,25 @@ export interface CrawledCourse {
tasks: CrawledTask[]; tasks: CrawledTask[];
} }
/**
* A room and its boards.
*
* Rooms hold boards and nothing else — no lessons, no tasks — so this is
* deliberately thinner than CrawledCourse rather than a course with empty
* fields.
*/
export interface CrawledRoom {
id: string;
name: string;
boards: CrawledBoard[];
}
export interface Snapshot { export interface Snapshot {
crawledAt: Date; crawledAt: Date;
schoolId: string; schoolId: string;
courses: CrawledCourse[]; courses: CrawledCourse[];
/** Rooms ("Räume"), a separate space from courses. */
rooms: CrawledRoom[];
files: CrawledFile[]; files: CrawledFile[];
/** /**
* Anything that could not be read, with the reason. Boards appear here too: * Anything that could not be read, with the reason. Boards appear here too:
@@ -127,12 +149,130 @@ export async function crawl(client: SchulcloudClient, options: CrawlOptions): Pr
} }
}); });
// Rooms are a separate space from courses and are only walked on a full
// crawl: a per-course refresh names a course, and the store carries anything
// outside that scope forward untouched.
const rooms: CrawledRoom[] = options.courseIds ? [] : await crawlRooms(client, options, includeFiles, files, failures);
// Traversal order is nondeterministic under concurrency; sort so that two // Traversal order is nondeterministic under concurrency; sort so that two
// crawls of unchanged content produce identical snapshots. // crawls of unchanged content produce identical snapshots.
crawled.sort((a, b) => a.course.id.localeCompare(b.course.id)); crawled.sort((a, b) => a.course.id.localeCompare(b.course.id));
rooms.sort((a, b) => a.id.localeCompare(b.id));
files.sort((a, b) => a.record.id.localeCompare(b.record.id)); files.sort((a, b) => a.record.id.localeCompare(b.record.id));
return { crawledAt: new Date(), schoolId: options.schoolId, courses: crawled, files, failures }; return { crawledAt: new Date(), schoolId: options.schoolId, courses: crawled, rooms, files, failures };
}
/**
* Walks the rooms this account belongs to.
*
* A room holds only boards. Unpublished ones are skipped rather than attempted:
* the room board listing reports `isVisible`, so unlike a course board there is
* no need to try one and record the resulting 403 as a failure.
*
* A room being unreadable is recorded, not swallowed — same rule as boards.
*/
async function crawlRooms(
client: SchulcloudClient,
options: CrawlOptions,
includeFiles: boolean,
files: CrawledFile[],
failures: { courseId: string; boardId?: string; reason: string }[],
): Promise<CrawledRoom[]> {
let listed;
try {
listed = await client.listRooms();
} catch (error) {
failures.push({ courseId: '(rooms)', reason: error instanceof Error ? error.message : String(error) });
return [];
}
const rooms: CrawledRoom[] = [];
await forEachLimited(listed, options.courseConcurrency ?? 5, async (room) => {
const boards: CrawledBoard[] = [];
try {
const listing = await client.listRoomBoards(room.id);
const readable = listing.filter((board) => board.isVisible !== false).map((board) => board.id);
await crawlBoards(client, options, { id: room.id, title: room.name }, readable, includeFiles, boards, files, failures);
} catch (error) {
failures.push({ courseId: room.id, reason: error instanceof Error ? error.message : String(error) });
}
boards.sort((a, b) => a.id.localeCompare(b.id));
rooms.push({ id: room.id, name: room.name, boards });
});
return rooms;
}
/**
* Assembles a set of boards into a container — a course or a room.
*
* Shared so that rooms are not a second, quietly divergent traversal: when pads
* or file handling change here, both get it.
*/
async function crawlBoards(
client: SchulcloudClient,
options: CrawlOptions,
container: { id: string; title: string },
boardIds: string[],
includeFiles: boolean,
boards: CrawledBoard[],
files: CrawledFile[],
failures: { courseId: string; boardId?: string; reason: string }[],
): Promise<void> {
await forEachLimited(boardIds, options.boardConcurrency ?? 4, async (boardId) => {
let assembled: AssembledBoard;
try {
assembled = await assembleBoard(client, boardId, options.schoolId, {
resolveFiles: includeFiles,
resolvePads: options.config,
});
} catch (error) {
// Record rather than swallow: a dropped board used to disappear from the
// index while the crawl still reported success, which is how a 20-card
// query limit went unnoticed.
failures.push({
courseId: container.id,
boardId,
reason: error instanceof Error ? error.message : String(error),
});
return;
}
const parts: string[] = [];
for (const column of assembled.columns) {
for (const card of column.cards) {
parts.push(card.title);
for (const element of card.elements) {
if (element.text) parts.push(htmlToText(element.text));
// Pad contents are course material like any other; without this they
// are unsearchable, and a pad is often where the actual group work is.
if (element.padText) parts.push(element.padText);
if (element.url) parts.push(element.url);
for (const record of element.files) {
files.push({
record,
parentType: 'boardnodes',
parentId: element.id,
at: {
courseId: container.id,
courseTitle: container.title,
containerTitle: assembled.title,
columnTitle: column.title,
cardTitle: card.title,
},
});
}
}
}
}
boards.push({
id: assembled.id,
title: assembled.title,
courseId: container.id,
board: assembled,
text: parts.filter(Boolean).join('\n'),
});
});
} }
async function crawlCourse( async function crawlCourse(
@@ -206,60 +346,7 @@ async function crawlCourse(
} }
} }
await forEachLimited(boardIds, options.boardConcurrency ?? 4, async (boardId) => { await crawlBoards(client, options, { id: course.id, title }, boardIds, includeFiles, boards, files, failures);
let assembled: AssembledBoard;
try {
assembled = await assembleBoard(client, boardId, options.schoolId, {
resolveFiles: includeFiles,
resolvePads: options.config,
});
} catch (error) {
// Record rather than swallow: a dropped board used to disappear from the
// index while the crawl still reported success, which is how a 20-card
// query limit went unnoticed.
failures.push({
courseId: course.id,
boardId,
reason: error instanceof Error ? error.message : String(error),
});
return;
}
const parts: string[] = [];
for (const column of assembled.columns) {
for (const card of column.cards) {
parts.push(card.title);
for (const element of card.elements) {
if (element.text) parts.push(htmlToText(element.text));
// Pad contents are course material like any other; without this they
// are unsearchable, and a pad is often where the actual group work is.
if (element.padText) parts.push(element.padText);
if (element.url) parts.push(element.url);
for (const record of element.files) {
files.push({
record,
parentType: 'boardnodes',
parentId: element.id,
at: {
courseId: course.id,
courseTitle: title,
containerTitle: assembled.title,
columnTitle: column.title,
cardTitle: card.title,
},
});
}
}
}
}
boards.push({
id: assembled.id,
title: assembled.title,
courseId: course.id,
board: assembled,
text: parts.filter(Boolean).join('\n'),
});
});
boards.sort((a, b) => a.id.localeCompare(b.id)); boards.sort((a, b) => a.id.localeCompare(b.id));
return { course: { course, title, boards, lessons, tasks }, files, failures }; return { course: { course, title, boards, lessons, tasks }, files, failures };

View File

@@ -1,4 +1,4 @@
import type { Snapshot } from './crawl.ts'; import type { CrawledBoard, Snapshot } from './crawl.ts';
import { matchesAll, snippet, tokenize } from './text.ts'; import { matchesAll, snippet, tokenize } from './text.ts';
/** /**
@@ -17,10 +17,51 @@ export interface Hit {
where: string; where: string;
/** Id to pass to a follow-up tool, with the tool that takes it. */ /** Id to pass to a follow-up tool, with the tool that takes it. */
targetId: string; targetId: string;
targetKind: 'course' | 'board' | 'lesson' | 'task' | 'file'; targetKind: 'course' | 'room' | 'board' | 'lesson' | 'task' | 'file';
snippet: string; snippet: string;
} }
/**
* Matches a container's boards. Shared by courses and rooms so a hit in a room
* looks and behaves exactly like a hit in a course.
*/
function matchBoards(
boards: CrawledBoard[],
base: { courseId: string; courseTitle: string },
terms: string[],
push: (hit: Hit) => void,
): void {
for (const board of boards) {
if (matchesAll(board.title, terms)) {
push({ ...base, where: 'board title', targetId: board.id, targetKind: 'board', snippet: board.title });
}
// Match per card, so the snippet points at the right part of the board.
for (const column of board.board.columns) {
for (const card of column.cards) {
const parts = [card.title];
for (const element of card.elements) {
if (element.text) parts.push(element.text);
// Pad contents are searched by the index; without this the live
// crawl path would quietly disagree with it.
if (element.padText) parts.push(element.padText);
if (element.url) parts.push(element.url);
for (const file of element.files) parts.push(file.name);
}
const haystack = parts.filter(Boolean).join('\n');
if (matchesAll(haystack, terms)) {
push({
...base,
where: `board "${board.title}" → card "${card.title}"`,
targetId: board.id,
targetKind: 'board',
snippet: snippet(haystack, terms),
});
}
}
}
}
}
export function searchSnapshot(snapshot: Snapshot, query: string, limit = 50): Hit[] { export function searchSnapshot(snapshot: Snapshot, query: string, limit = 50): Hit[] {
const terms = tokenize(query); const terms = tokenize(query);
if (terms.length === 0) return []; if (terms.length === 0) return [];
@@ -37,32 +78,7 @@ export function searchSnapshot(snapshot: Snapshot, query: string, limit = 50): H
push({ ...base, where: 'course title', targetId: course.course.id, targetKind: 'course', snippet: course.title }); push({ ...base, where: 'course title', targetId: course.course.id, targetKind: 'course', snippet: course.title });
} }
for (const board of course.boards) { matchBoards(course.boards, base, terms, push);
if (matchesAll(board.title, terms)) {
push({ ...base, where: 'board title', targetId: board.id, targetKind: 'board', snippet: board.title });
}
// Match per card, so the snippet points at the right part of the board.
for (const column of board.board.columns) {
for (const card of column.cards) {
const parts = [card.title];
for (const element of card.elements) {
if (element.text) parts.push(element.text);
if (element.url) parts.push(element.url);
for (const file of element.files) parts.push(file.name);
}
const haystack = parts.filter(Boolean).join('\n');
if (matchesAll(haystack, terms)) {
push({
...base,
where: `board "${board.title}" → card "${card.title}"`,
targetId: board.id,
targetKind: 'board',
snippet: snippet(haystack, terms),
});
}
}
}
}
for (const lesson of course.lessons) { for (const lesson of course.lessons) {
const haystack = `${lesson.name}\n${lesson.text}`; const haystack = `${lesson.name}\n${lesson.text}`;
@@ -85,6 +101,16 @@ export function searchSnapshot(snapshot: Snapshot, query: string, limit = 50): H
} }
} }
// Rooms carry boards and nothing else, so they reuse the same matcher; a hit
// reads the same whether the board hangs off a course or a room.
for (const room of snapshot.rooms) {
const base = { courseId: room.id, courseTitle: room.name };
if (matchesAll(room.name, terms)) {
push({ ...base, where: 'room title', targetId: room.id, targetKind: 'room', snippet: room.name });
}
matchBoards(room.boards, base, terms, push);
}
for (const file of snapshot.files) { for (const file of snapshot.files) {
if (matchesAll(file.record.name, terms)) { if (matchesAll(file.record.name, terms)) {
hits.push({ hits.push({

View File

@@ -239,6 +239,61 @@ export type FileParentType =
| 'boardnodes' | 'boardnodes'
| 'externaltools'; | 'externaltools';
/**
* A room ("Raum") — the newer collaboration space, separate from courses.
*
* The naming is a trap worth knowing: the sidebar's *Kurse* entry points at
* `/rooms/courses-overview` and shows courses, while *Räume* points at `/rooms`
* and shows these. A url containing `/rooms` says nothing about which one it is.
*
* Unlike a course, a room holds only boards — no lessons, no tasks.
*/
export interface RoomItem {
id: string;
name: string;
color?: string;
schoolId?: string;
startDate?: string;
endDate?: string;
createdAt?: string;
updatedAt?: string;
/** What this account may do here — 'room_edit_content' and friends. */
allowedOperations?: string[];
isLocked?: boolean;
totalMembers?: number;
}
export interface RoomDetails extends Omit<RoomItem, 'isLocked' | 'totalMembers'> {
features?: string[];
}
/**
* A board inside a room.
*
* Carries `isVisible`, which the course-page projection does not — so unlike a
* course board, a room's draft boards can be told apart before trying to open
* one and getting a 403.
*/
export interface RoomBoardItem {
id: string;
title: string;
layout?: string;
isVisible?: boolean;
createdAt?: string;
updatedAt?: string;
allowedOperations?: string[];
}
export interface RoomMember {
userId: string;
firstName?: string;
lastName?: string;
/** roomowner | roomadmin | roomeditor | roomviewer */
roomRoleName?: string;
schoolRoleNames?: string[];
schoolName?: string;
}
export const FILE_PARENT_TYPES: FileParentType[] = [ export const FILE_PARENT_TYPES: FileParentType[] = [
'users', 'users',
'schools', 'schools',

View File

@@ -23,6 +23,8 @@ export interface IndexResult {
crawlId: number; crawlId: number;
scope: string; scope: string;
courses: number; courses: number;
/** Rooms walked. Zero is normal — many accounts are in none. */
rooms: number;
files: number; files: number;
mirrored: number; mirrored: number;
extracted: number; extracted: number;
@@ -123,6 +125,7 @@ export class Indexer {
crawlId, crawlId,
scope, scope,
courses: snapshot.courses.length, courses: snapshot.courses.length,
rooms: snapshot.rooms.length,
files: snapshot.files.length, files: snapshot.files.length,
mirrored, mirrored,
extracted, extracted,

View File

@@ -8,6 +8,7 @@ import { registerOverviewTools } from './tools/overview.ts';
import { registerRawTool } from './tools/raw.ts'; import { registerRawTool } from './tools/raw.ts';
import { registerIndexTools } from './tools/index-tools.ts'; import { registerIndexTools } from './tools/index-tools.ts';
import { registerSearchTool } from './tools/search.ts'; import { registerSearchTool } from './tools/search.ts';
import { registerRoomTools } from './tools/rooms.ts';
import { registerSubmissionTools } from './tools/submissions.ts'; import { registerSubmissionTools } from './tools/submissions.ts';
export const SERVER_NAME = 'schulcloud-mcp'; export const SERVER_NAME = 'schulcloud-mcp';
@@ -48,6 +49,7 @@ export function createServer(config: Config, services?: Services): { server: Mcp
registerOverviewTools(server, context); registerOverviewTools(server, context);
registerContentTools(server, context); registerContentTools(server, context);
registerRoomTools(server, context);
registerFileTools(server, context); registerFileTools(server, context);
registerSearchTool(server, context); registerSearchTool(server, context);
registerSubmissionTools(server, context); registerSubmissionTools(server, context);

View File

@@ -40,7 +40,7 @@ export function registerIndexTools(server: McpServer, context: ServerContext): v
[ [
`- Scope: ${result.scope === 'full' ? 'all courses' : `course ${result.scope}`}`, `- Scope: ${result.scope === 'full' ? 'all courses' : `course ${result.scope}`}`,
`- Generation: ${result.crawlId}`, `- Generation: ${result.crawlId}`,
`- Courses: ${result.courses}, files: ${result.files}`, `- Courses: ${result.courses}${result.rooms > 0 ? `, rooms: ${result.rooms}` : ''}, files: ${result.files}`,
`- Newly mirrored: ${result.mirrored}, text extracted: ${result.extracted}, skipped: ${result.skipped}`, `- Newly mirrored: ${result.mirrored}, text extracted: ${result.extracted}, skipped: ${result.skipped}`,
`- Took ${(result.durationMs / 1000).toFixed(1)}s`, `- Took ${(result.durationMs / 1000).toFixed(1)}s`,
result.failures.length > 0 result.failures.length > 0
@@ -73,7 +73,7 @@ export function registerIndexTools(server: McpServer, context: ServerContext): v
.string() .string()
.describe('An ISO date/time, or a generation id from refresh_index. e.g. "2026-09-10".'), .describe('An ISO date/time, or a generation id from refresh_index. e.g. "2026-09-10".'),
kinds: z kinds: z
.array(z.enum(['course', 'board', 'lesson', 'task', 'file'])) .array(z.enum(['course', 'room', 'board', 'lesson', 'task', 'file']))
.optional() .optional()
.describe('Restrict to certain kinds of thing. Omit for all.'), .describe('Restrict to certain kinds of thing. Omit for all.'),
limit: z.number().int().min(1).max(200).default(50).describe('Maximum entries per section.'), limit: z.number().int().min(1).max(200).default(50).describe('Maximum entries per section.'),

114
src/mcp/tools/rooms.ts Normal file
View File

@@ -0,0 +1,114 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../../context.ts';
import { formatDate, heading, joinSections } from '../../core/text.ts';
import type { RoomBoardItem, RoomMember } from '../../core/types.ts';
import { text, toToolError } from './result.ts';
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
/**
* Rooms ("Räume") — the newer collaboration space, alongside courses.
*
* Worth stating plainly in the tool descriptions, because the UI's own naming
* misleads: the sidebar entry *Kurse* links to `/rooms/courses-overview` and
* lists courses, while *Räume* links to `/rooms` and lists these. A model that
* sees a `/rooms/...` url cannot tell from it which of the two it is looking at.
*/
export function registerRoomTools(server: McpServer, context: ServerContext): void {
server.registerTool(
'list_rooms',
{
title: 'List rooms',
description:
'The rooms ("Räume") this account belongs to. Rooms are a separate space from courses — they hold ' +
'column boards but no topics and no tasks. Use list_courses for courses ("Kurse"); the two are ' +
'different things despite the UI listing courses under a /rooms/... url. An empty result is normal ' +
'and simply means the account is in no rooms, which is also what happens after a teacher removes access.',
inputSchema: {},
annotations: READ_ONLY,
},
async () => {
try {
const rooms = await context.client.listRooms();
if (rooms.length === 0) {
return text(
'This account is a member of no rooms ("Räume").\n\n' +
'That is not an error — rooms are separate from courses, and membership ends when a room ' +
'is deleted or access is revoked. Course content is under list_courses.',
);
}
return text(
joinSections([
heading(2, `Rooms (${rooms.length})`),
rooms
.map((room) => {
const facts = [
room.totalMembers === undefined ? undefined : `${room.totalMembers} member(s)`,
room.isLocked ? '**locked**' : undefined,
room.endDate ? `until ${formatDate(room.endDate)}` : undefined,
].filter(Boolean);
return `- **${room.name}** (\`${room.id}\`)${facts.length > 0 ? `${facts.join(', ')}` : ''}`;
})
.join('\n'),
'Read one with get_room.',
]),
);
} catch (error) {
return toToolError(error, 'list rooms');
}
},
);
server.registerTool(
'get_room',
{
title: 'Get room',
description:
'One room and everything in it: its column boards, ready for get_board, plus who else is in it. ' +
'A room has no topics and no tasks — if you are looking for homework, that lives in courses.',
inputSchema: {
roomId: z.string().describe('Room id from list_rooms.'),
},
annotations: READ_ONLY,
},
async ({ roomId }) => {
try {
// Members and boards are both allowed to fail without costing the room:
// a viewer may be refused the member list, and boards can be empty.
const [room, boards, members] = await Promise.all([
context.client.getRoom(roomId),
context.client.listRoomBoards(roomId).catch(() => [] as RoomBoardItem[]),
context.client.listRoomMembers(roomId).catch(() => [] as RoomMember[]),
]);
return text(formatRoom(room.name, roomId, boards, members));
} catch (error) {
return toToolError(error, `read room ${roomId}`);
}
},
);
}
function formatRoom(name: string, roomId: string, boards: RoomBoardItem[], members: RoomMember[]): string {
// Room boards report `isVisible`, so an unpublished one can be named as such
// instead of being offered and then answering 403 — which is all a course
// board can do, since the course projection omits the flag.
const boardLines = boards.map((board) => {
const draft = board.isVisible === false ? ' — **not published yet**, get_board will refuse it' : '';
return `- **${board.title}** (\`${board.id}\`)${draft}`;
});
const memberLines = members.map((member) => {
const who = [member.firstName, member.lastName].filter(Boolean).join(' ') || member.userId;
return `- ${who}${member.roomRoleName ? `${member.roomRoleName.replace(/^room/, '')}` : ''}`;
});
return joinSections([
heading(2, name),
`Room id: \`${roomId}\``,
boardLines.length > 0
? joinSections([heading(3, `Boards (${boardLines.length})`), boardLines.join('\n'), 'Read one with get_board.'])
: '_No boards in this room._',
memberLines.length > 0 ? joinSections([heading(3, `Members (${memberLines.length})`), memberLines.join('\n')]) : undefined,
]);
}

View File

@@ -32,7 +32,7 @@ export function registerSearchTool(server: McpServer, context: ServerContext): v
query: z.string().min(2).describe('What to look for. German and English both work.'), 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.'), courseId: z.string().optional().describe('Restrict the search to a single course.'),
kinds: z kinds: z
.array(z.enum(['course', 'board', 'lesson', 'task', 'file'])) .array(z.enum(['course', 'room', 'board', 'lesson', 'task', 'file']))
.optional() .optional()
.describe('Restrict to certain kinds of thing, e.g. ["file"] to find documents only.'), .describe('Restrict to certain kinds of thing, e.g. ["file"] to find documents only.'),
limit: z.number().int().min(1).max(100).default(30).describe('Maximum number of hits to return.'), limit: z.number().int().min(1).max(100).default(30).describe('Maximum number of hits to return.'),

View File

@@ -24,7 +24,7 @@ CREATE INDEX IF NOT EXISTS crawls_finished_idx ON crawls (finished_at DESC) WHER
-- complete picture and any two can be diffed directly. -- complete picture and any two can be diffed directly.
CREATE TABLE IF NOT EXISTS nodes ( CREATE TABLE IF NOT EXISTS nodes (
crawl_id BIGINT NOT NULL REFERENCES crawls(id) ON DELETE CASCADE, crawl_id BIGINT NOT NULL REFERENCES crawls(id) ON DELETE CASCADE,
kind TEXT NOT NULL, -- course | board | lesson | task | file kind TEXT NOT NULL, -- course | room | board | lesson | task | file
node_id TEXT NOT NULL, -- Schulcloud id node_id TEXT NOT NULL, -- Schulcloud id
course_id TEXT, course_id TEXT,
title TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',

View File

@@ -14,7 +14,7 @@ import { connect, migrate, type Db } from './db.ts';
* Identity diffing also gives deletions for free, which no timestamp scheme can. * Identity diffing also gives deletions for free, which no timestamp scheme can.
*/ */
export type NodeKind = 'course' | 'board' | 'lesson' | 'task' | 'file'; export type NodeKind = 'course' | 'room' | 'board' | 'lesson' | 'task' | 'file';
export interface StoredNode { export interface StoredNode {
kind: NodeKind; kind: NodeKind;
@@ -521,6 +521,35 @@ export function snapshotToNodes(snapshot: Snapshot): StoredNode[] {
} }
} }
// Rooms sit alongside courses rather than inside them. `course_id` carries
// the room id: the column is the container key, and widening its meaning
// keeps per-container carry-forward and the manifest working unchanged.
for (const room of snapshot.rooms) {
nodes.push({
kind: 'room',
nodeId: room.id,
courseId: room.id,
title: room.name,
body: '',
path: room.name,
meta: { boards: room.boards.length },
digest: digestOf([room.name]),
});
for (const board of room.boards) {
nodes.push({
kind: 'board',
nodeId: board.id,
courseId: room.id,
title: board.title,
body: board.text,
path: `${room.name}/${board.title}`,
meta: { columns: board.board.columns.length, fileCount: board.board.fileCount, inRoom: true },
digest: digestOf([board.title, board.text]),
});
}
}
for (const file of snapshot.files) { for (const file of snapshot.files) {
nodes.push(fileNode(file)); nodes.push(fileNode(file));
} }

View File

@@ -26,11 +26,22 @@ function assertDisposable(url: string): void {
} }
} }
function snapshot(courses: { id: string; title: string; boardText?: string; files?: { id: string; name: string; size: number }[] }[]): Snapshot { function snapshot(
courses: { id: string; title: string; boardText?: string; files?: { id: string; name: string; size: number }[] }[],
rooms: { id: string; name: string; boardText?: string }[] = [],
): Snapshot {
return { return {
crawledAt: new Date(), crawledAt: new Date(),
schoolId: 'school1', schoolId: 'school1',
failures: [], failures: [],
rooms: rooms.map((r) => ({
id: r.id,
name: r.name,
boards: r.boardText
? [{ id: `${r.id}-b`, title: 'Raum-Board', courseId: r.id, text: r.boardText,
board: { id: `${r.id}-b`, title: 'Raum-Board', columns: [], fileCount: 0 } }]
: [],
})),
courses: courses.map((c) => ({ courses: courses.map((c) => ({
course: { id: c.id, title: c.title, shortTitle: c.title.slice(0, 2), displayColor: '#000' }, course: { id: c.id, title: c.title, shortTitle: c.title.slice(0, 2), displayColor: '#000' },
title: c.title, title: c.title,
@@ -122,6 +133,25 @@ describe('Store', { skip: DB_URL ? false : 'set TEST_DATABASE_URL to run' }, ()
assert.ok(!diff.removed.some((n) => n.nodeId === 'f2'), 'and not a deleted one'); assert.ok(!diff.removed.some((n) => n.nodeId === 'f2'), 'and not a deleted one');
}); });
it('stores rooms alongside courses, and notices when one changes', async () => {
// Rooms are a separate space, not a kind of course: they must land in the
// index under their own kind, or room content becomes unsearchable the way
// submitted files once were.
const before = await store.saveSnapshot(
snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }], [{ id: 'r1', name: 'Projektraum', boardText: 'Projektsteuerung' }]),
'full',
);
const hits = await store.search('Projektsteuerung', { limit: 5 });
assert.ok(hits.some((h) => h.nodeId === 'r1-b'), 'a room board is searchable');
const after = await store.saveSnapshot(
snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }], [{ id: 'r1', name: 'Projektraum Informatik', boardText: 'Projektsteuerung' }]),
'full',
);
const diff = await store.diff(before, after);
assert.ok(diff.changed.some((n) => n.nodeId === 'r1' && n.kind === 'room'), 'a renamed room is reported as changed');
});
it('carries other courses forward on a per-course crawl', async () => { it('carries other courses forward on a per-course crawl', async () => {
await store.saveSnapshot( await store.saveSnapshot(
snapshot([ snapshot([