Files
Schulcloud-MCP/src/mcp/tools/content.ts
MechaCat02 ac08e17b48 Expose submissions: list_submissions, and get_task shows your own
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>
2026-09-12 23:19:11 +02:00

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,
]);
}