Files
Schulcloud-MCP/src/mcp/tools/submissions.ts
MechaCat02 5ae2210459 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>
2026-09-16 20:19:16 +02:00

379 lines
16 KiB
TypeScript

import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
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 { 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';
import { text, toToolError } from './result.ts';
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
/**
* What the account handed in.
*
* The API is thin here and the limits are worth stating plainly, because they
* shape what these tools can promise: `GET /submissions/status/task/{taskId}`
* is the *only* submission endpoint, there is no way to fetch a submission by
* its own id, and the response carries no submitted text, no grade comment and
* no graded-at timestamp. Submitted files are reachable, through files-storage
* with `parentType: 'submissions'`.
*/
export function registerSubmissionTools(server: McpServer, context: ServerContext): void {
server.registerTool(
'list_submissions',
{
title: 'List my submissions',
description:
'What the account has handed in ("Abgaben"), across tasks: whether each was submitted, whether it ' +
'was graded, and the grade if there is one. Use it for "what have I handed in", "what is still ' +
'ungraded", or to find a submission id. **Pass courseId whenever you can**: the API has no ' +
'submissions list, so this checks tasks one by one, and without a course it can only check tasks ' +
'from the task lists — which cover the dashboard, not every task in every course. With a courseId ' +
'it also reads that course page and so sees all of them. On a teacher account it reports every ' +
'student\'s submission, not just the caller\'s.',
inputSchema: {
courseId: z.string().optional().describe('Only tasks in this course. Strongly recommended — much faster.'),
scope: z
.enum(['open', 'finished', 'all'])
.default('all')
.describe('Which tasks to check: still-open ones, archived ones, or both.'),
onlyMine: z
.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, 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; grading?: SubmissionGrading }[] = [];
const unavailable: string[] = [];
await forEachLimited(tasks, 5, async (task) => {
try {
const statuses = await context.client.listSubmissionStatuses(task.id);
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.
unavailable.push(task.name);
}
});
if (rows.length === 0) {
return text(
joinSections([
`No submissions found across ${tasks.length} task(s)${courseId ? ' in that course' : ''}.`,
unavailable.length > 0 ? `Could not check ${unavailable.length} task(s).` : undefined,
]),
);
}
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))`),
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,
]),
);
} catch (error) {
return toToolError(error, 'list submissions');
}
},
);
}
/** True when at least one of these submissions is marked graded. */
function anyGraded(statuses: SubmissionStatus[]): boolean {
return statuses.some((status) => status.isGraded);
}
/**
* How to describe the grading.
*
* The data model has no textual grade: `grade` is `Number, min 0, max 100` — a
* percentage — and `gradeComment` is free text. Teachers routinely grade with
* the comment alone, leaving `grade` unset, so a written "OK" *is* the verdict
* for that submission rather than a missing value. Saying "no numeric grade
* recorded" in that case reads as broken data, which is why it is only said
* when there is genuinely nothing to show.
*/
export function formatGradeState(
status: Pick<SubmissionStatus, 'isGraded' | 'grade'>,
hasWrittenFeedback: boolean,
percentFromPage?: number,
): string {
if (!status.isGraded) return 'not graded yet';
// `grade` is a percentage in the schema, so render it as one rather than as
// a bare number that could be mistaken for a mark out of 6 or 15.
const percent = status.grade ?? percentFromPage;
if (percent !== null && percent !== undefined) {
return hasWrittenFeedback ? `graded **${percent}%**, with feedback` : `graded **${percent}%**`;
}
if (hasWrittenFeedback) return 'graded by feedback, with no percentage given';
return 'marked graded, but neither a percentage nor feedback was found';
}
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';
// 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}]` : '';
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(
context: ServerContext,
scope: 'open' | 'finished' | 'all',
courseId: string | undefined,
limit: number,
): Promise<(ResolvedTask & { id: string })[]> {
const wanted: ResolvedTask[] = [];
if (scope === 'open' || scope === 'all') {
wanted.push(...(await context.client.listTasks({ limit }).catch(() => ({ data: [] }))).data);
}
if (scope === 'finished' || scope === 'all') {
wanted.push(...(await context.client.listFinishedTasks({ limit }).catch(() => ({ data: [] }))).data);
}
// The task lists only cover what the dashboard shows; a course page lists
// tasks the lists can omit, so fold those in when a course was named.
if (courseId) {
const board = await context.client.getCourseBoard(courseId).catch(() => undefined);
for (const element of board?.elements ?? []) {
if (element.type === 'task') wanted.push({ ...element.content, courseId });
}
// Tasks attached to a topic are not task elements on the course page, so
// they have to be asked for per topic. They are the ones most likely to
// carry a grade: a task old enough to have been marked is usually old
// enough to have dropped out of both task lists.
for (const element of board?.elements ?? []) {
if (element.type !== 'lesson' || !element.content.numberOfPublishedTasks) continue;
const [tasks, links] = await Promise.all([
context.client.getLessonTasks(element.content.id).catch(() => []),
fetchLessonTaskLinks(context.config, courseId, element.content.id),
]);
// Only the ones whose id could be recovered: a submission lookup needs it.
for (const task of withScrapedIds(tasks, links)) if (task.id) wanted.push({ ...task, courseId });
}
}
const seen = new Set<string>();
return wanted
.filter((task): task is ResolvedTask & { id: string } => Boolean(task.id))
.filter((task) => (courseId ? task.courseId === courseId : true))
.filter((task) => (seen.has(task.id) ? false : (seen.add(task.id), true)))
.slice(0, limit);
}
/**
* The caller's submission for a task, with its files — used by `get_task`.
*
* Returns undefined when the task has no submission or the endpoint refuses,
* so a task without one still renders.
*/
export async function describeSubmission(
context: ServerContext,
taskId: string,
): Promise<string | undefined> {
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.
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);
// 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}\`${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,
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',
files.grading.length > 0
? `- Returned by the teacher:\n${files.grading.map((file) => ` - ${formatFileLine(file)}`).join('\n')}`
: undefined,
]
.filter(Boolean)
.join('\n'),
);
}
// Only the rendered page carries these, and only for the caller's own work.
const written = [
detail?.submittedText
? joinSections([heading(4, 'What you wrote'), detail.submittedText])
: undefined,
detail?.gradeComment
? joinSections([heading(4, "Teacher's feedback"), detail.gradeComment])
: undefined,
].filter(Boolean) as string[];
return joinSections([
// 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 && !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.'
: ''),
]);
}
/**
* Submission and grading attachments.
*
* Note the odd part: listing with `parentType: 'gradings'` returns files whose
* own `parentType` is `submissions`, i.e. the path segment does not filter.
* So both lists are fetched and split on what each record actually says,
* otherwise a student's own upload would be reported back as teacher feedback.
*/
async function loadSubmissionFiles(
context: ServerContext,
submissionId: string,
): Promise<{ submitted: FileRecord[]; grading: FileRecord[] }> {
const schoolId = await context.schoolId();
const [submitted, grading] = await Promise.all([
context.client
.listFiles({ storageLocationId: schoolId, parentType: 'submissions', parentId: submissionId })
.catch(() => undefined),
context.client
.listFiles({ storageLocationId: schoolId, parentType: 'gradings', parentId: submissionId })
.catch(() => undefined),
]);
const all = [...(submitted?.data ?? []), ...(grading?.data ?? [])];
const seen = new Set<string>();
const unique = all.filter((file) => (seen.has(file.id) ? false : (seen.add(file.id), true)));
return {
submitted: unique.filter((file) => file.parentType === 'submissions'),
grading: unique.filter((file) => file.parentType === 'gradings'),
};
}