Close the gaps an audit of courses, tasks, files and grades turned up
Every area — courses, rooms, boards, topics, tasks, files, quizzes, teams,
groups, submissions, grades — was checked for data the instance has and the
tools did not show.
Grades and feedback. A teacher's /homework page is a different page from a
student's: grade and comment live in the grading form, one block per
submission, so a teacher account reported every graded submission as having
neither. parseTeacherGrading reads the form, and list_submissions can now
include the written feedback and who handed the work in.
Names. /api/v1 is partly served: courses, users and classes survive in the
deployment's ingress table, and users/{id} is the only route from an id to a
name. Submitters, file creators and course teachers resolve through it, and
degrade to "not visible to this account" where a student may not read them.
Courses, rooms and classes. get_course adds the description, teachers,
member count and weekly timetable from /api/v1/courses. list_classes is new.
get_room reports what the account may do — allowedOperations is an object of
booleans, not the list it was typed as — and applicants and invitation links
where it may manage them.
Board and topic content. Link descriptions, image alt text, drawing and
video-conference titles, the ids behind external tools and H5P content (the
only thing resembling a quiz), and what a deleted element used to be. Topic
Etherpad pads are read like board pads, and htmlToText keeps table columns
apart and drops template indentation.
Files. A scan with no text layer falls back to the preview endpoint, whose
width and outputFormat are undocumented enums, so Claude gets a picture of
the page; list_files reports counts and sizes. Teams stay documented as
unreadable at any API version; their files come later.
What the crawl missed. Tasks attached to topics (18 of 60 on the live
account), each course's own file area, and — behind INDEX_PERSONAL_FILES —
personal files and submissions with their grade comments, so search and
what_changed cover grading. A submission hit points at get_task.
The local instance's preview profile gets an ImageMagick policy that allows
the coders its 7.1.2 build needs; the image's own denies them all.
110 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,10 +6,13 @@ import { formatBytes } from '../../core/extract.ts';
|
||||
import { dueLabel, formatDate, heading, htmlToText, joinSections, normalizeObjectId } from '../../core/text.ts';
|
||||
import { assembleBoard, type AssembledBoard, type AssembledElement } from '../../core/board.ts';
|
||||
import { forEachLimited } from '../../core/crawl.ts';
|
||||
import { fetchLessonPadText } from '../../core/etherpad.ts';
|
||||
import { fetchLessonTaskLinks, withScrapedIds } from '../../core/lesson-page.ts';
|
||||
import type {
|
||||
CourseBoardResponse,
|
||||
CourseTime,
|
||||
FileRecord,
|
||||
LegacyCourse,
|
||||
LessonLinkedTask,
|
||||
LessonResponse,
|
||||
ResolvedTask,
|
||||
@@ -35,8 +38,17 @@ export function registerContentTools(server: McpServer, context: ServerContext):
|
||||
},
|
||||
async ({ courseId }) => {
|
||||
try {
|
||||
const board = await context.client.getCourseBoard(courseId);
|
||||
return text(formatCourseBoard(board));
|
||||
const [board, legacy] = await Promise.all([
|
||||
context.client.getCourseBoard(courseId),
|
||||
// The v3 projection carries no description, teachers, members or
|
||||
// timetable; /api/v1/courses still does. Optional on purpose — it
|
||||
// is a legacy route, so its absence must cost detail, not the call.
|
||||
context.client.getLegacyCourse(courseId).catch(() => undefined),
|
||||
]);
|
||||
const teachers = legacy
|
||||
? await context.resolveNames([...(legacy.teacherIds ?? []), ...(legacy.substitutionIds ?? [])])
|
||||
: { names: [], unresolved: 0 };
|
||||
return text(formatCourseBoard(board, legacy, teachers));
|
||||
} catch (error) {
|
||||
return toToolError(error, `read course ${courseId}`);
|
||||
}
|
||||
@@ -114,7 +126,19 @@ export function registerContentTools(server: McpServer, context: ServerContext):
|
||||
tasks.length > 0
|
||||
? withScrapedIds(tasks, await fetchLessonTaskLinks(context.config, lesson.courseId, lessonId))
|
||||
: tasks;
|
||||
return text(formatLesson(lesson, withIds, files?.data ?? []));
|
||||
// Pads are fetched only when the topic actually has one: each costs a
|
||||
// topic-page render to obtain the Etherpad session cookie.
|
||||
const padTexts = new Map<number, string>();
|
||||
await Promise.all(
|
||||
(lesson.contents ?? []).map(async (entry, index) => {
|
||||
if (entry.component !== 'Etherpad') return;
|
||||
const url = entry.content?.url;
|
||||
if (typeof url !== 'string') return;
|
||||
const padText = await fetchLessonPadText(context.config, lesson.courseId, lessonId, url);
|
||||
if (padText) padTexts.set(index, padText);
|
||||
}),
|
||||
);
|
||||
return text(formatLesson(lesson, withIds, files?.data ?? [], padTexts));
|
||||
} catch (error) {
|
||||
return toToolError(error, `read lesson ${lessonId}`);
|
||||
}
|
||||
@@ -238,7 +262,11 @@ async function taskFromCourse(
|
||||
|
||||
// --- formatting --------------------------------------------------------
|
||||
|
||||
function formatCourseBoard(board: CourseBoardResponse): string {
|
||||
function formatCourseBoard(
|
||||
board: CourseBoardResponse,
|
||||
legacy?: LegacyCourse,
|
||||
teachers: { names: string[]; unresolved: number } = { names: [], unresolved: 0 },
|
||||
): string {
|
||||
const boards: string[] = [];
|
||||
const lessons: string[] = [];
|
||||
const tasks: string[] = [];
|
||||
@@ -258,19 +286,72 @@ function formatCourseBoard(board: CourseBoardResponse): string {
|
||||
}
|
||||
}
|
||||
|
||||
const about = joinSections([
|
||||
htmlToText(legacy?.description)?.trim() || undefined,
|
||||
formatTeachers(teachers),
|
||||
legacy?.userIds?.length ? `**Members:** ${legacy.userIds.length}` : undefined,
|
||||
formatCourseTimes(legacy?.times),
|
||||
]);
|
||||
|
||||
if (boards.length + lessons.length + tasks.length === 0) {
|
||||
return `${heading(2, board.title)}\n\nThis course page is empty.`;
|
||||
return joinSections([heading(2, board.title), about, 'This course page is empty.']);
|
||||
}
|
||||
|
||||
return joinSections([
|
||||
heading(2, board.title),
|
||||
`Course id: \`${board.roomId}\``,
|
||||
about,
|
||||
boards.length > 0 && joinSections([heading(3, `Boards (${boards.length})`), boards.join('\n'), 'Read one with get_board.']),
|
||||
lessons.length > 0 && joinSections([heading(3, `Topics (${lessons.length})`), lessons.join('\n'), 'Read one with get_lesson.']),
|
||||
tasks.length > 0 && joinSections([heading(3, `Tasks (${tasks.length})`), tasks.join('\n'), 'Read one with get_task.']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who teaches the course.
|
||||
*
|
||||
* A student may not read their teachers' user records, so names are often
|
||||
* unavailable; say how many there are rather than printing bare ids, which
|
||||
* are no use to a reader and look like a bug.
|
||||
*/
|
||||
function formatTeachers(teachers: { names: string[]; unresolved: number }): string | undefined {
|
||||
const { names, unresolved } = teachers;
|
||||
if (names.length === 0 && unresolved === 0) return undefined;
|
||||
if (names.length === 0) {
|
||||
return `**Taught by:** ${unresolved} teacher(s) — names are not visible to this account`;
|
||||
}
|
||||
const rest = unresolved > 0 ? ` and ${unresolved} more (name not visible to this account)` : '';
|
||||
return `**Taught by:** ${names.join(', ')}${rest}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A course's weekly timetable.
|
||||
*
|
||||
* `times` is the closest thing to a calendar the API exposes — the calendar
|
||||
* service itself is not part of the v3 document. `startTime` is milliseconds
|
||||
* since midnight and `weekday` is 0-based from Monday, as the legacy client
|
||||
* renders it.
|
||||
*/
|
||||
function formatCourseTimes(times: CourseTime[] | undefined): string | undefined {
|
||||
if (!times || times.length === 0) return undefined;
|
||||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
const rows = times
|
||||
.slice()
|
||||
.sort((a, b) => (a.weekday ?? 0) - (b.weekday ?? 0) || (a.startTime ?? 0) - (b.startTime ?? 0))
|
||||
.map((slot) => {
|
||||
const day = days[slot.weekday ?? 0] ?? `day ${slot.weekday}`;
|
||||
const room = slot.room ? `, room ${slot.room}` : '';
|
||||
return `- ${day} ${clockFromMs(slot.startTime)}${slot.duration ? `–${clockFromMs((slot.startTime ?? 0) + slot.duration)}` : ''}${room}`;
|
||||
});
|
||||
return joinSections([`**Weekly schedule:**`, rows.join('\n')]);
|
||||
}
|
||||
|
||||
function clockFromMs(ms: number | undefined): string {
|
||||
if (ms === undefined) return '?';
|
||||
const total = Math.floor(ms / 60_000);
|
||||
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatBoard(board: AssembledBoard, includeFiles: boolean): string {
|
||||
const columns = board.columns.map((column) => {
|
||||
const cards = column.cards.map((card) => {
|
||||
@@ -312,16 +393,24 @@ function formatElement(element: AssembledElement, includeFiles: boolean): string
|
||||
}
|
||||
case 'link': {
|
||||
const label = element.text?.trim();
|
||||
return element.url ? `- Link: ${label && label !== element.url ? `${label} — ${element.url}` : element.url}` : '';
|
||||
if (!element.url) return '';
|
||||
const head = `- Link: ${label && label !== element.url ? `${label} — ${element.url}` : element.url}`;
|
||||
const note = htmlToText(element.description)?.trim();
|
||||
return note ? `${head}\n${indent(note)}` : head;
|
||||
}
|
||||
case 'file':
|
||||
case 'fileFolder':
|
||||
case 'drawing': {
|
||||
const caption = element.text ? ` — caption: ${element.text}` : '';
|
||||
if (!includeFiles) return `- ${element.type} element \`${element.id}\`${caption}`;
|
||||
// Alt text describes the picture itself, so it belongs on the line
|
||||
// whether or not the file records could be listed.
|
||||
const alt = element.alternativeText?.trim() ? ` — alt: ${element.alternativeText.trim()}` : '';
|
||||
const note = element.description?.trim() ? ` — ${element.description.trim()}` : '';
|
||||
const extra = `${caption}${alt}${note}`;
|
||||
if (!includeFiles) return `- ${element.type} element \`${element.id}\`${extra}`;
|
||||
if (element.fileError) return `- ${element.type} element \`${element.id}\` — could not list files (${element.fileError})`;
|
||||
if (element.files.length === 0) return `- ${element.type} element \`${element.id}\` — no files${caption}`;
|
||||
return element.files.map((file) => `- ${formatFileLine(file)}${caption}`).join('\n');
|
||||
if (element.files.length === 0) return `- ${element.type} element \`${element.id}\` — no files${extra}`;
|
||||
return element.files.map((file) => `- ${formatFileLine(file)}${extra}`).join('\n');
|
||||
}
|
||||
case 'collaborativeTextEditor': {
|
||||
const title = element.text ? ` — ${element.text}` : '';
|
||||
@@ -333,14 +422,29 @@ function formatElement(element: AssembledElement, includeFiles: boolean): string
|
||||
}
|
||||
return [`- Collaborative text document \`${element.id}\`${title}:`, indent(element.padText)].join('\n');
|
||||
}
|
||||
case 'externalTool':
|
||||
return `- External tool${element.text ? `: ${element.text}` : ''} \`${element.id}\``;
|
||||
case 'externalTool': {
|
||||
// The configured-tool id is what `api_get /api/v3/tools/...` needs to
|
||||
// say which tool this actually is; without it the element is opaque.
|
||||
const tool = element.contextExternalToolId
|
||||
? ` — configured tool \`${element.contextExternalToolId}\``
|
||||
: '';
|
||||
return `- External tool${element.text ? `: ${element.text}` : ''} \`${element.id}\`${tool}`;
|
||||
}
|
||||
case 'videoConference':
|
||||
return `- Video conference \`${element.id}\``;
|
||||
case 'h5p':
|
||||
return `- H5P interactive content \`${element.id}\``;
|
||||
case 'deleted':
|
||||
return '- _(deleted element)_';
|
||||
return `- Video conference${element.text ? `: ${element.text}` : ''} \`${element.id}\``;
|
||||
case 'h5p': {
|
||||
// Schulcloud has no quiz of its own: interactive exercises are H5P, and
|
||||
// this id is the only way to reach the content behind one.
|
||||
const content = element.h5pContentId ? ` — H5P content \`${element.h5pContentId}\`` : '';
|
||||
return `- H5P interactive content \`${element.id}\`${content}`;
|
||||
}
|
||||
case 'deleted': {
|
||||
// Saying what it was beats "(deleted element)": the title often names
|
||||
// the material a student is looking for and cannot find.
|
||||
const was = element.deletedElementType ? ` ${element.deletedElementType}` : '';
|
||||
const title = element.text ? `: ${element.text}` : '';
|
||||
return `- _(deleted${was} element${title})_`;
|
||||
}
|
||||
default:
|
||||
return `- ${element.type} element \`${element.id}\``;
|
||||
}
|
||||
@@ -352,12 +456,17 @@ export function formatFileLine(file: FileRecord): string {
|
||||
return `File: **${file.name}** (\`${file.id}\`, ${file.mimeType}, ${formatBytes(file.size)})${blocked}${pending}`;
|
||||
}
|
||||
|
||||
function formatLesson(lesson: LessonResponse, tasks: LessonLinkedTask[], files: FileRecord[]): string {
|
||||
const sections = (lesson.contents ?? []).map((entry) => {
|
||||
function formatLesson(
|
||||
lesson: LessonResponse,
|
||||
tasks: LessonLinkedTask[],
|
||||
files: FileRecord[],
|
||||
padTexts: Map<number, string> = new Map(),
|
||||
): string {
|
||||
const sections = (lesson.contents ?? []).map((entry, index) => {
|
||||
const title = entry.title?.trim();
|
||||
const component = entry.component ?? 'unknown';
|
||||
const hidden = entry.hidden ? ' [hidden]' : '';
|
||||
const body = formatLessonComponent(component, entry.content ?? {});
|
||||
const body = formatLessonComponent(component, entry.content ?? {}, padTexts.get(index));
|
||||
return joinSections([heading(4, `${title || component}${hidden}`), body || `_(${component} content, nothing to show)_`]);
|
||||
});
|
||||
|
||||
@@ -386,16 +495,40 @@ function formatLesson(lesson: LessonResponse, tasks: LessonLinkedTask[], files:
|
||||
]);
|
||||
}
|
||||
|
||||
function formatLessonComponent(component: string, content: Record<string, unknown>): string {
|
||||
function formatLessonComponent(
|
||||
component: string,
|
||||
content: Record<string, unknown>,
|
||||
padText?: string,
|
||||
): string {
|
||||
if (component === 'text' && typeof content.text === 'string') return htmlToText(content.text);
|
||||
if (component === 'resources' && Array.isArray(content.resources)) {
|
||||
return content.resources
|
||||
.map((resource) => {
|
||||
const entry = resource as { title?: string; url?: string; description?: string };
|
||||
return `- ${entry.title ?? 'Resource'}${entry.url ? ` — ${entry.url}` : ''}`;
|
||||
const note = entry.description?.trim();
|
||||
const head = `- ${entry.title ?? 'Resource'}${entry.url ? ` — ${entry.url}` : ''}`;
|
||||
return note ? `${head}\n${indent(note)}` : head;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
// A topic's Etherpad: the same collaborative document a column board can
|
||||
// hold, reached by a stored url instead of an element id. The url is data
|
||||
// and may name another deployment, in which case the text is unavailable
|
||||
// and the link is the honest answer.
|
||||
if (component === 'Etherpad') {
|
||||
const url = typeof content.url === 'string' ? content.url : undefined;
|
||||
const note = typeof content.description === 'string' ? content.description.trim() : '';
|
||||
const lines = [url ? `- Collaborative text document — ${url}` : '- Collaborative text document'];
|
||||
if (note) lines.push(indent(note));
|
||||
if (padText) lines.push(indent(padText));
|
||||
else if (url) lines.push(indent('_(empty, or its contents could not be read)_'));
|
||||
return lines.join('\n');
|
||||
}
|
||||
// A GeoGebra applet. Only the material id is stored, so name it and let the
|
||||
// reader follow it rather than rendering an empty section.
|
||||
if (component === 'geoGebra' && typeof content.materialId === 'string') {
|
||||
return `- GeoGebra applet \`${content.materialId}\` — https://www.geogebra.org/m/${content.materialId}`;
|
||||
}
|
||||
if (typeof content.url === 'string') return `- ${content.url}`;
|
||||
if (typeof content.title === 'string') return content.title;
|
||||
return '';
|
||||
|
||||
@@ -30,11 +30,22 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
|
||||
async ({ parentType, parentId }) => {
|
||||
try {
|
||||
const schoolId = await context.schoolId();
|
||||
const page = await context.client.listFiles({ storageLocationId: schoolId, parentType, parentId });
|
||||
const [page, stats] = await Promise.all([
|
||||
context.client.listFiles({ storageLocationId: schoolId, parentType, parentId }),
|
||||
// Cheap, and it is the only way to see that a parent holds files
|
||||
// the listing paged past.
|
||||
context.client.getParentFileStats(parentType, parentId).catch(() => undefined),
|
||||
]);
|
||||
if (page.data.length === 0) return text(`No files attached to ${parentType} ${parentId}.`);
|
||||
const total =
|
||||
stats && stats.fileCount > page.data.length
|
||||
? ` — ${stats.fileCount} in total, ${formatBytes(stats.totalSizeInBytes)}`
|
||||
: stats
|
||||
? ` — ${formatBytes(stats.totalSizeInBytes)} in total`
|
||||
: '';
|
||||
return text(
|
||||
joinSections([
|
||||
heading(2, `Files on ${parentType} ${parentId} (${page.data.length})`),
|
||||
heading(2, `Files on ${parentType} ${parentId} (${page.data.length})${total}`),
|
||||
page.data.map((file) => `- ${formatFileLine(file)} — uploaded ${formatDate(file.createdAt)}`).join('\n'),
|
||||
'Read one with download_file.',
|
||||
]),
|
||||
@@ -80,7 +91,12 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
|
||||
`"${record.name}" was blocked by the instance's virus scanner and will not be downloaded.`,
|
||||
);
|
||||
}
|
||||
const file = await context.client.downloadFile(record);
|
||||
const [file, uploader] = await Promise.all([
|
||||
context.client.downloadFile(record),
|
||||
// Who put the file there is often the quickest way to tell a
|
||||
// teacher's material apart from a classmate's upload.
|
||||
record.creatorId ? context.userName(record.creatorId) : Promise.resolve(undefined),
|
||||
]);
|
||||
const header = [
|
||||
heading(2, record.name),
|
||||
[
|
||||
@@ -88,7 +104,7 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
|
||||
`- Type: ${record.mimeType}`,
|
||||
`- Size: ${formatBytes(record.size)}`,
|
||||
`- Attached to: ${record.parentType} \`${record.parentId}\``,
|
||||
`- Uploaded: ${formatDate(record.createdAt)}`,
|
||||
`- Uploaded: ${formatDate(record.createdAt)}${uploader ? ` by ${uploader}` : ''}`,
|
||||
record.securityCheckStatus !== 'verified'
|
||||
? `- Virus scan: ${record.securityCheckStatus}`
|
||||
: undefined,
|
||||
@@ -140,6 +156,32 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
|
||||
);
|
||||
}
|
||||
|
||||
// Nothing extractable — but files-storage may still be able to render
|
||||
// the file as a picture. That is the whole answer for an image-only
|
||||
// PDF: its pages *are* pictures, so a rasterised preview is readable
|
||||
// where the bytes are not, and it needs no OCR on our side.
|
||||
if (record.previewStatus === 'preview_possible') {
|
||||
const preview = await context.client.getFilePreview(record, 500).catch(() => undefined);
|
||||
if (preview && preview.mimeType.startsWith('image/')) {
|
||||
const result: CallToolResult = {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: joinSections([
|
||||
header,
|
||||
// The note ends by suggesting raw bytes, which is no longer the
|
||||
// best answer once a readable rendering is attached.
|
||||
extraction.note.replace(' Use download_file with raw=true to get the bytes.', ''),
|
||||
"Showing the instance's own rendered preview below, which is readable as a picture.",
|
||||
]),
|
||||
},
|
||||
{ type: 'image', data: preview.bytes.toString('base64'), mimeType: preview.mimeType },
|
||||
],
|
||||
};
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return text(joinSections([header, extraction.note]));
|
||||
} catch (error) {
|
||||
return toToolError(error, `download file ${fileId}`);
|
||||
|
||||
@@ -2,7 +2,13 @@ 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 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 };
|
||||
@@ -76,20 +82,99 @@ export function registerRoomTools(server: McpServer, context: ServerContext): vo
|
||||
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),
|
||||
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 text(formatRoom(room.name, roomId, boards, members));
|
||||
return text(formatRoom(room, roomId, boards, members, applicants, links));
|
||||
} 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');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function formatRoom(name: string, roomId: string, boards: RoomBoardItem[], members: RoomMember[]): string {
|
||||
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.
|
||||
@@ -103,12 +188,54 @@ function formatRoom(name: string, roomId: string, boards: RoomBoardItem[], membe
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ const TOOL_FOR: Record<string, string> = {
|
||||
lesson: 'get_lesson',
|
||||
task: 'get_task',
|
||||
file: 'download_file',
|
||||
// A submission is reached through its task, not by an id of its own: there
|
||||
// is no get_submission because the API has no route to one.
|
||||
submission: 'get_task',
|
||||
};
|
||||
|
||||
export function registerSearchTool(server: McpServer, context: ServerContext): void {
|
||||
@@ -133,11 +136,22 @@ async function liveSearch(
|
||||
]);
|
||||
}
|
||||
|
||||
function targetIdFor(hit: SearchResult): string {
|
||||
if (hit.kind === 'submission') {
|
||||
const taskId = hit.meta?.taskId;
|
||||
if (typeof taskId === 'string') return taskId;
|
||||
}
|
||||
return hit.nodeId;
|
||||
}
|
||||
|
||||
function formatIndexed(hit: SearchResult): string {
|
||||
return [
|
||||
`- **${hit.title}** — ${hit.kind} in ${hit.courseTitle || hit.path}`,
|
||||
hit.snippet && hit.snippet !== hit.title ? ` ${hit.snippet}` : undefined,
|
||||
` → \`${TOOL_FOR[hit.kind] ?? 'api_get'}\` with id \`${hit.nodeId}\``,
|
||||
// A submission has no id of its own that any tool takes: get_task is
|
||||
// reached through the *task*, so point at that rather than at the
|
||||
// submission id, which would simply 404.
|
||||
` → \`${TOOL_FOR[hit.kind] ?? 'api_get'}\` with id \`${targetIdFor(hit)}\``,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import type { ServerContext } from '../../context.ts';
|
||||
import { fetchLessonTaskLinks, withScrapedIds } from '../../core/lesson-page.ts';
|
||||
import { forEachLimited } from '../../core/crawl.ts';
|
||||
import { fetchSubmissionDetail } from '../../core/homework-page.ts';
|
||||
import { fetchHomeworkPage, fetchSubmissionDetail, type SubmissionGrading } from '../../core/homework-page.ts';
|
||||
import { dueLabel, heading, joinSections } from '../../core/text.ts';
|
||||
import type { FileRecord, ResolvedTask, SubmissionStatus } from '../../core/types.ts';
|
||||
import { formatFileLine } from './content.ts';
|
||||
@@ -44,24 +44,55 @@ export function registerSubmissionTools(server: McpServer, context: ServerContex
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe('Keep only submissions the account itself is a submitter on. Matters on teacher accounts.'),
|
||||
includeFeedback: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
'Also read each task\'s page to report the written feedback and who submitted. Answers ' +
|
||||
'"what did the teacher say" in one call, but costs one extra page fetch per task — ' +
|
||||
'pair it with courseId or a small limit.',
|
||||
),
|
||||
limit: z.number().int().min(1).max(99).default(50).describe('Maximum tasks to check.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ courseId, scope, onlyMine, limit }) => {
|
||||
async ({ courseId, scope, onlyMine, includeFeedback, limit }) => {
|
||||
try {
|
||||
const [me, tasks] = await Promise.all([context.me(), collectTasks(context, scope, courseId, limit)]);
|
||||
if (tasks.length === 0) return text('No tasks found to check for submissions.');
|
||||
|
||||
const rows: { task: ResolvedTask; status: SubmissionStatus }[] = [];
|
||||
const rows: { task: ResolvedTask; status: SubmissionStatus; grading?: SubmissionGrading }[] = [];
|
||||
const unavailable: string[] = [];
|
||||
|
||||
await forEachLimited(tasks, 5, async (task) => {
|
||||
try {
|
||||
const statuses = await context.client.listSubmissionStatuses(task.id);
|
||||
for (const status of statuses) {
|
||||
if (onlyMine && !status.submitters.includes(me.user.id)) continue;
|
||||
rows.push({ task, status });
|
||||
const kept = statuses.filter((status) => !onlyMine || status.submitters.includes(me.user.id));
|
||||
if (kept.length === 0) return;
|
||||
|
||||
// Only pay for the page when asked: it is one fetch per task, and
|
||||
// the status endpoint alone cannot tell feedback-only grading from
|
||||
// an unmarked grade.
|
||||
let grading: SubmissionGrading[] = [];
|
||||
if (includeFeedback) {
|
||||
const page = await fetchHomeworkPage(context.config, task.id).catch(() => undefined);
|
||||
grading = page?.grading ?? [];
|
||||
// The student view has no grading form; its single submission's
|
||||
// feedback is still worth folding in under the same shape.
|
||||
if (grading.length === 0 && page?.own && kept.length === 1 && kept[0]) {
|
||||
grading = [
|
||||
{
|
||||
submissionId: kept[0].id,
|
||||
submitterIds: kept[0].submitters,
|
||||
gradeComment: page.own.gradeComment,
|
||||
gradePercent: page.own.gradePercent,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
for (const status of kept) {
|
||||
rows.push({ task, status, grading: grading.find((g) => g.submissionId === status.id) });
|
||||
}
|
||||
} catch {
|
||||
// A task whose submissions we cannot read is worth noting, not fatal.
|
||||
@@ -79,10 +110,23 @@ export function registerSubmissionTools(server: McpServer, context: ServerContex
|
||||
}
|
||||
|
||||
rows.sort((a, b) => Number(a.status.isGraded) - Number(b.status.isGraded));
|
||||
|
||||
// Resolve every submitter once. Without this a teacher's list is a
|
||||
// wall of identical rows: the same task name repeated per student
|
||||
// with nothing to tell them apart.
|
||||
const names = new Map<string, string>();
|
||||
await Promise.all(
|
||||
[...new Set(rows.flatMap((row) => row.status.submitters))].map(async (id) => {
|
||||
const name = await context.userName(id);
|
||||
if (name) names.set(id, name);
|
||||
}),
|
||||
);
|
||||
|
||||
const formatted = await Promise.all(rows.map((row) => formatRow(row, me.user.id, names)));
|
||||
return text(
|
||||
joinSections([
|
||||
heading(2, `Submissions (${rows.length} across ${tasks.length} task(s))`),
|
||||
rows.map(formatRow).join('\n'),
|
||||
formatted.join('\n'),
|
||||
'Use get_task with a task id to see the submitted files and download them.',
|
||||
unavailable.length > 0 ? `_Could not check ${unavailable.length} task(s)._` : undefined,
|
||||
]),
|
||||
@@ -126,18 +170,51 @@ export function formatGradeState(
|
||||
return 'marked graded, but neither a percentage nor feedback was found';
|
||||
}
|
||||
|
||||
function formatRow({ task, status }: { task: ResolvedTask; status: SubmissionStatus }): string {
|
||||
function formatRow(
|
||||
{ task, status, grading }: { task: ResolvedTask; status: SubmissionStatus; grading?: SubmissionGrading },
|
||||
myUserId: string,
|
||||
names: Map<string, string>,
|
||||
): string {
|
||||
const state = status.isSubmitted ? 'submitted' : 'not submitted';
|
||||
// The list does not fetch pages, so it cannot know whether feedback exists;
|
||||
// it says only what the API told it.
|
||||
const graded = status.isGraded
|
||||
? status.grade !== null && status.grade !== undefined
|
||||
? `graded ${status.grade}%`
|
||||
: 'graded (percentage not set — check get_task for written feedback)'
|
||||
: 'not graded';
|
||||
|
||||
// With the page read, feedback-only grading can be named for what it is
|
||||
// rather than deferred to get_task. Without it, say only what the API said.
|
||||
const percent = status.grade ?? grading?.gradePercent;
|
||||
const hasFeedback = Boolean(grading?.gradeComment);
|
||||
let graded: string;
|
||||
if (!status.isGraded) {
|
||||
graded = 'not graded';
|
||||
} else if (percent !== null && percent !== undefined) {
|
||||
graded = hasFeedback ? `graded ${percent}%, with feedback` : `graded ${percent}%`;
|
||||
} else if (hasFeedback) {
|
||||
graded = 'graded by feedback, with no percentage given';
|
||||
} else if (grading) {
|
||||
graded = 'marked graded, but neither a percentage nor feedback was found';
|
||||
} else {
|
||||
graded = 'graded (percentage not set — pass includeFeedback for the written feedback)';
|
||||
}
|
||||
|
||||
// Who handed it in. Omitted when the caller is the only submitter, which is
|
||||
// the student case and would just be noise.
|
||||
const others = status.submitters.filter((id) => id !== myUserId);
|
||||
const by =
|
||||
others.length > 0
|
||||
? ` — by ${status.submitters.map((id) => (id === myUserId ? 'you' : (names.get(id) ?? id))).join(', ')}`
|
||||
: '';
|
||||
|
||||
const group = status.submittingCourseGroupName ? ` — group "${status.submittingCourseGroupName}"` : '';
|
||||
const course = task.courseName ? ` [${task.courseName}]` : '';
|
||||
return `- **${task.name}**${course} — ${state}, ${graded}${group}\n task \`${task.id}\`, submission \`${status.id}\``;
|
||||
const feedback = grading?.gradeComment ? `\n feedback: ${oneLine(grading.gradeComment)}` : '';
|
||||
return (
|
||||
`- **${task.name}**${course} — ${state}, ${graded}${by}${group}` +
|
||||
`\n task \`${task.id}\`, submission \`${status.id}\`${feedback}`
|
||||
);
|
||||
}
|
||||
|
||||
/** Feedback is prose; keep a list row a row. */
|
||||
function oneLine(value: string): string {
|
||||
const collapsed = value.replace(/\s+/g, ' ').trim();
|
||||
return collapsed.length > 200 ? `${collapsed.slice(0, 197)}…` : collapsed;
|
||||
}
|
||||
|
||||
async function collectTasks(
|
||||
@@ -194,30 +271,44 @@ export async function describeSubmission(
|
||||
context: ServerContext,
|
||||
taskId: string,
|
||||
): Promise<string | undefined> {
|
||||
const [me, statuses, detail] = await Promise.all([
|
||||
const [me, statuses, page] = await Promise.all([
|
||||
context.me(),
|
||||
context.client.listSubmissionStatuses(taskId).catch(() => [] as SubmissionStatus[]),
|
||||
// Submitted text and written feedback exist only in the rendered web page;
|
||||
// see core/homework-page.ts. Optional by construction — a failure here
|
||||
// costs detail, not the whole answer.
|
||||
fetchSubmissionDetail(context.config, taskId).catch(() => undefined),
|
||||
fetchHomeworkPage(context.config, taskId).catch(() => undefined),
|
||||
]);
|
||||
if (statuses.length === 0) return undefined;
|
||||
|
||||
const detail = page?.own;
|
||||
const mine = statuses.filter((status) => status.submitters.includes(me.user.id));
|
||||
const relevant = mine.length > 0 ? mine : statuses;
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const status of relevant) {
|
||||
const files = await loadSubmissionFiles(context, status.id);
|
||||
const graded = formatGradeState(status, Boolean(detail?.gradeComment), detail?.gradePercent);
|
||||
// On a teacher account the grade lives in the grading form, one entry per
|
||||
// submission, rather than in the student's rendered feedback tab.
|
||||
const grading = page?.grading.find((entry) => entry.submissionId === status.id);
|
||||
const graded = formatGradeState(
|
||||
status,
|
||||
Boolean(detail?.gradeComment ?? grading?.gradeComment),
|
||||
detail?.gradePercent ?? grading?.gradePercent,
|
||||
);
|
||||
const submitterNames = await context.userNamesFor(
|
||||
status.submitters.filter((id) => id !== me.user.id),
|
||||
);
|
||||
|
||||
parts.push(
|
||||
[
|
||||
`- Submission \`${status.id}\`${mine.length === 0 ? ' _(not yours)_' : ''}`,
|
||||
`- Submission \`${status.id}\`${status.submitters.includes(me.user.id) ? '' : ' _(not yours)_'}`,
|
||||
submitterNames.length > 0 ? `- Handed in by: ${submitterNames.join(', ')}` : undefined,
|
||||
`- ${status.isSubmitted ? 'Submitted' : 'Not submitted'}, ${graded}`,
|
||||
status.submittingCourseGroupName ? `- Group: ${status.submittingCourseGroupName}` : undefined,
|
||||
status.submitters.length > 1 ? `- ${status.submitters.length} submitters` : undefined,
|
||||
grading?.gradeComment && !detail?.gradeComment
|
||||
? `- Feedback: ${grading.gradeComment}`
|
||||
: undefined,
|
||||
files.submitted.length > 0
|
||||
? `- Handed in:\n${files.submitted.map((file) => ` - ${formatFileLine(file)}`).join('\n')}`
|
||||
: '- No files attached to the submission',
|
||||
@@ -241,11 +332,12 @@ export async function describeSubmission(
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return joinSections([
|
||||
heading(3, 'Your submission'),
|
||||
// A teacher sees other people's work here, so do not call it "yours".
|
||||
heading(3, mine.length > 0 ? 'Your submission' : 'Submissions'),
|
||||
parts.join('\n\n'),
|
||||
...written,
|
||||
'Read any attachment with download_file.' +
|
||||
(written.length === 0 && anyGraded(relevant)
|
||||
(written.length === 0 && !page?.grading.some((entry) => entry.gradeComment) && anyGraded(relevant)
|
||||
? ' No written feedback was found for this submission. It is read from the web page rather ' +
|
||||
'than an API, so treat this as "not found", not as "none was given" — the teacher may ' +
|
||||
'have responded on paper or in person.'
|
||||
|
||||
Reference in New Issue
Block a user