Adds what the API actually permits, which is less than the request asked
for and worth being precise about.
GET /api/v3/submissions/status/task/{taskId} is the only submission
route — no list, no fetch-by-id — so a task id is the only way in. The
probe in the report missed it by trying /api/v3/submissions (404). Its
payload is {id, submitters, isSubmitted, isGraded, grade,
submittingCourseGroupName} and nothing more: no submitted text, no grade
comment, no graded-at. Those lived on /api/v1, which this instance does
not serve at all (404 across the board, confirmed — not the proxy). So
"what feedback did I get" is answerable only when the feedback is a file.
Submitted files are reachable, which covers the main workflow:
get_task now shows the submission id, graded state, grade, group, and
the handed-in files with ids ready for download_file. list_submissions
surveys tasks for "what have I handed in" and "what is still ungraded".
Both state the text/feedback gap rather than implying none was given.
Two things found while building it:
files-storage ignores the parentType path segment when listing —
.../gradings/{id} returns the same records, saying parentType
"submissions". Filtering on each record's own parentType, or a student's
own upload gets reported back as teacher feedback.
get_task could not find this task at all: the task lists only cover the
dashboard, and group-project tasks are absent from both, so it claimed
the id was wrong for a task the account can plainly see. It now falls
back to scanning course pages.
Also bounds live search by measured cost: resolving attachments needs a
request per board element, which is 2s for one course but 325s for all
of them — beyond any client timeout. An unscoped fresh search now reads
text only and says so.
38/38 smoke checks; verified end to end through Claude Code against a
real graded group submission.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
363 lines
15 KiB
TypeScript
363 lines
15 KiB
TypeScript
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
import { z } from 'zod';
|
|
import type { ServerContext } from '../../context.ts';
|
|
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 type { CourseBoardResponse, FileRecord, LessonResponse, TaskContent } from '../../core/types.ts';
|
|
import { failure, text, toToolError } from './result.ts';
|
|
import { describeSubmission } from './submissions.ts';
|
|
|
|
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
|
|
export function registerContentTools(server: McpServer, context: ServerContext): void {
|
|
server.registerTool(
|
|
'get_course',
|
|
{
|
|
title: 'Get course contents',
|
|
description:
|
|
'Everything inside one course: its topics ("Themen"/lessons), tasks, and column boards, in the order ' +
|
|
'shown on the course page. Returns ids for each, which get_board, get_lesson and get_task take. ' +
|
|
'Most teaching material lives on column boards.',
|
|
inputSchema: {
|
|
courseId: z.string().describe('Course id from list_courses or get_dashboard.'),
|
|
},
|
|
annotations: READ_ONLY,
|
|
},
|
|
async ({ courseId }) => {
|
|
try {
|
|
const board = await context.client.getCourseBoard(courseId);
|
|
return text(formatCourseBoard(board));
|
|
} catch (error) {
|
|
return toToolError(error, `read course ${courseId}`);
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
'get_board',
|
|
{
|
|
title: 'Get column board',
|
|
description:
|
|
'The full contents of a column board: every column, card, text block, link and attached file, with ' +
|
|
'file ids ready for download_file. This is where course material actually lives — prefer it over ' +
|
|
'poking at cards individually.',
|
|
inputSchema: {
|
|
boardId: z.string().describe('Board id, from get_course.'),
|
|
includeFiles: z
|
|
.boolean()
|
|
.default(true)
|
|
.describe('Resolve attachments to real file records. Turn off for a faster structure-only view.'),
|
|
},
|
|
annotations: READ_ONLY,
|
|
},
|
|
async ({ boardId, includeFiles }) => {
|
|
try {
|
|
const schoolId = await context.schoolId();
|
|
const board = await assembleBoard(context.client, boardId, schoolId, { resolveFiles: includeFiles });
|
|
return text(formatBoard(board, includeFiles));
|
|
} catch (error) {
|
|
return toToolError(error, `read board ${boardId}`);
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
'get_lesson',
|
|
{
|
|
title: 'Get lesson',
|
|
description:
|
|
'One topic/lesson ("Thema") from a course: its text sections, linked materials, attached files and ' +
|
|
'the tasks that belong to it. Lessons are the older content format; newer courses use column boards.',
|
|
inputSchema: {
|
|
lessonId: z.string().describe('Lesson id, from get_course.'),
|
|
},
|
|
annotations: READ_ONLY,
|
|
},
|
|
async ({ lessonId }) => {
|
|
try {
|
|
const schoolId = await context.schoolId();
|
|
const [lesson, tasks, files] = await Promise.all([
|
|
context.client.getLesson(lessonId),
|
|
context.client.getLessonTasks(lessonId).catch(() => undefined),
|
|
context.client
|
|
.listFiles({ storageLocationId: schoolId, parentType: 'lessons', parentId: lessonId })
|
|
.catch(() => undefined),
|
|
]);
|
|
return text(formatLesson(lesson, tasks?.data ?? [], files?.data ?? []));
|
|
} catch (error) {
|
|
return toToolError(error, `read lesson ${lessonId}`);
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
'get_task',
|
|
{
|
|
title: 'Get task',
|
|
description:
|
|
'Full detail for one task: description, due date, attached files, and **what the account handed ' +
|
|
'in** — submission id, graded state, grade, and the submitted files ready for download_file. ' +
|
|
'The API has no single-task endpoint, so this locates the task through the task lists and course ' +
|
|
'pages — pass courseId when you know it, which makes the lookup immediate instead of a scan.',
|
|
inputSchema: {
|
|
taskId: z.string().describe('Task id, from list_tasks or get_course.'),
|
|
courseId: z.string().optional().describe('Course the task belongs to. Optional; speeds up the lookup.'),
|
|
},
|
|
annotations: READ_ONLY,
|
|
},
|
|
async ({ taskId, courseId }) => {
|
|
try {
|
|
const schoolId = await context.schoolId();
|
|
const found = await findTask(context, taskId, courseId);
|
|
if (!found) {
|
|
return failure(
|
|
`Task ${taskId} was not found on any course page this account can see, nor in the task ` +
|
|
`lists. Check the id — get it from list_tasks, get_course or list_submissions.`,
|
|
);
|
|
}
|
|
const [files, submission] = await Promise.all([
|
|
context.client
|
|
.listFiles({ storageLocationId: schoolId, parentType: 'tasks', parentId: taskId })
|
|
.catch(() => undefined),
|
|
describeSubmission(context, taskId).catch(() => undefined),
|
|
]);
|
|
return text(formatTask(found, files?.data ?? [], submission));
|
|
} catch (error) {
|
|
return toToolError(error, `read task ${taskId}`);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
// --- task lookup -------------------------------------------------------
|
|
|
|
/**
|
|
* Finds a task by id.
|
|
*
|
|
* There is no `GET /tasks/{id}`, and the list endpoints omit `description`,
|
|
* which is only present on the course page's task element. So: use the lists
|
|
* to learn which course the task belongs to (unless told), then read the
|
|
* description off that course's page.
|
|
*/
|
|
async function findTask(context: ServerContext, taskId: string, courseId?: string): Promise<TaskContent | undefined> {
|
|
if (courseId) {
|
|
const fromCourse = await taskFromCourse(context, courseId, taskId);
|
|
if (fromCourse) return fromCourse;
|
|
}
|
|
|
|
const [open, finished] = await Promise.all([
|
|
context.client.listTasks({ limit: 99 }).catch(() => undefined),
|
|
context.client.listFinishedTasks({ limit: 99 }).catch(() => undefined),
|
|
]);
|
|
const listed = [...(open?.data ?? []), ...(finished?.data ?? [])].find((task) => task.id === taskId);
|
|
|
|
if (listed) {
|
|
// The list entry lacks the description; the course page has it.
|
|
if (listed.courseId) {
|
|
const enriched = await taskFromCourse(context, listed.courseId, taskId);
|
|
if (enriched) return { ...listed, ...enriched };
|
|
}
|
|
return listed;
|
|
}
|
|
|
|
// The task lists only cover what the dashboard shows, so a perfectly visible
|
|
// task can be absent from both — group-project tasks in particular. Falling
|
|
// back to scanning course pages costs ~26 requests and a few seconds, which
|
|
// is a fair price for the tool working instead of claiming the id is wrong.
|
|
const courses = await context.client.listAllCourses().catch(() => []);
|
|
let found: TaskContent | undefined;
|
|
await forEachLimited(courses, 6, async (course) => {
|
|
if (found) return;
|
|
const fromCourse = await taskFromCourse(context, course.id, taskId);
|
|
if (fromCourse) found = fromCourse;
|
|
});
|
|
return found;
|
|
}
|
|
|
|
async function taskFromCourse(
|
|
context: ServerContext,
|
|
courseId: string,
|
|
taskId: string,
|
|
): Promise<TaskContent | undefined> {
|
|
const board = await context.client.getCourseBoard(courseId).catch(() => undefined);
|
|
if (!board) return undefined;
|
|
for (const element of board.elements) {
|
|
if (element.type === 'task' && element.content.id === taskId) {
|
|
return { ...element.content, courseId, courseName: element.content.courseName ?? board.title };
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// --- formatting --------------------------------------------------------
|
|
|
|
function formatCourseBoard(board: CourseBoardResponse): string {
|
|
const boards: string[] = [];
|
|
const lessons: string[] = [];
|
|
const tasks: string[] = [];
|
|
|
|
for (const element of board.elements) {
|
|
if (element.type === 'column-board') {
|
|
boards.push(`- **${element.content.title}** (\`${element.content.id}\`)`);
|
|
} else if (element.type === 'lesson') {
|
|
const taskCount = element.content.numberOfPublishedTasks
|
|
? ` — ${element.content.numberOfPublishedTasks} task(s)`
|
|
: '';
|
|
const hidden = element.content.hidden ? ' [hidden]' : '';
|
|
lessons.push(`- **${element.content.name}** (\`${element.content.id}\`)${taskCount}${hidden}`);
|
|
} else if (element.type === 'task') {
|
|
const status = element.content.status.submitted > 0 ? 'submitted' : 'not submitted';
|
|
tasks.push(`- **${element.content.name}** (\`${element.content.id}\`) — ${dueLabel(element.content.dueDate)}, ${status}`);
|
|
}
|
|
}
|
|
|
|
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),
|
|
`Course id: \`${board.roomId}\``,
|
|
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.']),
|
|
]);
|
|
}
|
|
|
|
function formatBoard(board: AssembledBoard, includeFiles: boolean): string {
|
|
const columns = board.columns.map((column) => {
|
|
const cards = column.cards.map((card) => {
|
|
const body = card.elements
|
|
.map((element) => formatElement(element, includeFiles))
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
return joinSections([heading(4, card.title), body || '_(empty card)_']);
|
|
});
|
|
return joinSections([heading(3, column.title), cards.length > 0 ? cards.join('\n\n') : '_(no cards)_']);
|
|
});
|
|
|
|
const summary =
|
|
`Board id: \`${board.id}\`` +
|
|
(board.context ? ` — in ${board.context.type} \`${board.context.id}\`` : '') +
|
|
(includeFiles ? ` — ${board.fileCount} attached file(s)` : '');
|
|
|
|
return joinSections([
|
|
heading(2, board.title),
|
|
summary,
|
|
columns.length > 0 ? columns.join('\n\n') : '_(no columns)_',
|
|
includeFiles && board.fileCount > 0 ? 'Read any attachment with download_file using its file id.' : undefined,
|
|
]);
|
|
}
|
|
|
|
function formatElement(element: AssembledElement, includeFiles: boolean): string {
|
|
switch (element.type) {
|
|
case 'richText': {
|
|
const body = htmlToText(element.text);
|
|
return body ? body : '';
|
|
}
|
|
case 'link': {
|
|
const label = element.text?.trim();
|
|
return element.url ? `- Link: ${label && label !== element.url ? `${label} — ${element.url}` : element.url}` : '';
|
|
}
|
|
case 'file':
|
|
case 'fileFolder':
|
|
case 'drawing': {
|
|
const caption = element.text ? ` — caption: ${element.text}` : '';
|
|
if (!includeFiles) return `- ${element.type} element \`${element.id}\`${caption}`;
|
|
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');
|
|
}
|
|
case 'collaborativeTextEditor':
|
|
return `- Collaborative text document \`${element.id}\`${element.text ? ` — ${element.text}` : ''} (contents not available through the API)`;
|
|
case 'externalTool':
|
|
return `- External tool${element.text ? `: ${element.text}` : ''} \`${element.id}\``;
|
|
case 'videoConference':
|
|
return `- Video conference \`${element.id}\``;
|
|
case 'h5p':
|
|
return `- H5P interactive content \`${element.id}\``;
|
|
case 'deleted':
|
|
return '- _(deleted element)_';
|
|
default:
|
|
return `- ${element.type} element \`${element.id}\``;
|
|
}
|
|
}
|
|
|
|
export function formatFileLine(file: FileRecord): string {
|
|
const blocked = file.securityCheckStatus === 'blocked' ? ' **[virus scan: blocked]**' : '';
|
|
const pending = file.securityCheckStatus === 'pending' ? ' _[virus scan pending]_' : '';
|
|
return `File: **${file.name}** (\`${file.id}\`, ${file.mimeType}, ${formatBytes(file.size)})${blocked}${pending}`;
|
|
}
|
|
|
|
function formatLesson(lesson: LessonResponse, tasks: TaskContent[], files: FileRecord[]): string {
|
|
const sections = (lesson.contents ?? []).map((entry) => {
|
|
const title = entry.title?.trim();
|
|
const component = entry.component ?? 'unknown';
|
|
const hidden = entry.hidden ? ' [hidden]' : '';
|
|
const body = formatLessonComponent(component, entry.content ?? {});
|
|
return joinSections([heading(4, `${title || component}${hidden}`), body || `_(${component} content, nothing to show)_`]);
|
|
});
|
|
|
|
const materials = (lesson.materials ?? []).map((material) => {
|
|
const id = normalizeObjectId(material.id);
|
|
return `- ${material.title ?? 'Untitled material'}${material.url ? ` — ${material.url}` : ''}${id ? ` (\`${id}\`)` : ''}`;
|
|
});
|
|
|
|
return joinSections([
|
|
heading(2, lesson.name),
|
|
`Lesson id: \`${lesson.id}\` — in course \`${lesson.courseId}\`${lesson.hidden ? ' — hidden' : ''}`,
|
|
sections.length > 0 ? joinSections([heading(3, 'Contents'), sections.join('\n\n')]) : '_(no text contents)_',
|
|
materials.length > 0 && joinSections([heading(3, 'Linked materials'), materials.join('\n')]),
|
|
files.length > 0 &&
|
|
joinSections([heading(3, `Attached files (${files.length})`), files.map((file) => `- ${formatFileLine(file)}`).join('\n')]),
|
|
tasks.length > 0 &&
|
|
joinSections([
|
|
heading(3, `Tasks in this lesson (${tasks.length})`),
|
|
tasks.map((task) => `- **${task.name}** (\`${task.id}\`) — ${dueLabel(task.dueDate)}`).join('\n'),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
function formatLessonComponent(component: string, content: Record<string, unknown>): 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}` : ''}`;
|
|
})
|
|
.join('\n');
|
|
}
|
|
if (typeof content.url === 'string') return `- ${content.url}`;
|
|
if (typeof content.title === 'string') return content.title;
|
|
return '';
|
|
}
|
|
|
|
function formatTask(task: TaskContent, files: FileRecord[], submission?: string): string {
|
|
const description = htmlToText(task.description);
|
|
return joinSections([
|
|
heading(2, task.name),
|
|
[
|
|
`- Task id: \`${task.id}\``,
|
|
task.courseName ? `- Course: ${task.courseName}${task.courseId ? ` (\`${task.courseId}\`)` : ''}` : undefined,
|
|
task.lessonName ? `- Topic: ${task.lessonName}` : undefined,
|
|
`- Available from: ${formatDate(task.availableDate)}`,
|
|
`- Due: ${dueLabel(task.dueDate)}`,
|
|
`- Submitted: ${task.status.submitted}/${task.status.maxSubmissions}${task.status.graded > 0 ? ', graded' : ''}`,
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n'),
|
|
description ? joinSections([heading(3, 'Description'), description]) : '_(no description)_',
|
|
files.length > 0
|
|
? joinSections([
|
|
heading(3, `Attached files (${files.length})`),
|
|
files.map((file) => `- ${formatFileLine(file)}`).join('\n'),
|
|
'Read one with download_file.',
|
|
])
|
|
: undefined,
|
|
submission,
|
|
]);
|
|
}
|