You were right that the output was wrong, though not quite for the
reason given: the schema has no textual grade. It is
grade: { type: Number, min: 0, max: 100 } with gradeComment: String, so
"OK" is the comment, not the grade.
What the model does allow is exactly your case — teachers grade with the
comment alone and leave grade unset. Rendering that as "graded (no
numeric grade recorded)" reads as missing or broken data when in fact
the written verdict is the whole grade. It now says "graded by feedback,
with no percentage given", and reserves the it-is-absent wording for
when there is genuinely neither a percentage nor a comment.
Also fixes a real misrepresentation next to it: grade is a percentage,
and both the detail and list views printed it bare, so an 85 could be
read as a mark out of 100, 15 or 6. Now rendered as 85%.
formatGradeState is pure and covered by seven cases, including 0% staying
distinct from "no grade" — the bug that an `if (grade)` test would have
introduced.
85 tests, 38/38 smoke.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
import { z } from 'zod';
|
|
import type { ServerContext } from '../../context.ts';
|
|
import { forEachLimited } from '../../core/crawl.ts';
|
|
import { fetchSubmissionDetail } from '../../core/homework-page.ts';
|
|
import { dueLabel, heading, joinSections } from '../../core/text.ts';
|
|
import type { FileRecord, SubmissionStatus, TaskContent } 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.'),
|
|
limit: z.number().int().min(1).max(99).default(50).describe('Maximum tasks to check.'),
|
|
},
|
|
annotations: READ_ONLY,
|
|
},
|
|
async ({ courseId, scope, onlyMine, 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: TaskContent; status: SubmissionStatus }[] = [];
|
|
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 });
|
|
}
|
|
} 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));
|
|
return text(
|
|
joinSections([
|
|
heading(2, `Submissions (${rows.length} across ${tasks.length} task(s))`),
|
|
rows.map(formatRow).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 }: { task: TaskContent; status: SubmissionStatus }): 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';
|
|
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}\``;
|
|
}
|
|
|
|
async function collectTasks(
|
|
context: ServerContext,
|
|
scope: 'open' | 'finished' | 'all',
|
|
courseId: string | undefined,
|
|
limit: number,
|
|
): Promise<TaskContent[]> {
|
|
const wanted: TaskContent[] = [];
|
|
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 });
|
|
}
|
|
}
|
|
|
|
const seen = new Set<string>();
|
|
return wanted
|
|
.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, detail] = 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),
|
|
]);
|
|
if (statuses.length === 0) return undefined;
|
|
|
|
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);
|
|
|
|
parts.push(
|
|
[
|
|
`- Submission \`${status.id}\`${mine.length === 0 ? ' _(not yours)_' : ''}`,
|
|
`- ${status.isSubmitted ? 'Submitted' : 'Not submitted'}, ${graded}`,
|
|
status.submittingCourseGroupName ? `- Group: ${status.submittingCourseGroupName}` : undefined,
|
|
status.submitters.length > 1 ? `- ${status.submitters.length} submitters` : 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([
|
|
heading(3, 'Your submission'),
|
|
parts.join('\n\n'),
|
|
...written,
|
|
'Read any attachment with download_file.' +
|
|
(written.length === 0 && 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'),
|
|
};
|
|
}
|