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>
This commit is contained in:
2026-09-12 23:19:11 +02:00
parent 634b004985
commit ac08e17b48
10 changed files with 362 additions and 25 deletions

View File

@@ -123,6 +123,13 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
- **Never swallow a per-item crawl error.** Board failures used to be caught and
dropped, so the index lost whole boards while the crawl reported success —
which is how the 20-id limit went unnoticed. They go into `Snapshot.failures`.
- **Submissions: only `GET /submissions/status/task/{taskId}` exists.** No list,
no fetch-by-id, and the payload has no submitted text, grade comment or
graded-at — `/api/v1`, which had them, is not served here. Don't imply absent
feedback means none was given.
- **files-storage listing ignores the `parentType` path segment** — filter on
each record's own `parentType`, or submission files get reported as grading
files.
- **Board file elements carry no file id.** Files are found by listing
files-storage with `parentType: 'boardnodes'` and the *element* id as
`parentId`. Same for `fileFolder` and `drawing`.

View File

@@ -28,7 +28,8 @@ Thirteen tools, all read-only:
| `get_board` | a column board in full: columns, cards, text, links, files |
| `get_lesson` | a topic's text sections, materials, files and tasks |
| `list_tasks` | homework across all courses, by due date |
| `get_task` | one task: description, due date, status, attachments |
| `get_task` | one task: description, due date, attachments, **and your submission** |
| `list_submissions` | what you handed in, and what is still ungraded |
| `list_files` | files attached to any entity |
| `download_file` | fetch a file and extract its text, or view an image |
| `search` | keyword search across everything — **including the text inside PDFs and Office files** |

View File

@@ -119,6 +119,23 @@ parser underneath them. Verified live — 20 ids return 200, 21 return 400 with
identical ids. A board with more than 20 cards is therefore unreadable in one
request; `getCards` chunks at 20.
**Submissions are nearly invisible.** The only route is
`GET /api/v3/submissions/status/task/{taskId}` — there is no `GET /submissions`
and no fetch-by-id, so a task id is the only way to reach a submission. The
response carries `{id, submitters, isSubmitted, isGraded, grade,
submittingCourseGroupName}` and **nothing else**: no submitted text, no grade
comment, no graded-at. Those lived on the legacy Feathers API, and `/api/v1` is
not served on this instance (404 across the board), so they are simply
unavailable. Submitted *files* are reachable through files-storage with
`parentType: 'submissions'`.
**files-storage ignores `parentType` when listing.** Asking for
`.../gradings/{submissionId}` returns the files parented to that id whatever
their type — the records come back saying `parentType: "submissions"`. The path
segment appears to serve authorisation, not filtering, so filter on each
record's own `parentType` or a student's own upload will be reported back as
teacher feedback.
**`storageLocationId` is the school id** (from `/me`), with
`storageLocation: 'school'`, for every parent type in normal use.

View File

