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:
@@ -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
|
||||||
|
|||||||
16
docs/API.md
16
docs/API.md
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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] });
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
114
src/mcp/tools/rooms.ts
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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.'),
|
||||||
|
|||||||
@@ -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 '',
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
Reference in New Issue
Block a user