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:
@@ -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
|
- **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 —
|
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`.
|
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
|
- **Board file elements carry no file id.** Files are found by listing
|
||||||
files-storage with `parentType: 'boardnodes'` and the *element* id as
|
files-storage with `parentType: 'boardnodes'` and the *element* id as
|
||||||
`parentId`. Same for `fileFolder` and `drawing`.
|
`parentId`. Same for `fileFolder` and `drawing`.
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ Thirteen tools, all read-only:
|
|||||||
| `get_board` | a column board in full: columns, cards, text, links, files |
|
| `get_board` | a column board in full: columns, cards, text, links, files |
|
||||||
| `get_lesson` | a topic's text sections, materials, files and tasks |
|
| `get_lesson` | a topic's text sections, materials, files and tasks |
|
||||||
| `list_tasks` | homework across all courses, by due date |
|
| `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 |
|
| `list_files` | files attached to any entity |
|
||||||
| `download_file` | fetch a file and extract its text, or view an image |
|
| `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** |
|
| `search` | keyword search across everything — **including the text inside PDFs and Office files** |
|
||||||
|
|||||||
17
docs/API.md
17
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
|
identical ids. A board with more than 20 cards is therefore unreadable in one
|
||||||
request; `getCards` chunks at 20.
|
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
|
**`storageLocationId` is the school id** (from `/me`), with
|
||||||
`storageLocation: 'school'`, for every parent type in normal use.
|
`storageLocation: 'school'`, for every parent type in normal use.
|
||||||
|
|
||||||
|
|||||||
@@ -154,10 +154,22 @@ if (fileId) {
|
|||||||
|
|
||||||
console.log('\n== search ==');
|
console.log('\n== search ==');
|
||||||
const searchTerm = process.env.SMOKE_SEARCH ?? 'Datenschutz';
|
const searchTerm = process.env.SMOKE_SEARCH ?? 'Datenschutz';
|
||||||
const search = await call('search', { query: searchTerm, fresh: true });
|
const search = await call('search', { query: searchTerm, fresh: true, courseId: courseIds[0] });
|
||||||
check(`search "${searchTerm}" (fresh, bypassing any index)`, !search.isError, search.text.split('\n')[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);
|
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 ==');
|
console.log('\n== index tools ==');
|
||||||
// These degrade gracefully without DATABASE_URL, so assert on either outcome
|
// These degrade gracefully without DATABASE_URL, so assert on either outcome
|
||||||
// rather than requiring a database for the smoke run to be meaningful.
|
// rather than requiring a database for the smoke run to be meaningful.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
MeResponse,
|
MeResponse,
|
||||||
NewsResponse,
|
NewsResponse,
|
||||||
Paginated,
|
Paginated,
|
||||||
|
SubmissionStatus,
|
||||||
TaskContent,
|
TaskContent,
|
||||||
} from './types.ts';
|
} 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 ---------------------------------------------------------
|
// --- lessons ---------------------------------------------------------
|
||||||
|
|
||||||
getLesson(lessonId: string): Promise<LessonResponse> {
|
getLesson(lessonId: string): Promise<LessonResponse> {
|
||||||
|
|||||||
@@ -218,6 +218,26 @@ export const FILE_PARENT_TYPES: FileParentType[] = [
|
|||||||
'externaltools',
|
'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 {
|
export interface DashboardResponse {
|
||||||
id: string;
|
id: string;
|
||||||
gridElements: {
|
gridElements: {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { registerOverviewTools } from './tools/overview.ts';
|
|||||||
import { registerRawTool } from './tools/raw.ts';
|
import { registerRawTool } from './tools/raw.ts';
|
||||||
import { registerIndexTools } from './tools/index-tools.ts';
|
import { registerIndexTools } from './tools/index-tools.ts';
|
||||||
import { registerSearchTool } from './tools/search.ts';
|
import { registerSearchTool } from './tools/search.ts';
|
||||||
|
import { registerSubmissionTools } from './tools/submissions.ts';
|
||||||
|
|
||||||
export const SERVER_NAME = 'schulcloud-mcp';
|
export const SERVER_NAME = 'schulcloud-mcp';
|
||||||
export const SERVER_VERSION = '0.1.0';
|
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.
|
- **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
|
- **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.
|
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
|
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.
|
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);
|
registerContentTools(server, context);
|
||||||
registerFileTools(server, context);
|
registerFileTools(server, context);
|
||||||
registerSearchTool(server, context);
|
registerSearchTool(server, context);
|
||||||
|
registerSubmissionTools(server, context);
|
||||||
registerIndexTools(server, context);
|
registerIndexTools(server, context);
|
||||||
registerRawTool(server, context);
|
registerRawTool(server, context);
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import type { ServerContext } from '../../context.ts';
|
|||||||
import { formatBytes } from '../../core/extract.ts';
|
import { formatBytes } from '../../core/extract.ts';
|
||||||
import { dueLabel, formatDate, heading, htmlToText, joinSections, normalizeObjectId } from '../../core/text.ts';
|
import { dueLabel, formatDate, heading, htmlToText, joinSections, normalizeObjectId } from '../../core/text.ts';
|
||||||
import { assembleBoard, type AssembledBoard, type AssembledElement } from '../../core/board.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 type { CourseBoardResponse, FileRecord, LessonResponse, TaskContent } from '../../core/types.ts';
|
||||||
import { failure, text, toToolError } from './result.ts';
|
import { failure, text, toToolError } from './result.ts';
|
||||||
|
import { describeSubmission } from './submissions.ts';
|
||||||
|
|
||||||
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
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',
|
title: 'Get task',
|
||||||
description:
|
description:
|
||||||
'Full detail for one task: description, due date, submission status and attached files. ' +
|
'Full detail for one task: description, due date, attached files, and **what the account handed ' +
|
||||||
'The API has no single-task endpoint, so this locates the task through the task lists and its ' +
|
'in** — submission id, graded state, grade, and the submitted files ready for download_file. ' +
|
||||||
'course page — pass courseId when you know it to skip the search.',
|
'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: {
|
inputSchema: {
|
||||||
taskId: z.string().describe('Task id, from list_tasks or get_course.'),
|
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.'),
|
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);
|
const found = await findTask(context, taskId, courseId);
|
||||||
if (!found) {
|
if (!found) {
|
||||||
return failure(
|
return failure(
|
||||||
`Task ${taskId} was not found in the open or finished task lists, nor on the given course page. ` +
|
`Task ${taskId} was not found on any course page this account can see, nor in the task ` +
|
||||||
`It may belong to a course this account cannot see, or the id may be wrong.`,
|
`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([
|
||||||
.listFiles({ storageLocationId: schoolId, parentType: 'tasks', parentId: taskId })
|
context.client
|
||||||
.catch(() => undefined);
|
.listFiles({ storageLocationId: schoolId, parentType: 'tasks', parentId: taskId })
|
||||||
return text(formatTask(found, files?.data ?? []));
|
.catch(() => undefined),
|
||||||
|
describeSubmission(context, taskId).catch(() => undefined),
|
||||||
|
]);
|
||||||
|
return text(formatTask(found, files?.data ?? [], submission));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return toToolError(error, `read task ${taskId}`);
|
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),
|
context.client.listFinishedTasks({ limit: 99 }).catch(() => undefined),
|
||||||
]);
|
]);
|
||||||
const listed = [...(open?.data ?? []), ...(finished?.data ?? [])].find((task) => task.id === taskId);
|
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) {
|
||||||
if (listed.courseId) {
|
// The list entry lacks the description; the course page has it.
|
||||||
const enriched = await taskFromCourse(context, listed.courseId, taskId);
|
if (listed.courseId) {
|
||||||
if (enriched) return { ...listed, ...enriched };
|
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(
|
async function taskFromCourse(
|
||||||
@@ -315,7 +335,7 @@ function formatLessonComponent(component: string, content: Record<string, unknow
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTask(task: TaskContent, files: FileRecord[]): string {
|
function formatTask(task: TaskContent, files: FileRecord[], submission?: string): string {
|
||||||
const description = htmlToText(task.description);
|
const description = htmlToText(task.description);
|
||||||
return joinSections([
|
return joinSections([
|
||||||
heading(2, task.name),
|
heading(2, task.name),
|
||||||
@@ -337,5 +357,6 @@ function formatTask(task: TaskContent, files: FileRecord[]): string {
|
|||||||
'Read one with download_file.',
|
'Read one with download_file.',
|
||||||
])
|
])
|
||||||
: undefined,
|
: undefined,
|
||||||
|
submission,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,10 @@ export function registerSearchTool(server: McpServer, context: ServerContext): v
|
|||||||
.boolean()
|
.boolean()
|
||||||
.default(false)
|
.default(false)
|
||||||
.describe(
|
.describe(
|
||||||
'Bypass the index and read Schulcloud live. Slower (seconds) but guaranteed current — ' +
|
'Bypass the index and read Schulcloud live. Pair it with courseId: one course takes ~2s and ' +
|
||||||
'use when the user says something was just added, or when index_status shows a stale crawl.',
|
'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,
|
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(
|
async function liveSearch(
|
||||||
context: ServerContext,
|
context: ServerContext,
|
||||||
query: string,
|
query: string,
|
||||||
@@ -94,17 +104,21 @@ async function liveSearch(
|
|||||||
limit: number,
|
limit: number,
|
||||||
explicit: boolean,
|
explicit: boolean,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
const scoped = Boolean(courseId);
|
||||||
const snapshot = await crawl(context.client, {
|
const snapshot = await crawl(context.client, {
|
||||||
schoolId: await context.schoolId(),
|
schoolId: await context.schoolId(),
|
||||||
courseIds: courseId ? [courseId] : undefined,
|
courseIds: courseId ? [courseId] : undefined,
|
||||||
includeLessonContents: true,
|
includeLessonContents: true,
|
||||||
includeFiles: true,
|
includeFiles: scoped,
|
||||||
});
|
});
|
||||||
const hits = searchSnapshot(snapshot, query, limit);
|
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
|
const note = explicit
|
||||||
? '_Read live from Schulcloud, bypassing the index._'
|
? `_Read live from Schulcloud, bypassing the index.${scopeNote}_`
|
||||||
: '_No index configured; read live from Schulcloud. File contents are not searched this way._';
|
: `_No index configured; read live from Schulcloud. File contents are not searched this way.${scopeNote}_`;
|
||||||
|
|
||||||
if (hits.length === 0) {
|
if (hits.length === 0) {
|
||||||
return `No matches for "${query}" across ${snapshot.courses.length} course(s).\n\n${note}`;
|
return `No matches for "${query}" across ${snapshot.courses.length} course(s).\n\n${note}`;
|
||||||
|
|||||||
222
src/mcp/tools/submissions.ts
Normal file
222
src/mcp/tools/submissions.ts
Normal 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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user