From ac08e17b48209b5b06241fed3d622834f360a36c Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 12 Sep 2026 23:19:11 +0200 Subject: [PATCH] Expose submissions: list_submissions, and get_task shows your own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 7 ++ README.md | 3 +- docs/API.md | 17 +++ scripts/smoke.mjs | 16 ++- src/core/client.ts | 17 +++ src/core/types.ts | 20 ++++ src/mcp/server.ts | 6 + src/mcp/tools/content.ts | 53 ++++++--- src/mcp/tools/search.ts | 26 +++- src/mcp/tools/submissions.ts | 222 +++++++++++++++++++++++++++++++++++ 10 files changed, 362 insertions(+), 25 deletions(-) create mode 100644 src/mcp/tools/submissions.ts diff --git a/CLAUDE.md b/CLAUDE.md index ca35f35..82fc733 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/README.md b/README.md index c44a016..147fb3f 100644 --- a/README.md +++ b/README.md @@ -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** | diff --git a/docs/API.md b/docs/API.md index 84fcf4f..72ecd64 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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. diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 3053708..b2b55db 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -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. diff --git a/src/core/client.ts b/src/core/client.ts index 8ef358d..897262e 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -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 { + const page = await this.getJson<{ data: SubmissionStatus[] }>( + `/api/v3/submissions/status/task/${encodeURIComponent(taskId)}`, + ); + return page.data ?? []; + } + // --- lessons --------------------------------------------------------- getLesson(lessonId: string): Promise { diff --git a/src/core/types.ts b/src/core/types.ts index b9aa50a..1624e06 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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: { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f9d82d4..098cb50 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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); diff --git a/src/mcp/tools/content.ts b/src/mcp/tools/content.ts index 8eadd66..a9446cf 100644 --- a/src/mcp/tools/content.ts +++ b/src/mcp/tools/content.ts @@ -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 - .listFiles({ storageLocationId: schoolId, parentType: 'tasks', parentId: taskId }) - .catch(() => undefined); - return text(formatTask(found, files?.data ?? [])); + 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}`); } @@ -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; - // 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 }; + 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; } - 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 { + 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}`; diff --git a/src/mcp/tools/submissions.ts b/src/mcp/tools/submissions.ts new file mode 100644 index 0000000..f65c282 --- /dev/null +++ b/src/mcp/tools/submissions.ts @@ -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 { + 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(); + 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 { + 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(); + 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'), + }; +}