@@ -154,10 +154,22 @@ if (fileId) {
console.log('\n== search ==');
const searchTerm = process.env.SMOKE_SEARCH ?? 'Datenschutz';
const search = await call('search', { query: searchTerm, fresh: true });
check(`search "${searchTerm}" (fresh, bypassing any index)`, !search.isError, search.text.split('\n')[0]);
const search = await call('search', { query: searchTerm, fresh: true, courseId: courseIds[0] });
check(`search "${searchTerm}" (fresh + scoped, bypassing any index)`, !search.isError, search.text.split('\n')[0]);
const freshAll = await call('search', { query: searchTerm, fresh: true });
check('unscoped fresh search returns without timing out', !freshAll.isError, freshAll.text.split('\n')[0]);
check('search scoped to one course', !(await call('search', { query: 'a b', courseId: courseIds[0], fresh: true })).isError);
console.log('\n== submissions ==');
if (taskId) {
const task = await call('get_task', { taskId });
check('get_task still works with submission lookup', !task.isError);
const subs = await call('list_submissions', { courseId: courseIds[0], scope: 'all' });
check('list_submissions scoped to a course', !subs.isError, subs.text.split('\n')[0]);
const all = await call('list_submissions', { limit: 5 });
check('list_submissions unscoped', !all.isError, all.text.split('\n')[0]);
}
console.log('\n== index tools ==');
// These degrade gracefully without DATABASE_URL, so assert on either outcome
// rather than requiring a database for the smoke run to be meaningful.

View File

@@ -12,6 +12,7 @@ import type {
MeResponse,
NewsResponse,
Paginated,
SubmissionStatus,
TaskContent,
} from './types.ts';
@@ -281,6 +282,22 @@ export class SchulcloudClient {
});
}
// --- submissions -----------------------------------------------------
/**
* Submission statuses for one task.
*
* The only way to obtain a submission id: there is no `GET /submissions` and
* no `GET /submissions/{id}`. What comes back depends on the account — a
* student sees their own submission, a teacher sees the whole class's.
*/
async listSubmissionStatuses(taskId: string): Promise<SubmissionStatus[]> {
const page = await this.getJson<{ data: SubmissionStatus[] }>(
`/api/v3/submissions/status/task/${encodeURIComponent(taskId)}`,
);
return page.data ?? [];
}
// --- lessons ---------------------------------------------------------
getLesson(lessonId: string): Promise<LessonResponse> {

View File

@@ -218,6 +218,26 @@ export const FILE_PARENT_TYPES: FileParentType[] = [
'externaltools',
];
/**
* One submission's status for a task.
*
* This is the entire submission surface the v3 API offers — there is no
* endpoint for the submitted *text*, the grade comment, or a graded-at
* timestamp, and the legacy `/api/v1` surface that once carried them is not
* served on this instance (404, not proxied away). Submitted work that is a
* file is still reachable, through files-storage.
*/
export interface SubmissionStatus {
id: string;
/** User ids. A group submission lists every member. */
submitters: string[];
isSubmitted: boolean;
isGraded: boolean;
/** Present only when a numeric grade was given; often null even if graded. */
grade?: number | null;
submittingCourseGroupName?: string;
}
export interface DashboardResponse {
id: string;
gridElements: {

View File

@@ -8,6 +8,7 @@ import { registerOverviewTools } from './tools/overview.ts';
import { registerRawTool } from './tools/raw.ts';
import { registerIndexTools } from './tools/index-tools.ts';
import { registerSearchTool } from './tools/search.ts';
import { registerSubmissionTools } from './tools/submissions.ts';
export const SERVER_NAME = 'schulcloud-mcp';
export const SERVER_VERSION = '0.1.0';
@@ -24,6 +25,10 @@ How the content is organised, and the usual path through it:
- **Tasks** ("Aufgaben") — homework. list_tasks across all courses, get_task for one.
- **Files** hang off boards, lessons and tasks. Every listing shows file ids; download_file fetches one and
extracts its text (PDF, Word, Excel, PowerPoint, OpenDocument) or returns an image inline.
- **Submissions** ("Abgaben") — what the user handed in. get_task shows the submission for that task;
list_submissions surveys them. Only files, the graded flag and a numeric grade are available: the API
exposes no submitted text and no written feedback, so say so rather than implying none was given.
On a teacher account these tools report other people's submissions too.
When the user names a topic rather than a course, use search — the API has no search endpoint, so it walks the
courses and matches client-side, which takes a few seconds but covers board text and file names.
@@ -41,6 +46,7 @@ export function createServer(config: Config, services?: Services): { server: Mcp
registerContentTools(server, context);
registerFileTools(server, context);
registerSearchTool(server, context);
registerSubmissionTools(server, context);
registerIndexTools(server, context);
registerRawTool(server, context);

View File

@@ -4,8 +4,10 @@ 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 };
@@ -95,9 +97,10 @@ export function registerContentTools(server: McpServer, context: ServerContext):
{
title: 'Get task',
description:
'Full detail for one task: description, due date, submission status and attached files. ' +
'The API has no single-task endpoint, so this locates the task through the task lists and its ' +
'course page — pass courseId when you know it to skip the search.',
'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.'),
@@ -110,14 +113,17 @@ export function registerContentTools(server: McpServer, context: ServerContext):
const found = await findTask(context, taskId, courseId);
if (!found) {
return failure(
`Task ${taskId} was not found in the open or finished task lists, nor on the given course page. ` +
`It may belong to a course this account cannot see, or the id may be wrong.`,
`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 = await context.client
const [files, submission] = await Promise.all([
context.client
.listFiles({ storageLocationId: schoolId, parentType: 'tasks', parentId: taskId })
.catch(() => undefined);
return text(formatTask(found, files?.data ?? []));
.catch(() => undefined),
describeSubmission(context, taskId).catch(() => undefined),
]);
return text(formatTask(found, files?.data ?? [], submission));
} catch (error) {
return toToolError(error, `read task ${taskId}`);
}
@@ -146,14 +152,28 @@ async function findTask(context: ServerContext, taskId: string, courseId?: strin
context.client.listFinishedTasks({ limit: 99 }).catch(() => undefined),
]);
const listed = [...(open?.data ?? []), ...(finished?.data ?? [])].find((task) => task.id === taskId);
if (!listed) return undefined;
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(
@@ -315,7 +335,7 @@ function formatLessonComponent(component: string, content: Record<string, unknow
return '';
}
function formatTask(task: TaskContent, files: FileRecord[]): string {
function formatTask(task: TaskContent, files: FileRecord[], submission?: string): string {
const description = htmlToText(task.description);
return joinSections([
heading(2, task.name),
@@ -337,5 +357,6 @@ function formatTask(task: TaskContent, files: FileRecord[]): string {
'Read one with download_file.',
])
: undefined,
submission,
]);
}

View File

@@ -40,8 +40,10 @@ export function registerSearchTool(server: McpServer, context: ServerContext): v
.boolean()
.default(false)
.describe(
'Bypass the index and read Schulcloud live. Slower (seconds) but guaranteed current — ' +
'use when the user says something was just added, or when index_status shows a stale crawl.',
'Bypass the index and read Schulcloud live. Pair it with courseId: one course takes ~2s and ' +
'includes file names. Without courseId it reads every course text-only (~5s) and skips ' +
'attachments, because resolving those needs a request per element and takes minutes. ' +
'To bring file names up to date instead, use refresh_index.',
),
},
annotations: READ_ONLY,
@@ -86,7 +88,15 @@ export function registerSearchTool(server: McpServer, context: ServerContext): v
);
}
/** The no-index path: crawl now and match in memory. */
/**
* The live path: crawl now and match in memory.
*
* Attachments are resolved only when a single course was named. Resolving them
* costs one files-storage request per board element — measured against the live
* instance, that is the difference between 2s for one course and **325s for all
* of them**, which no client will wait for. So an unscoped live search reads
* text only and says so.
*/
async function liveSearch(
context: ServerContext,
query: string,
@@ -94,17 +104,21 @@ async function liveSearch(
limit: number,
explicit: boolean,
): Promise<string> {
const scoped = Boolean(courseId);
const snapshot = await crawl(context.client, {
schoolId: await context.schoolId(),
courseIds: courseId ? [courseId] : undefined,
includeLessonContents: true,
includeFiles: true,
includeFiles: scoped,
});
const hits = searchSnapshot(snapshot, query, limit);
const scopeNote = scoped
? ''
: ' File names and attachments were not re-read — name a courseId, or use refresh_index, to include them.';
const note = explicit
? '_Read live from Schulcloud, bypassing the index._'
: '_No index configured; read live from Schulcloud. File contents are not searched this way._';
? `_Read live from Schulcloud, bypassing the index.${scopeNote}_`
: `_No index configured; read live from Schulcloud. File contents are not searched this way.${scopeNote}_`;
if (hits.length === 0) {
return `No matches for "${query}" across ${snapshot.courses.length} course(s).\n\n${note}`;

View File

@@ -0,0 +1,222 @@
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 { 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');
}
},
);
}
function formatRow({ task, status }: { task: TaskContent; status: SubmissionStatus }): string {
const state = status.isSubmitted ? 'submitted' : 'not submitted';
const graded = status.isGraded
? status.grade !== null && status.grade !== undefined
? `graded ${status.grade}`
: 'graded (no numeric grade)'
: '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] = await Promise.all([
context.me(),
context.client.listSubmissionStatuses(taskId).catch(() => [] as SubmissionStatus[]),
]);
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 = status.isGraded
? status.grade !== null && status.grade !== undefined
? `graded **${status.grade}**`
: 'graded (no numeric grade recorded)'
: 'not graded yet';
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'),
);
}
return joinSections([
heading(3, 'Your submission'),
parts.join('\n\n'),
'Read any of these with download_file. The API exposes no submitted text or written ' +
'feedback — only files, the graded flag and a numeric grade — so a comment-only ' +
'response from the teacher will not appear here.',
]);
}
/**
* 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'),
};
}