import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; 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 }; export function registerContentTools(server: McpServer, context: ServerContext): void { server.registerTool( 'get_course', { title: 'Get course contents', description: 'Everything inside one course: its topics ("Themen"/lessons), tasks, and column boards, in the order ' + 'shown on the course page. Returns ids for each, which get_board, get_lesson and get_task take. ' + 'Most teaching material lives on column boards.', inputSchema: { courseId: z.string().describe('Course id from list_courses or get_dashboard.'), }, annotations: READ_ONLY, }, async ({ courseId }) => { try { const board = await context.client.getCourseBoard(courseId); return text(formatCourseBoard(board)); } catch (error) { return toToolError(error, `read course ${courseId}`); } }, ); server.registerTool( 'get_board', { title: 'Get column board', description: 'The full contents of a column board: every column, card, text block, link and attached file, with ' + 'file ids ready for download_file. This is where course material actually lives — prefer it over ' + 'poking at cards individually.', inputSchema: { boardId: z.string().describe('Board id, from get_course.'), includeFiles: z .boolean() .default(true) .describe('Resolve attachments to real file records. Turn off for a faster structure-only view.'), }, annotations: READ_ONLY, }, async ({ boardId, includeFiles }) => { try { const schoolId = await context.schoolId(); const board = await assembleBoard(context.client, boardId, schoolId, { resolveFiles: includeFiles }); return text(formatBoard(board, includeFiles)); } catch (error) { return toToolError(error, `read board ${boardId}`); } }, ); server.registerTool( 'get_lesson', { title: 'Get lesson', description: 'One topic/lesson ("Thema") from a course: its text sections, linked materials, attached files and ' + 'the tasks that belong to it. Lessons are the older content format; newer courses use column boards.', inputSchema: { lessonId: z.string().describe('Lesson id, from get_course.'), }, annotations: READ_ONLY, }, async ({ lessonId }) => { try { const schoolId = await context.schoolId(); const [lesson, tasks, files] = await Promise.all([ context.client.getLesson(lessonId), context.client.getLessonTasks(lessonId).catch(() => undefined), context.client .listFiles({ storageLocationId: schoolId, parentType: 'lessons', parentId: lessonId }) .catch(() => undefined), ]); return text(formatLesson(lesson, tasks?.data ?? [], files?.data ?? [])); } catch (error) { return toToolError(error, `read lesson ${lessonId}`); } }, ); server.registerTool( 'get_task', { title: 'Get task', description: '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.'), }, annotations: READ_ONLY, }, async ({ taskId, courseId }) => { try { const schoolId = await context.schoolId(); const found = await findTask(context, taskId, courseId); if (!found) { return failure( `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, 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}`); } }, ); } // --- task lookup ------------------------------------------------------- /** * Finds a task by id. * * There is no `GET /tasks/{id}`, and the list endpoints omit `description`, * which is only present on the course page's task element. So: use the lists * to learn which course the task belongs to (unless told), then read the * description off that course's page. */ async function findTask(context: ServerContext, taskId: string, courseId?: string): Promise { if (courseId) { const fromCourse = await taskFromCourse(context, courseId, taskId); if (fromCourse) return fromCourse; } const [open, finished] = await Promise.all([ context.client.listTasks({ limit: 99 }).catch(() => undefined), context.client.listFinishedTasks({ limit: 99 }).catch(() => undefined), ]); const listed = [...(open?.data ?? []), ...(finished?.data ?? [])].find((task) => task.id === taskId); 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( context: ServerContext, courseId: string, taskId: string, ): Promise { const board = await context.client.getCourseBoard(courseId).catch(() => undefined); if (!board) return undefined; for (const element of board.elements) { if (element.type === 'task' && element.content.id === taskId) { return { ...element.content, courseId, courseName: element.content.courseName ?? board.title }; } } return undefined; } // --- formatting -------------------------------------------------------- function formatCourseBoard(board: CourseBoardResponse): string { const boards: string[] = []; const lessons: string[] = []; const tasks: string[] = []; for (const element of board.elements) { if (element.type === 'column-board') { boards.push(`- **${element.content.title}** (\`${element.content.id}\`)`); } else if (element.type === 'lesson') { const taskCount = element.content.numberOfPublishedTasks ? ` — ${element.content.numberOfPublishedTasks} task(s)` : ''; const hidden = element.content.hidden ? ' [hidden]' : ''; lessons.push(`- **${element.content.name}** (\`${element.content.id}\`)${taskCount}${hidden}`); } else if (element.type === 'task') { const status = element.content.status.submitted > 0 ? 'submitted' : 'not submitted'; tasks.push(`- **${element.content.name}** (\`${element.content.id}\`) — ${dueLabel(element.content.dueDate)}, ${status}`); } } if (boards.length + lessons.length + tasks.length === 0) { return `${heading(2, board.title)}\n\nThis course page is empty.`; } return joinSections([ heading(2, board.title), `Course id: \`${board.roomId}\``, boards.length > 0 && joinSections([heading(3, `Boards (${boards.length})`), boards.join('\n'), 'Read one with get_board.']), lessons.length > 0 && joinSections([heading(3, `Topics (${lessons.length})`), lessons.join('\n'), 'Read one with get_lesson.']), tasks.length > 0 && joinSections([heading(3, `Tasks (${tasks.length})`), tasks.join('\n'), 'Read one with get_task.']), ]); } function formatBoard(board: AssembledBoard, includeFiles: boolean): string { const columns = board.columns.map((column) => { const cards = column.cards.map((card) => { const body = card.elements .map((element) => formatElement(element, includeFiles)) .filter(Boolean) .join('\n'); return joinSections([heading(4, card.title), body || '_(empty card)_']); }); return joinSections([heading(3, column.title), cards.length > 0 ? cards.join('\n\n') : '_(no cards)_']); }); const summary = `Board id: \`${board.id}\`` + (board.context ? ` — in ${board.context.type} \`${board.context.id}\`` : '') + (includeFiles ? ` — ${board.fileCount} attached file(s)` : ''); return joinSections([ heading(2, board.title), summary, columns.length > 0 ? columns.join('\n\n') : '_(no columns)_', includeFiles && board.fileCount > 0 ? 'Read any attachment with download_file using its file id.' : undefined, ]); } function formatElement(element: AssembledElement, includeFiles: boolean): string { switch (element.type) { case 'richText': { const body = htmlToText(element.text); return body ? body : ''; } case 'link': { const label = element.text?.trim(); return element.url ? `- Link: ${label && label !== element.url ? `${label} — ${element.url}` : element.url}` : ''; } case 'file': case 'fileFolder': case 'drawing': { const caption = element.text ? ` — caption: ${element.text}` : ''; if (!includeFiles) return `- ${element.type} element \`${element.id}\`${caption}`; if (element.fileError) return `- ${element.type} element \`${element.id}\` — could not list files (${element.fileError})`; if (element.files.length === 0) return `- ${element.type} element \`${element.id}\` — no files${caption}`; return element.files.map((file) => `- ${formatFileLine(file)}${caption}`).join('\n'); } case 'collaborativeTextEditor': return `- Collaborative text document \`${element.id}\`${element.text ? ` — ${element.text}` : ''} (contents not available through the API)`; case 'externalTool': return `- External tool${element.text ? `: ${element.text}` : ''} \`${element.id}\``; case 'videoConference': return `- Video conference \`${element.id}\``; case 'h5p': return `- H5P interactive content \`${element.id}\``; case 'deleted': return '- _(deleted element)_'; default: return `- ${element.type} element \`${element.id}\``; } } export function formatFileLine(file: FileRecord): string { const blocked = file.securityCheckStatus === 'blocked' ? ' **[virus scan: blocked]**' : ''; const pending = file.securityCheckStatus === 'pending' ? ' _[virus scan pending]_' : ''; return `File: **${file.name}** (\`${file.id}\`, ${file.mimeType}, ${formatBytes(file.size)})${blocked}${pending}`; } function formatLesson(lesson: LessonResponse, tasks: TaskContent[], files: FileRecord[]): string { const sections = (lesson.contents ?? []).map((entry) => { const title = entry.title?.trim(); const component = entry.component ?? 'unknown'; const hidden = entry.hidden ? ' [hidden]' : ''; const body = formatLessonComponent(component, entry.content ?? {}); return joinSections([heading(4, `${title || component}${hidden}`), body || `_(${component} content, nothing to show)_`]); }); const materials = (lesson.materials ?? []).map((material) => { const id = normalizeObjectId(material.id); return `- ${material.title ?? 'Untitled material'}${material.url ? ` — ${material.url}` : ''}${id ? ` (\`${id}\`)` : ''}`; }); return joinSections([ heading(2, lesson.name), `Lesson id: \`${lesson.id}\` — in course \`${lesson.courseId}\`${lesson.hidden ? ' — hidden' : ''}`, sections.length > 0 ? joinSections([heading(3, 'Contents'), sections.join('\n\n')]) : '_(no text contents)_', materials.length > 0 && joinSections([heading(3, 'Linked materials'), materials.join('\n')]), files.length > 0 && joinSections([heading(3, `Attached files (${files.length})`), files.map((file) => `- ${formatFileLine(file)}`).join('\n')]), tasks.length > 0 && joinSections([ heading(3, `Tasks in this lesson (${tasks.length})`), tasks.map((task) => `- **${task.name}** (\`${task.id}\`) — ${dueLabel(task.dueDate)}`).join('\n'), ]), ]); } function formatLessonComponent(component: string, content: Record): string { if (component === 'text' && typeof content.text === 'string') return htmlToText(content.text); if (component === 'resources' && Array.isArray(content.resources)) { return content.resources .map((resource) => { const entry = resource as { title?: string; url?: string; description?: string }; return `- ${entry.title ?? 'Resource'}${entry.url ? ` — ${entry.url}` : ''}`; }) .join('\n'); } if (typeof content.url === 'string') return `- ${content.url}`; if (typeof content.title === 'string') return content.title; return ''; } function formatTask(task: TaskContent, files: FileRecord[], submission?: string): string { const description = htmlToText(task.description); return joinSections([ heading(2, task.name), [ `- Task id: \`${task.id}\``, task.courseName ? `- Course: ${task.courseName}${task.courseId ? ` (\`${task.courseId}\`)` : ''}` : undefined, task.lessonName ? `- Topic: ${task.lessonName}` : undefined, `- Available from: ${formatDate(task.availableDate)}`, `- Due: ${dueLabel(task.dueDate)}`, `- Submitted: ${task.status.submitted}/${task.status.maxSubmissions}${task.status.graded > 0 ? ', graded' : ''}`, ] .filter(Boolean) .join('\n'), description ? joinSections([heading(3, 'Description'), description]) : '_(no description)_', files.length > 0 ? joinSections([ heading(3, `Attached files (${files.length})`), files.map((file) => `- ${formatFileLine(file)}`).join('\n'), 'Read one with download_file.', ]) : undefined, submission, ]); }