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>
379 lines
16 KiB
TypeScript
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'),
|
|
};
|
|
}
|