Schulcloud says what was uploaded and WebUntis says what was scheduled. Neither says what was *taught* — which point the teacher laboured, which example landed, what "will definitely come up". That lives in two places this server could not reach: the notes the user takes in the lesson, and WebUntis' class register. Notes are a directory of Markdown files (NOTES_DIR), not a table. They have to be writable from a phone in a classroom, readable when Postgres is down, and outlive this project, and files are the only shape that is all three — so the files are the truth and the index is a view of them, the same split as file_texts and the mirror. list_notes and get_note read disk, so they answer before the first crawl; search, what_changed and all three German prompts read them alongside the Schulcloud material. add_note writes one, and is the only thing in this server that writes anything. That is not a hole in the read-only invariant but a different store: it is bounded to NOTES_DIR by the same safeComponent/resolveWithin pair that stops a hostile Schulcloud filename escaping the mirror, so a note titled ../../.ssh/authorized_keys becomes a filename. Schulcloud and WebUntis stay GET-only and allowlisted respectively. NOTES_READONLY refuses writes outright. Appending targets the *lesson*, not the title: "halt das auch noch fest" mid-lesson carries a new title, and deriving the path from it would start a second note every time, which is the one thing append exists to prevent. Notes.app has no export — its bodies are compressed protobuf and the iCloud copy is encrypted — so scripting the app is not the clumsy route to the notes but the only one. scripts/export-apple-notes.js reads them through AppleScript into one JSON object per line, and `schulcloud note import` converts the HTML to Markdown, takes the Notes folder as the subject and the *creation* date as the lesson's date. Attachments cannot come across; a note that was a photo of the board imports as a line saying so, because importing it empty would hide the loss. The class register needed one API property to become cheap: getLessonTopic2017 answers per *series*, not per period, so a term is reconstructed by asking about the latest period of each lesson series and merging back by id — a few dozen calls for a school year rather than one per lesson. untis_lesson_topics now takes a subject as well as a period id, and UNTIS_HISTORY_DAYS of register goes into the index under a kind of its own, so "what did we actually do before the test" is searchable. Sharing the snapshot rather than duplicating it caught one thing on the way: the search tool's live path had to learn notes too, or fresh=true would have quietly disagreed with the index. 305 tests; 88/89 smoke against the local instance, the one failure being the H5P service that instance does not run. The live smoke could not be retaken: that session has lapsed and needs a fresh jwt cookie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
105 lines
6.6 KiB
TypeScript
105 lines
6.6 KiB
TypeScript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
import type { Config } from '../config.ts';
|
|
import { ServerContext } from '../context.ts';
|
|
import type { Services } from '../services.ts';
|
|
import { registerPrompts } from './prompts.ts';
|
|
import { registerResources } from './resources.ts';
|
|
import { registerContentTools } from './tools/content.ts';
|
|
import { registerFileTools } from './tools/files.ts';
|
|
import { registerFilesystemTools } from './tools/filesystem.ts';
|
|
import { registerH5pTools } from './tools/h5p.ts';
|
|
import { registerOverviewTools } from './tools/overview.ts';
|
|
import { registerRawTool } from './tools/raw.ts';
|
|
import { registerIndexTools } from './tools/index-tools.ts';
|
|
import { registerNoteTools } from './tools/notes.ts';
|
|
import { registerSearchTool } from './tools/search.ts';
|
|
import { registerRoomTools } from './tools/rooms.ts';
|
|
import { registerSubmissionTools } from './tools/submissions.ts';
|
|
import { registerUntisTools } from './tools/untis.ts';
|
|
|
|
export const SERVER_NAME = 'schulcloud-mcp';
|
|
export const SERVER_VERSION = '0.1.0';
|
|
|
|
const INSTRUCTIONS = `Read-only access to a Schulcloud (HPI Schul-Cloud / Schulcloud-Verbund-Software) account.
|
|
|
|
How the content is organised, and the usual path through it:
|
|
|
|
- **Courses** ("Kurse") are the top level — list_courses, or get_dashboard for the ones the user has pinned.
|
|
- A course page (get_course) holds three kinds of thing:
|
|
- **Column boards** — where most current teaching material lives. get_board returns every column, card,
|
|
text block, link and attached file in one call.
|
|
- **Topics / lessons** ("Themen") — the older format. get_lesson.
|
|
- **Tasks** ("Aufgaben") — homework. list_tasks across all courses, get_task for one.
|
|
- **Quizzes and interactive exercises** are H5P elements on a board ("Quiz", "Test", "Übung"). get_board names
|
|
one and says how many questions it holds; get_h5p returns all of them with the correct options marked — the
|
|
player steps through one at a time, this does not. search finds their question text too.
|
|
- **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.
|
|
- **The file manager ("Dateien")** is a separate store with a real folder tree, browsed with the fs_* tools:
|
|
/my (Persönliche Dateien), /courses/<course> (Kurs-Dateien), /teams/<team> (Team-Dateien) and /shared
|
|
(Geteilte Dateien). **Many teachers put their material only here**, so when a course page looks empty or the
|
|
worksheets are not on its boards, look in /courses/<course name>. fs_list and fs_tree browse, fs_find finds by
|
|
name, fs_read opens a file. list_files and download_file do not see these files.
|
|
- **Submissions** ("Abgaben") — what the user handed in. get_task shows that task's submission: the files,
|
|
the graded flag, the grade, what the user wrote, and the teacher's written feedback. A grade is a
|
|
percentage (0-100) or absent — there is no textual grade — and teachers often grade with the written
|
|
feedback alone, so "graded by feedback" is a complete result, not missing data. list_submissions
|
|
surveys them across tasks ("what is still ungraded?"). The written parts are read from the web page
|
|
because no API exposes them, so they can be missing even when feedback exists — if none is shown for a
|
|
graded submission, say it was not found rather than that none was given. On a teacher account these
|
|
tools report other people's submissions too.
|
|
|
|
**The user's own notes are a third source, and often the best one.** When the note tools are listed, the user
|
|
keeps notes from their lessons as Markdown files: list_notes and get_note read them, search finds them by
|
|
content, and add_note writes one. They record what a teacher said and stressed, which no upload does — so
|
|
consult them whenever the question is what was covered in class, what a topic means "the way we did it", or
|
|
what to revise for a test, and say when a note disagrees with the material. Notes are the user's own words:
|
|
quote them, do not silently correct them.
|
|
|
|
**The timetable is not in Schulcloud.** When the untis_* tools are listed, the school's schedule lives in
|
|
WebUntis and they are the only way to it: untis_timetable says which lessons a day actually holds, what was
|
|
cancelled ("Entfall"), what is a substitution ("Vertretung") and what a teacher noted on a period — announced
|
|
tests are usually in those notes. Schulcloud holds the material for those lessons, so the two go together:
|
|
take the subject from untis_timetable, then find its course with list_courses. untis_homework is the class
|
|
register's homework, which is a different list from Schulcloud's tasks; check both. untis_lesson_topics says
|
|
what previous lessons of a subject actually covered — pass it a subject to read back over a whole term, which
|
|
is the fastest way to reconstruct what a course has done. Those class-register entries are in the index too,
|
|
so search finds them beside the Schulcloud material.
|
|
|
|
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.
|
|
|
|
The user can also attach a course or room directly (resources schulcloud://courses/<id> and schulcloud://rooms/<id>).
|
|
An attached one is exactly what get_course or get_room returns, so do not fetch it again — continue from its ids.
|
|
|
|
Everything that touches Schulcloud and WebUntis is read-only: no tool here can change the school account,
|
|
hand anything in, or mark anything done. The one exception writes nowhere near them — add_note, when it is
|
|
listed, saves a file in the user's own notes directory.`;
|
|
|
|
export function createServer(config: Config, services?: Services): { server: McpServer; context: ServerContext } {
|
|
const context = new ServerContext(config, services);
|
|
const server = new McpServer(
|
|
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
{ capabilities: { tools: {}, logging: {} }, instructions: INSTRUCTIONS },
|
|
);
|
|
|
|
registerOverviewTools(server, context);
|
|
registerContentTools(server, context);
|
|
registerRoomTools(server, context);
|
|
registerFileTools(server, context);
|
|
registerFilesystemTools(server, context);
|
|
registerH5pTools(server, context);
|
|
registerSearchTool(server, context);
|
|
registerSubmissionTools(server, context);
|
|
registerIndexTools(server, context);
|
|
// Only when a key is configured: the tools are not offered at all otherwise.
|
|
registerUntisTools(server, context);
|
|
// Same rule, for NOTES_DIR.
|
|
registerNoteTools(server, context);
|
|
registerRawTool(server, context);
|
|
registerResources(server, context);
|
|
registerPrompts(server, context);
|
|
|
|
return { server, context };
|
|
}
|