Files
Schulcloud-MCP/src/mcp/tools/rooms.ts
MechaCat02 9d0272c622 Offer courses and rooms as MCP resources, with two German prompts
A course or room can now be attached to a message rather than fetched:
schulcloud://courses/<id> and schulcloud://rooms/<id> carry exactly what
get_course and get_room return. Deliberately coarse — a picker lists every
resource at once, which suits some twenty courses and not a thousand files.

Two prompts, in German because the school is: zusammenfassung summarises a
course or room, and pruefungsvorbereitung prepares for an exam with practice
questions and a study plan. Each embeds the overview and says where material
hides and what cannot be read.

Claude Code shaped the details, read from its bundle rather than its docs.
It splits prompt arguments on whitespace and drops extra words, so words
arrive joined with "_", and courses match by fragments, whole words first,
so LF1 is not ambiguous with LF10. Its @ autocomplete shows a resource's
description, so the description carries the name. Errors are ProtocolError,
because McpError's message prefix is doubled by the client.

Verified in interactive Claude Code: @-mention, autocomplete and the prompt
commands. 157 tests. Smoke 67/67 live; 69/69 and 67/67 on the local instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:16 +02:00

247 lines
9.7 KiB
TypeScript

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 {
RoomApplicant,
RoomBoardItem,
RoomDetails,
RoomInvitationLink,
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 {
return text(await readRoom(context, roomId));
} catch (error) {
return toToolError(error, `read room ${roomId}`);
}
},
);
server.registerTool(
'list_classes',
{
title: 'List my classes and groups',
description:
'The classes ("Klassen") this account belongs to, with their teachers and size, and any other ' +
'groups it is a member of. Use it for "who is my class teacher", "which class am I in", or to ' +
'find the people behind a course. This is the only place class membership is visible — courses ' +
'and rooms do not report it.',
inputSchema: {
includeGroups: z
.boolean()
.default(false)
.describe('Also list non-class groups, such as the membership group behind each room.'),
},
annotations: READ_ONLY,
},
async ({ includeGroups }) => {
try {
const [classes, groups] = await Promise.all([
context.client.listClasses(),
includeGroups ? context.client.listGroups().catch(() => []) : Promise.resolve([]),
]);
if (classes.length === 0 && groups.length === 0) {
return text('This account is not in any class or group.');
}
const classLines = classes.map((entry) => {
const teachers = entry.teacherNames?.length ? ` — taught by ${entry.teacherNames.join(', ')}` : '';
const size = entry.studentCount === undefined ? '' : ` — ${entry.studentCount} student(s)`;
return `- **${entry.name ?? 'Unnamed class'}** (\`${entry.id}\`)${teachers}${size}`;
});
// Room membership groups carry names and room roles, which is a second
// route to "who is in this room" when the members endpoint refuses.
const groupLines = groups
.filter((group) => group.type !== 'class')
.map((group) => {
const people = (group.users ?? [])
.map((user) => `${[user.firstName, user.lastName].filter(Boolean).join(' ')}${user.role ? ` (${user.role.replace(/^room/, '')})` : ''}`)
.filter(Boolean);
const who = people.length > 0 ? `\n${people.map((line) => ` - ${line}`).join('\n')}` : '';
return `- **${group.name ?? 'Unnamed group'}** (\`${group.id}\`, ${group.type ?? 'group'})${who}`;
});
return text(
joinSections([
classLines.length > 0
? joinSections([heading(2, `Classes (${classLines.length})`), classLines.join('\n')])
: undefined,
groupLines.length > 0
? joinSections([heading(2, `Groups (${groupLines.length})`), groupLines.join('\n')])
: undefined,
]),
);
} catch (error) {
return toToolError(error, 'list classes');
}
},
);
}
/** A room as Markdown: what get_room returns, and what the room resource carries. */
export async function readRoom(context: ServerContext, roomId: string): Promise<string> {
// 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 = await context.client.getRoom(roomId);
// Applicants and invitation links are room-admin surface. Ask only
// when this account is allowed to, so a viewer does not pay for two
// requests that can only come back 403.
const may = room.allowedOperations ?? {};
const [boards, members, applicants, links] = await Promise.all([
context.client.listRoomBoards(roomId).catch(() => [] as RoomBoardItem[]),
context.client.listRoomMembers(roomId).catch(() => [] as RoomMember[]),
may.manageRoomApplicants
? context.client.listRoomApplicants(roomId).catch(() => [] as RoomApplicant[])
: Promise.resolve([] as RoomApplicant[]),
may.listRoomInvitationLinks
? context.client.listRoomInvitationLinks(roomId).catch(() => [] as RoomInvitationLink[])
: Promise.resolve([] as RoomInvitationLink[]),
]);
return formatRoom(room, roomId, boards, members, applicants, links);
}
function formatRoom(
room: RoomDetails,
roomId: string,
boards: RoomBoardItem[],
members: RoomMember[],
applicants: RoomApplicant[] = [],
links: RoomInvitationLink[] = [],
): string {
const name = room.name;
// 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/, '')}` : ''}`;
});
// What this account may do here, and which optional features the room has
// switched on. Both are already in the response and were simply dropped —
// `allowedOperations` in particular is the difference between "you are a
// viewer" and "you could edit this", which changes what to suggest next.
const granted = Object.entries(room.allowedOperations ?? {})
.filter(([, allowed]) => allowed)
.map(([operation]) => operation);
const canEdit = room.allowedOperations?.editContent === true;
const facts = [
granted.length > 0
? `- Your access: ${canEdit ? 'can edit content' : 'read-only'} (${granted.join(', ')})`
: undefined,
room.features?.length ? `- Features: ${room.features.join(', ')}` : undefined,
room.startDate || room.endDate
? `- Active: ${formatDate(room.startDate).slice(0, 10)} to ${formatDate(room.endDate).slice(0, 10)}`
: undefined,
].filter(Boolean) as string[];
return joinSections([
heading(2, name),
`Room id: \`${roomId}\``,
facts.length > 0 ? facts.join('\n') : undefined,
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,
applicants.length > 0
? joinSections([
heading(3, `Waiting to join (${applicants.length})`),
applicants
.map((person) => {
const who = [person.firstName, person.lastName].filter(Boolean).join(' ') || person.userId || 'Someone';
return `- ${who}${person.schoolName ? ` — ${person.schoolName}` : ''}`;
})
.join('\n'),
])
: undefined,
links.length > 0
? joinSections([
heading(3, `Invitation links (${links.length})`),
links
.map((link) => {
const until = link.activeUntil ? ` — until ${formatDate(link.activeUntil)}` : '';
const who = link.isOnlyForTeachers ? ' — teachers only' : '';
return `- ${link.title ?? 'Untitled link'} (\`${link.id}\`)${until}${who}`;
})
.join('\n'),
])
: undefined,
]);
}