diff --git a/CLAUDE.md b/CLAUDE.md index 12e9c03..f12e661 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,8 +39,8 @@ index. read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`, `src/mcp/` or `src/http/` — the unit tests cover only pure functions. -Run smoke **both ways**: with `DATABASE_URL` set (57 checks, index-backed) and -without (55 checks, live-only). The degradation path is a supported mode, not a +Run smoke **both ways**: with `DATABASE_URL` set (69 checks, index-backed) and +without (67 checks, live-only). The degradation path is a supported mode, not a fallback nobody exercises. Store tests need a database and skip without one: @@ -53,7 +53,7 @@ once put fixtures into real data. ## Architecture ``` -bin/{http,stdio}.ts ─┬─ mcp/server.ts ── mcp/tools/* +bin/{http,stdio}.ts ─┬─ mcp/server.ts ── mcp/tools/*, mcp/{resources,prompts}.ts └─ http/{server,api,auth}.ts /mcp and /api │ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync}.ts @@ -86,6 +86,10 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync - **`mcp/tools/*.ts`** — tool descriptions are prompts: they are how Claude picks a tool, so they carry the German domain terms (Kurse, Themen, Aufgaben) and say when *not* to use the tool. +- **`mcp/resources.ts`, `mcp/prompts.ts`** — courses and rooms as resources a + person attaches, carrying exactly `readCourse`/`readRoom`, the functions behind + `get_course`/`get_room`; and two prompts. What people read in a picker (labels, + prompt texts) is German; what the model reads stays English. - **`context.ts`** — per-session state. Only `/me` is cached, because the school id is on every files-storage path and cannot change for a token. @@ -290,6 +294,26 @@ These cost real time to discover; `docs/API.md` has the full list with evidence. saying what is unavailable and what still works. 5. Add a check to `scripts/smoke.mjs` and run `npm run smoke` both ways. +## Resources and prompts + +Claude Code is the client these are tested in, and it shapes them. Read from its +bundle (2.1.272), not its docs: + +- **A prompt command's arguments are split on whitespace, and extra words are + dropped** (`zipObject(argNames, input.split(/\s+/))`). A value of several words + can only arrive joined, so `argumentText` turns `_` back into spaces and reads + `-` as "skipped"; course names match by fragments (`resolveTarget`). Any new + free-text argument needs the same treatment. +- **The @ autocomplete fuzzy-matches `name` but displays `description`**, falling + back to the name only when there is none — a description must carry the name. +- **An @-mention resolves only URIs from `resources/list`**; a template alone + cannot be mentioned. `McpServer` returns every template's listing in one reply + and ignores cursors, which suits a few dozen entries and not the file manager. +- **Throw `ProtocolError`, not `McpError`**: McpError prefixes its message with + "MCP error :", the client prefixes it again, and people read both. +- A resource listing that fails yields no entries rather than an error: one + refused kind would otherwise cost the whole reply. + ## Environment `.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`. See `.env.example` diff --git a/README.md b/README.md index 1f56122..0cb8375 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,23 @@ files and returns **images inline** for Claude to look at. Image-only PDFs — scans with no text layer, which are common in this account — are reported as such rather than as an empty result. +**Attach a course instead of asking for it.** Every course and room is also an +MCP resource (`schulcloud://courses/`, `schulcloud://rooms/`) holding +the same overview `get_course` and `get_room` return. In Claude Code, type `@` +and part of the course name. + +**Two ready-made prompts**, in German because the school is: + +| | | +|---|---| +| `zusammenfassung` *kurs* [*fokus*] | summarise a course or room: topics, tasks, key material, each with its source | +| `pruefungsvorbereitung` *kurs* [*thema*] [*datum*] | prepare for an exam: scope, explanations, practice questions, a study plan | + +In Claude Code they run as `/mcp__schulcloud__zusammenfassung Mathe_10b`. +Claude Code splits arguments on spaces and drops extra words, so join words +with `_` (`Lineare_Funktionen`) and skip an optional argument with `-`. *kurs* +is any unambiguous part of a course or room name, or its id. + ## The CLI ```bash @@ -130,7 +147,7 @@ src/ core/ client, types, board assembly, crawler, extraction, paths store/ Postgres: crawl generations, diffs, full-text search indexer/ crawl → persist → mirror bytes → extract text → index - mcp/ MCP server and tools + mcp/ MCP server, tools, resources and prompts http/ express app, bearer auth, /api for the CLI cli/ CLI config, API client, sync engine bin/ http, stdio and cli entry points @@ -157,9 +174,9 @@ npm run typecheck ``` `npm run smoke` starts the HTTP server, connects a real MCP client over -Streamable HTTP and exercises every tool against the live account — 55 checks (57 with the index) +Streamable HTTP and exercises every tool against the live account — 67 checks (69 with the index) covering the auth gate, the protocol handshake, every content chain, file -extraction, `api_get`'s guard rails and error handling. +extraction, resources and prompts, `api_get`'s guard rails and error handling. ## Upstream diff --git a/docs/LOCAL.md b/docs/LOCAL.md index 4acf3fc..8880eb7 100644 --- a/docs/LOCAL.md +++ b/docs/LOCAL.md @@ -105,7 +105,7 @@ node dist/bin/cli.js sync ```bash npm test # 135 offline tests npm run smoke # end-to-end against the live instance, live-only mode -DATABASE_URL=… npm run smoke # end-to-end with the index (57 checks) +DATABASE_URL=… npm run smoke # end-to-end with the index (69 checks) ``` Store tests need a database and skip without one: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4c113de..72f3148 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -98,8 +98,12 @@ Genuinely unavailable, not merely uncovered: - **Video/audio transcription** — this account has 5 MP4s and a WebM that are currently just "here is a file you cannot read". -- **MCP resources and prompts** — expose courses/boards as attachable resources; - canned prompts such as "summarise this week's homework". +- **MCP resources and prompts — partly built.** Courses and rooms are resources, + and there are two German prompts (`zusammenfassung`, `pruefungsvorbereitung`). + Still open: files as resources — about a thousand of them do not fit a picker + that lists every entry, so they need a browsing UI (an MCP App) rather than a + longer list — and claude.ai, which cannot reach a server on localhost and does + not send a static bearer token. - **Calendar** — a separate `schulcloud-calendar` service, not part of the v3 API mapped in `API.md`. diff --git a/local-instance/README.md b/local-instance/README.md index 6355f51..c8f5956 100644 --- a/local-instance/README.md +++ b/local-instance/README.md @@ -217,7 +217,7 @@ be pointed at the instance in between: ```bash eval "$(./scripts/mcp-env.sh)" # as the demo student -cd .. && npm run smoke # 57 checks against the local instance +cd .. && npm run smoke # 69 checks against the local instance ``` `mcp-env.sh` points the index at its own database, `schulcloud_local`, and the diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 7550313..4492f94 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -303,6 +303,100 @@ console.log('\n== rooms =='); } } +console.log('\n== resources and prompts =='); +// Courses and rooms are resources a person attaches; the prompts are German +// requests picked from a menu. Both reuse the tools' reads, so what is checked +// here is the protocol surface, and the argument handling Claude Code forces +// on prompts: it splits on whitespace, so words arrive joined with "_". +{ + const { resources } = await client.listResources(); + const courseResources = resources.filter((r) => r.uri.startsWith('schulcloud://courses/')); + const roomResources = resources.filter((r) => r.uri.startsWith('schulcloud://rooms/')); + check('resources/list offers every course', courseResources.length === courseIds.length, `${courseResources.length} of ${courseIds.length}`); + const roomsListed = await call('list_rooms'); + const roomCount = [...roomsListed.text.matchAll(/\(`([0-9a-f]{24})`\)/g)].length; + check('resources/list offers every room', roomResources.length === roomCount, `${roomResources.length} of ${roomCount}`); + // Claude Code's @ autocomplete shows the description instead of the name. + check( + 'every resource description carries its name', + resources.length > 0 && resources.every((r) => r.name && r.mimeType === 'text/markdown' && r.description?.endsWith(r.name)), + ); + const { resourceTemplates } = await client.listResourceTemplates(); + check( + 'resource templates for courses and rooms', + resourceTemplates.map((t) => t.uriTemplate).sort().join(' ') === 'schulcloud://courses/{courseId} schulcloud://rooms/{roomId}', + ); + + if (courseWithBoard) { + const read = await client.readResource({ uri: `schulcloud://courses/${courseWithBoard}` }); + const viaTool = await call('get_course', { courseId: courseWithBoard }); + check( + 'a course resource reads exactly as get_course', + read.contents[0]?.mimeType === 'text/markdown' && read.contents[0]?.text === viaTool.text, + read.contents[0]?.text?.split('\n')[0], + ); + } + if (roomResources[0]) { + const room = await client.readResource({ uri: roomResources[0].uri }); + check('a room resource opens', /Room id:/.test(room.contents[0]?.text ?? ''), roomResources[0].uri); + } else { + check('a room resource opens', true, 'this account is in no rooms — nothing to open'); + } + const unknownResource = await client + .readResource({ uri: 'schulcloud://courses/000000000000000000000000' }) + .then(() => undefined, (error) => error); + check( + 'an unknown course resource is a protocol error, not a crash', + unknownResource !== undefined, + unknownResource?.message?.split('\n')[0], + ); + + const { prompts } = await client.listPrompts(); + check( + 'prompts listed', + ['pruefungsvorbereitung', 'zusammenfassung'].every((name) => prompts.some((p) => p.name === name)), + prompts.map((p) => p.name).join(', '), + ); + const courseTitle = courseWithBoard + ? courses.text.match(new RegExp(`- \\*\\*(.+?)\\*\\* \\(\`${courseWithBoard}\`\\)`))?.[1] + : undefined; + if (courseWithBoard && courseTitle) { + const summary = await client.getPrompt({ + name: 'zusammenfassung', + arguments: { kurs: courseTitle.split(/\s+/).join('_') }, + }); + const [embedded, instructions] = summary.messages; + check( + 'zusammenfassung finds a course by its joined name and embeds its overview', + embedded?.content.type === 'resource' && embedded.content.resource.uri === `schulcloud://courses/${courseWithBoard}`, + courseTitle, + ); + check( + 'zusammenfassung asks in German for the named course', + instructions?.content.type === 'text' && + instructions.content.text.includes(`„${courseTitle}“`) && + /Antworte auf Deutsch/.test(instructions.content.text), + ); + const exam = await client.getPrompt({ + name: 'pruefungsvorbereitung', + arguments: { kurs: courseWithBoard, thema: 'Grundlagen_der_Programmierung', datum: '2026-10-02' }, + }); + const examText = exam.messages[1]?.content.type === 'text' ? exam.messages[1].content.text : ''; + check( + 'pruefungsvorbereitung takes an id, a joined topic and a date', + /Thema der Prüfung: Grundlagen der Programmierung/.test(examText) && /Prüfungstermin: 2026-10-02/.test(examText), + ); + } + const refused = await client + .getPrompt({ name: 'zusammenfassung', arguments: { kurs: 'kein_solcher_kurs_xyz' } }) + .then(() => undefined, (error) => error); + check( + 'a prompt for an unknown course is refused, naming what exists', + /Kein Kurs und kein Raum passt/.test(refused?.message ?? ''), + refused?.message?.slice(0, 100), + ); +} + console.log('\n== search =='); const searchTerm = process.env.SMOKE_SEARCH ?? 'Datenschutz'; const search = await call('search', { query: searchTerm, fresh: true, courseId: courseIds[0] }); diff --git a/src/mcp/prompts.ts b/src/mcp/prompts.ts new file mode 100644 index 0000000..6badabd --- /dev/null +++ b/src/mcp/prompts.ts @@ -0,0 +1,307 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ErrorCode, type GetPromptResult } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import type { ServerContext } from '../context.ts'; +import { fold, joinSections, matchesAll, tokenize } from '../core/text.ts'; +import { courseUri, roomUri } from './resources.ts'; +import { readCourse } from './tools/content.ts'; +import { readRoom } from './tools/rooms.ts'; +import { ProtocolError, toProtocolError } from './tools/result.ts'; + +/** + * Prompts: ready-made requests a person picks from a menu, written in German + * because the people using them are at a German school. + * + * Each embeds the course's overview as a resource, so Claude starts from the + * real structure and ids instead of a name it has to look up first, and each + * says where material hides and what cannot be read — the file manager, scans + * and drafts — which is otherwise learnt one failed tool call at a time. + */ + +export interface Target { + kind: 'course' | 'room'; + id: string; + name: string; +} + +const COURSE_ARGUMENT = z + .string() + .describe('Kurs oder Raum: ein eindeutiger Teil des Namens oder die ID. Mehrere Wörter mit _ verbinden, z. B. Mathe_10b.'); + +export function registerPrompts(server: McpServer, context: ServerContext): void { + server.registerPrompt( + 'zusammenfassung', + { + title: 'Kurs zusammenfassen', + description: + 'Fasst einen Kurs oder Raum aus der Schulcloud zusammen: Themen, Aufgaben und die wichtigsten ' + + 'Materialien, jeweils mit Quelle.', + argsSchema: { + kurs: COURSE_ARGUMENT, + fokus: z + .string() + .optional() + .describe('Optional: worauf die Zusammenfassung eingehen soll, z. B. ein Thema. Mehrere Wörter mit _ verbinden.'), + }, + }, + async ({ kurs, fokus }) => { + const target = await findTarget(context, kurs); + return withOverview(context, target, 'Zusammenfassung', summaryPrompt(target, argumentText(fokus))); + }, + ); + + server.registerPrompt( + 'pruefungsvorbereitung', + { + title: 'Prüfungsvorbereitung', + description: + 'Hilft bei der Vorbereitung auf eine Prüfung: Prüfungsstoff, Erklärungen, Übungsfragen und ein ' + + 'Lernplan, auf Grundlage des Kursmaterials und des Feedbacks zu den eigenen Abgaben.', + argsSchema: { + kurs: COURSE_ARGUMENT, + thema: z + .string() + .optional() + .describe('Optional: Thema oder Stoff der Prüfung. Mehrere Wörter mit _ verbinden; ein - lässt es aus.'), + datum: z + .string() + .optional() + .describe('Optional: Tag der Prüfung, z. B. 2026-10-02. Dann gibt es einen Lernplan bis dahin.'), + }, + }, + async ({ kurs, thema, datum }) => { + const target = await findTarget(context, kurs); + const text = examPrompt(target, { + topic: argumentText(thema), + date: argumentText(datum), + today: germanDate(new Date()), + }); + return withOverview(context, target, 'Prüfungsvorbereitung', text); + }, + ); +} + +// --- arguments ----------------------------------------------------------- + +/** + * An argument as a person typed it, or undefined when left out. + * + * Claude Code splits a prompt command on whitespace and drops the words that + * do not fit a named argument, so a value of several words can only arrive + * joined — `Erbrecht_und_Testament` — and a later argument can only be reached + * by filling the earlier ones, which is what `-` is for. + */ +export function argumentText(value: string | undefined): string | undefined { + const cleaned = value?.replace(/_+/g, ' ').replace(/\s+/g, ' ').trim(); + return cleaned && cleaned !== '-' ? cleaned : undefined; +} + +/** + * Picks the course or room a person meant. + * + * Matching is `search`'s — case- and umlaut-insensitive, every word must occur + * — so `lf07` finds "LF07 - FIA24A/B - Sb/Ha" without anyone typing the slashes. + * Looser readings only apply when a stricter one found nothing, which is what + * keeps real course names choosable: `LF1` must not be ambiguous merely because + * LF10 and LF12 exist, and "LF 11" is still found as `LF11`, since teachers + * space the same codes differently. Anything that is not a single match is + * refused with the candidates, because summarising the wrong course is worse + * than asking again. + */ +export function resolveTarget(query: string, candidates: Target[]): Target { + const wanted = query.trim(); + const byId = candidates.find((candidate) => candidate.id === wanted); + if (byId) return byId; + + const shown = argumentText(wanted) ?? wanted; + const terms = tokenize(wanted); + if (terms.length === 0) { + throw new ProtocolError(ErrorCode.InvalidParams, 'Gib einen Kurs oder Raum an: einen Teil des Namens oder die ID.'); + } + + const readings: ((name: string) => boolean)[] = [ + // the whole name, word for word + (name) => tokenize(name).join(' ') === terms.join(' '), + // every word as a whole word + (name) => terms.every((term) => tokenize(name).includes(term)), + // every word as part of a word + (name) => matchesAll(name, terms), + // every word, ignoring the spaces and punctuation inside the name + (name) => terms.every((term) => fold(name).replace(/[^\p{L}\p{N}]+/gu, '').includes(term)), + ]; + for (const reading of readings) { + const matches = candidates.filter((candidate) => reading(candidate.name)); + if (matches.length === 1) return matches[0]!; + if (matches.length > 1) { + const listed = matches + .slice(0, 10) + .map((candidate) => `${candidate.name} (${kindLabel(candidate)}, ID ${candidate.id})`) + .join('; '); + throw new ProtocolError( + ErrorCode.InvalidParams, + `„${shown}“ passt auf ${matches.length} Einträge: ${listed}${matches.length > 10 ? '; …' : ''}. ` + + 'Gib mehr vom Namen an (Wörter mit _ verbinden) oder die ID.', + ); + } + } + + const available = candidates.map((candidate) => `${candidate.name} (${kindLabel(candidate)})`).join('; '); + throw new ProtocolError( + ErrorCode.InvalidParams, + `Kein Kurs und kein Raum passt zu „${shown}“.${available ? ` Vorhanden: ${available}.` : ''}`, + ); +} + +function kindLabel(target: Target): string { + return target.kind === 'course' ? 'Kurs' : 'Raum'; +} + +async function findTarget(context: ServerContext, query: string): Promise { + let candidates: Target[]; + try { + const [courses, rooms] = await Promise.all([ + context.client.listAllCourses(), + // Rooms are optional here as everywhere: an account in none, or an + // instance that refuses the route, must not cost the course lookup. + context.client.listRooms().catch(() => []), + ]); + candidates = [ + ...courses.map((course): Target => ({ kind: 'course', id: course.id, name: course.title })), + ...rooms.map((room): Target => ({ kind: 'room', id: room.id, name: room.name })), + ]; + } catch (error) { + throw toProtocolError(error, 'list courses'); + } + return resolveTarget(query, candidates); +} + +async function withOverview( + context: ServerContext, + target: Target, + title: string, + instructions: string, +): Promise { + const course = target.kind === 'course'; + let overview: string; + try { + overview = course ? await readCourse(context, target.id) : await readRoom(context, target.id); + } catch (error) { + throw toProtocolError(error, `read ${target.kind} ${target.id}`); + } + return { + description: `${title}: ${target.name}`, + messages: [ + { + role: 'user', + content: { + type: 'resource', + resource: { uri: course ? courseUri(target.id) : roomUri(target.id), mimeType: 'text/markdown', text: overview }, + }, + }, + { role: 'user', content: { type: 'text', text: instructions } }, + ], + }; +} + +// --- prompt texts ---------------------------------------------------------- + +const UNREADABLE = + 'Eingescannte PDFs ohne Textebene und noch nicht veröffentlichte Boards kannst du nicht lesen. ' + + 'Sag, was dir dadurch fehlt, statt es stillschweigend zu übergehen.'; + +export function summaryPrompt(target: Target, focus?: string): string { + const course = target.kind === 'course'; + return joinSections([ + `Fasse ${course ? 'den Kurs' : 'den Raum'} „${target.name}“ für mich zusammen. Die Übersicht aus der Schulcloud ist angehängt.`, + `So gehst du vor:\n${numbered([ + course + ? 'Lies das Material hinter der Übersicht: die Boards mit get_board, die Themen mit get_lesson und die Aufgaben mit get_task.' + : 'Lies die Boards des Raums mit get_board.', + course && + `Sieh dir auch die Kurs-Dateien an (fs_tree mit dem Pfad "/courses/${target.id}") und lies die aussagekräftigsten ` + + 'Dateien mit fs_read. Viele Lehrkräfte legen ihr Material nur dort ab, dann wirkt die Kursseite fast leer.', + 'Wenn es sehr viel Material gibt, lies zuerst das Neueste und das, was einen Überblick gibt (Arbeitsblätter, ' + + 'Präsentationen, Zusammenfassungen), und sag mir, was du ausgelassen hast.', + focus && `Konzentriere dich auf: ${focus}.`, + ])}`, + `Die Zusammenfassung enthält:\n${bulleted([ + `**Worum es geht:** Ziel und Inhalt ${course ? 'des Kurses' : 'des Raums'} in zwei, drei Sätzen.`, + '**Themen:** die behandelten Themen, möglichst in der Reihenfolge des Unterrichts, jeweils mit den wichtigsten ' + + 'Inhalten und Fachbegriffen.', + course && '**Aufgaben:** was zu erledigen war oder ist, mit Fälligkeit, ob ich abgegeben habe und wie es bewertet wurde.', + '**Wichtige Materialien:** die Boards und Dateien, die man kennen sollte, mit Namen, damit ich sie wiederfinde.', + '**Lücken:** was fehlt, unklar ist oder nicht gelesen werden konnte.', + ])}`, + `Wichtig:\n${bulleted([ + 'Stütze dich nur auf das, was du in der Schulcloud findest, nenne jeweils die Quelle (Board, Thema, Aufgabe ' + + 'oder Datei) und erfinde nichts dazu.', + UNREADABLE, + 'Antworte auf Deutsch.', + ])}`, + ]); +} + +export function examPrompt(target: Target, options: { topic?: string; date?: string; today: string }): string { + const course = target.kind === 'course'; + return joinSections([ + `Hilf mir, mich auf eine Prüfung ${course ? 'im Kurs' : 'im Raum'} „${target.name}“ vorzubereiten. ` + + 'Die Übersicht aus der Schulcloud ist angehängt.', + options.topic + ? `Thema der Prüfung: ${options.topic}` + : 'Das Thema der Prüfung steht noch nicht fest. Leite den wahrscheinlichen Prüfungsstoff aus dem Material ab ' + + 'und gewichte die neueren Inhalte stärker.', + options.date && `Prüfungstermin: ${options.date} (heute ist ${options.today}).`, + `So gehst du vor:\n${numbered([ + course + ? 'Sammle den Stoff: Lies die passenden Boards (get_board), Themen (get_lesson) und Aufgaben (get_task).' + : 'Sammle den Stoff: Lies die passenden Boards des Raums mit get_board.', + course && + `Durchsuche auch die Kurs-Dateien (fs_tree oder fs_find mit dem Pfad "/courses/${target.id}") und lies die ` + + 'passenden Dateien mit fs_read. Viele Lehrkräfte legen ihr Material nur dort ab.', + options.topic && 'Mit search findest du das Thema auch im Text von Dateien.', + course && + 'Sieh dir meine Abgaben und das Feedback dazu an (get_task, list_submissions für diesen Kurs). Daran erkennst ' + + 'du, was ich schon kann und wo ich nacharbeiten sollte.', + ])}`, + `Erstelle daraus:\n${bulleted([ + '**Prüfungsstoff:** die Themen, die drankommen können, jeweils mit Quelle.', + '**Das Wichtigste:** Kernbegriffe, Definitionen, Zusammenhänge und Verfahren, knapp und verständlich erklärt.', + '**Typische Aufgaben:** welche Arten von Aufgaben im Unterricht vorkamen, jeweils mit einem Beispiel.', + '**Übungsfragen:** 8 bis 12 Fragen mit steigender Schwierigkeit. Die Lösungen stehen gesammelt am Ende, damit ' + + 'ich erst selbst nachdenken kann.', + `**Lernplan:** ${options.date ? 'Tag für Tag bis zur Prüfung' : 'eine sinnvolle Reihenfolge der Themen'}, mit Zeit zum Wiederholen.`, + course && '**Nacharbeiten:** Stellen, an denen Feedback oder Bewertungen Lücken zeigen, falls es welche gibt.', + ])}`, + `Wichtig:\n${bulleted([ + 'Stütze dich auf das Material aus der Schulcloud und nenne die Quellen. Was du aus eigenem Wissen ergänzt, kennzeichnest du.', + UNREADABLE, + 'Biete mir am Ende an, mich abzufragen.', + 'Antworte auf Deutsch.', + ])}`, + ]); +} + +/** "Dienstag, 15.09.2026" — with the weekday, so a plan can say "bis Freitag". */ +export function germanDate(date: Date): string { + return new Intl.DateTimeFormat('de-DE', { + weekday: 'long', + day: '2-digit', + month: '2-digit', + year: 'numeric', + timeZone: 'Europe/Berlin', + }).format(date); +} + +function numbered(items: (string | false | undefined)[]): string { + return items + .filter((item): item is string => Boolean(item)) + .map((item, index) => `${index + 1}. ${item}`) + .join('\n'); +} + +function bulleted(items: (string | false | undefined)[]): string { + return items + .filter((item): item is string => Boolean(item)) + .map((item) => `- ${item}`) + .join('\n'); +} diff --git a/src/mcp/resources.ts b/src/mcp/resources.ts new file mode 100644 index 0000000..2745dbd --- /dev/null +++ b/src/mcp/resources.ts @@ -0,0 +1,103 @@ +import { ResourceTemplate, type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { ReadResourceResult, Resource } from '@modelcontextprotocol/sdk/types.js'; +import type { ServerContext } from '../context.ts'; +import { readCourse } from './tools/content.ts'; +import { readRoom } from './tools/rooms.ts'; +import { toProtocolError } from './tools/result.ts'; + +/** + * Courses and rooms as MCP resources: things a person attaches to a message, + * where tools are things the model decides to call. + * + * A resource carries exactly what get_course or get_room returns, so an + * attached course and a fetched one read the same, and the ids in it lead to + * the same tools. Deliberately coarse: a picker lists every resource at once, + * which suits some twenty-odd courses and not the thousand-odd files. + * + * The labels are German because people read them in a picker; the content + * stays the English Markdown the tools return, since the model reads that. + */ + +const MARKDOWN = 'text/markdown'; + +export function courseUri(courseId: string): string { + return `schulcloud://courses/${courseId}`; +} + +export function roomUri(roomId: string): string { + return `schulcloud://rooms/${roomId}`; +} + +export function registerResources(server: McpServer, context: ServerContext): void { + server.registerResource( + 'course', + new ResourceTemplate(courseUri('{courseId}'), { + list: async () => ({ + resources: await listOrEmpty('courses', async () => + (await context.client.listAllCourses()).map((course) => entry(courseUri(course.id), 'Kurs', course.title)), + ), + }), + }), + { + title: 'Kurs', + description: 'Ein Kurs aus der Schulcloud: Boards, Themen, Aufgaben und Kurs-Dateien im Überblick.', + mimeType: MARKDOWN, + }, + async (uri, { courseId }) => read(uri, `read course ${courseId}`, () => readCourse(context, String(courseId))), + ); + + server.registerResource( + 'room', + new ResourceTemplate(roomUri('{roomId}'), { + list: async () => ({ + resources: await listOrEmpty('rooms', async () => + (await context.client.listRooms()).map((room) => entry(roomUri(room.id), 'Raum', room.name)), + ), + }), + }), + { + title: 'Raum', + description: 'Ein Raum aus der Schulcloud: seine Boards und wer darin ist.', + mimeType: MARKDOWN, + }, + async (uri, { roomId }) => read(uri, `read room ${roomId}`, () => readRoom(context, String(roomId))), + ); +} + +function entry(uri: string, kind: string, name: string): Resource { + return { + uri, + name, + title: name, + // Claude Code's @ autocomplete shows the description in place of the + // name, so a description without the name would leave every entry + // reading as an opaque id. + description: `${kind}: ${name}`, + mimeType: MARKDOWN, + }; +} + +/** + * A listing that fails yields no entries instead of an error. + * + * Every resource kind is listed in one `resources/list` reply, so one refused + * kind would otherwise cost the rest. A client may also treat a failed + * listing as a failed server and drop its tools with it — and the tools are + * where an expired token gets explained. + */ +async function listOrEmpty(kind: string, load: () => Promise): Promise { + try { + return await load(); + } catch (error) { + console.error(`[schulcloud-mcp] resources: listing ${kind} failed:`, toProtocolError(error, `list ${kind}`).message); + return []; + } +} + +async function read(uri: URL, action: string, render: () => Promise): Promise { + try { + return { contents: [{ uri: uri.href, mimeType: MARKDOWN, text: await render() }] }; + } catch (error) { + throw toProtocolError(error, action); + } +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 79b5244..8fe44a8 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -2,6 +2,8 @@ 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'; @@ -44,6 +46,9 @@ How the content is organised, and the usual path through it: 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/ and schulcloud://rooms/). +An attached one is exactly what get_course or get_room returns, so do not fetch it again — continue from its ids. + Everything here is read-only; nothing in this server can modify the account.`; export function createServer(config: Config, services?: Services): { server: McpServer; context: ServerContext } { @@ -62,6 +67,8 @@ export function createServer(config: Config, services?: Services): { server: Mcp registerSubmissionTools(server, context); registerIndexTools(server, context); registerRawTool(server, context); + registerResources(server, context); + registerPrompts(server, context); return { server, context }; } diff --git a/src/mcp/tools/content.ts b/src/mcp/tools/content.ts index 9447b53..416dd2f 100644 --- a/src/mcp/tools/content.ts +++ b/src/mcp/tools/content.ts @@ -39,21 +39,7 @@ export function registerContentTools(server: McpServer, context: ServerContext): }, async ({ courseId }) => { try { - const [board, legacy, courseFiles] = await Promise.all([ - context.client.getCourseBoard(courseId), - // The v3 projection carries no description, teachers, members or - // timetable; /api/v1/courses still does. Optional on purpose — it - // is a legacy route, so its absence must cost detail, not the call. - context.client.getLegacyCourse(courseId).catch(() => undefined), - // The course's file-manager area is a different store from the page. - // Teachers who only upload files there leave the page itself empty, - // and reporting "empty" then sends the reader away from the material. - context.files.list({ area: 'courses', ownerId: courseId }).catch(() => undefined), - ]); - const teachers = legacy - ? await context.resolveNames([...(legacy.teacherIds ?? []), ...(legacy.substitutionIds ?? [])]) - : { names: [], unresolved: 0 }; - return text(formatCourseBoard(board, legacy, teachers, courseFiles)); + return text(await readCourse(context, courseId)); } catch (error) { return toToolError(error, `read course ${courseId}`); } @@ -190,6 +176,28 @@ export function registerContentTools(server: McpServer, context: ServerContext): ); } +/** + * A course's overview as Markdown: what get_course returns, and what the + * course resource carries, so an attached course reads the same as a fetched one. + */ +export async function readCourse(context: ServerContext, courseId: string): Promise { + const [board, legacy, courseFiles] = await Promise.all([ + context.client.getCourseBoard(courseId), + // The v3 projection carries no description, teachers, members or + // timetable; /api/v1/courses still does. Optional on purpose — it + // is a legacy route, so its absence must cost detail, not the call. + context.client.getLegacyCourse(courseId).catch(() => undefined), + // The course's file-manager area is a different store from the page. + // Teachers who only upload files there leave the page itself empty, + // and reporting "empty" then sends the reader away from the material. + context.files.list({ area: 'courses', ownerId: courseId }).catch(() => undefined), + ]); + const teachers = legacy + ? await context.resolveNames([...(legacy.teacherIds ?? []), ...(legacy.substitutionIds ?? [])]) + : { names: [], unresolved: 0 }; + return formatCourseBoard(board, legacy, teachers, courseFiles); +} + // --- task lookup ------------------------------------------------------- /** diff --git a/src/mcp/tools/result.ts b/src/mcp/tools/result.ts index 22295ba..6a03c81 100644 --- a/src/mcp/tools/result.ts +++ b/src/mcp/tools/result.ts @@ -1,6 +1,25 @@ -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { ErrorCode, type CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { SchulcloudApiError } from '../../core/client.ts'; +/** The spec's "resource not found" code; the SDK's `ErrorCode` has no name for it. */ +const RESOURCE_NOT_FOUND = -32002; + +/** + * An error the SDK sends as-is: this code, exactly this message. + * + * Not McpError, whose constructor prefixes "MCP error :" to the message + * — the receiving client prefixes it again, and the person reading it gets both. + */ +export class ProtocolError extends Error { + readonly code: number; + + constructor(code: number, message: string) { + super(message); + this.name = 'ProtocolError'; + this.code = code; + } +} + export function text(body: string): CallToolResult { return { content: [{ type: 'text', text: body }] }; } @@ -18,25 +37,40 @@ export function failure(body: string): CallToolResult { * account genuinely lacks access, not that the call was malformed. */ export function toToolError(error: unknown, action: string): CallToolResult { + return failure(describeFailure(error, action)); +} + +/** + * The same diagnosis for a resource read or a prompt, which fail as protocol + * errors rather than as tool results. A 404 carries the spec's not-found code, + * which is what lets a client drop a stale resource instead of retrying it. + */ +export function toProtocolError(error: unknown, action: string): ProtocolError { + if (error instanceof ProtocolError) return error; + const notFound = error instanceof SchulcloudApiError && error.status === 404; + return new ProtocolError(notFound ? RESOURCE_NOT_FOUND : ErrorCode.InternalError, describeFailure(error, action)); +} + +function describeFailure(error: unknown, action: string): string { if (error instanceof SchulcloudApiError) { if (error.isAuthFailure) { - return failure( + return ( `Schulcloud rejected the token while trying to ${action} (HTTP 401).\n\n` + - `The JWT in TSC_JWT_COOKIE has expired or been revoked. Copy a fresh one from ` + - `the browser (DevTools → Application → Cookies → the "jwt" cookie) into the server's ` + - `environment and restart it. See docs/AUTH.md.`, + `The JWT in TSC_JWT_COOKIE has expired or been revoked. Copy a fresh one from ` + + `the browser (DevTools → Application → Cookies → the "jwt" cookie) into the server's ` + + `environment and restart it. See docs/AUTH.md.` ); } if (error.status === 403) { - return failure(`No permission to ${action} (HTTP 403). This account cannot see that resource.`); + return `No permission to ${action} (HTTP 403). This account cannot see that resource.`; } if (error.status === 404) { - return failure(`Not found while trying to ${action} (HTTP 404). Check the id.`); + return `Not found while trying to ${action} (HTTP 404). Check the id.`; } - return failure(`Failed to ${action}: ${error.message}`); + return `Failed to ${action}: ${error.message}`; } if (error instanceof Error && error.name === 'TimeoutError') { - return failure(`Timed out trying to ${action}. The instance may be slow or unreachable.`); + return `Timed out trying to ${action}. The instance may be slow or unreachable.`; } - return failure(`Failed to ${action}: ${error instanceof Error ? error.message : String(error)}`); + return `Failed to ${action}: ${error instanceof Error ? error.message : String(error)}`; } diff --git a/src/mcp/tools/rooms.ts b/src/mcp/tools/rooms.ts index b22af41..8d02d22 100644 --- a/src/mcp/tools/rooms.ts +++ b/src/mcp/tools/rooms.ts @@ -80,24 +80,7 @@ export function registerRoomTools(server: McpServer, context: ServerContext): vo }, async ({ roomId }) => { try { - // Members and boards are both allowed to fail without costing the room: - // a viewer may be refused the member list, and boards can be empty. - const room = await context.client.getRoom(roomId); - // Applicants and invitation links are room-admin surface. Ask only - // when this account is allowed to, so a viewer does not pay for two - // requests that can only come back 403. - const may = room.allowedOperations ?? {}; - const [boards, members, applicants, links] = await Promise.all([ - context.client.listRoomBoards(roomId).catch(() => [] as RoomBoardItem[]), - context.client.listRoomMembers(roomId).catch(() => [] as RoomMember[]), - may.manageRoomApplicants - ? context.client.listRoomApplicants(roomId).catch(() => [] as RoomApplicant[]) - : Promise.resolve([] as RoomApplicant[]), - may.listRoomInvitationLinks - ? context.client.listRoomInvitationLinks(roomId).catch(() => [] as RoomInvitationLink[]) - : Promise.resolve([] as RoomInvitationLink[]), - ]); - return text(formatRoom(room, roomId, boards, members, applicants, links)); + return text(await readRoom(context, roomId)); } catch (error) { return toToolError(error, `read room ${roomId}`); } @@ -166,6 +149,28 @@ export function registerRoomTools(server: McpServer, context: ServerContext): vo ); } +/** A room as Markdown: what get_room returns, and what the room resource carries. */ +export async function readRoom(context: ServerContext, roomId: string): Promise { + // Members and boards are both allowed to fail without costing the room: + // a viewer may be refused the member list, and boards can be empty. + const room = await context.client.getRoom(roomId); + // Applicants and invitation links are room-admin surface. Ask only + // when this account is allowed to, so a viewer does not pay for two + // requests that can only come back 403. + const may = room.allowedOperations ?? {}; + const [boards, members, applicants, links] = await Promise.all([ + context.client.listRoomBoards(roomId).catch(() => [] as RoomBoardItem[]), + context.client.listRoomMembers(roomId).catch(() => [] as RoomMember[]), + may.manageRoomApplicants + ? context.client.listRoomApplicants(roomId).catch(() => [] as RoomApplicant[]) + : Promise.resolve([] as RoomApplicant[]), + may.listRoomInvitationLinks + ? context.client.listRoomInvitationLinks(roomId).catch(() => [] as RoomInvitationLink[]) + : Promise.resolve([] as RoomInvitationLink[]), + ]); + return formatRoom(room, roomId, boards, members, applicants, links); +} + function formatRoom( room: RoomDetails, roomId: string, diff --git a/test/prompts.test.ts b/test/prompts.test.ts new file mode 100644 index 0000000..b0998aa --- /dev/null +++ b/test/prompts.test.ts @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { ErrorCode } from '@modelcontextprotocol/sdk/types.js'; +import { argumentText, examPrompt, germanDate, resolveTarget, summaryPrompt, type Target } from '../src/mcp/prompts.ts'; +import { ProtocolError } from '../src/mcp/tools/result.ts'; + +const CANDIDATES: Target[] = [ + { kind: 'course', id: '65a000000000000000000001', name: 'LF07 - FIA24A/B - Sb/Ha' }, + { kind: 'course', id: '65a000000000000000000002', name: 'FIA24B - SK (Rh)' }, + { kind: 'course', id: '65a000000000000000000003', name: 'FIA24A - SK (Rh)' }, + { kind: 'course', id: '65a000000000000000000004', name: 'Deutsch' }, + { kind: 'course', id: '65a000000000000000000005', name: 'Deutsch Förderkurs' }, + { kind: 'room', id: '65a000000000000000000006', name: 'Prüfungsgruppe AP1' }, + // Real naming, from one live account: the same codes, spaced differently. + { kind: 'course', id: '65a000000000000000000007', name: 'FIA24B - IT- LF1 (Wm)' }, + { kind: 'course', id: '65a000000000000000000008', name: 'FIA24B LF10 (VH)' }, + { kind: 'course', id: '65a000000000000000000009', name: 'FIA24/FIP24 IT LF10 Ra/ZZ-Ri' }, + { kind: 'course', id: '65a00000000000000000000a', name: 'FIA24B LF 11 Richter' }, +]; + +function refusal(query: string): ProtocolError { + try { + resolveTarget(query, CANDIDATES); + } catch (error) { + assert.ok(error instanceof ProtocolError, 'refusals are protocol errors'); + assert.equal(error.code, ErrorCode.InvalidParams); + return error; + } + assert.fail(`"${query}" should have been refused`); +} + +describe('argumentText', () => { + it('turns the underscores a joined value needs back into spaces', () => { + assert.equal(argumentText('Erbrecht_und__Testament'), 'Erbrecht und Testament'); + }); + + it('treats a dash, blanks and absence as not given', () => { + assert.equal(argumentText('-'), undefined); + assert.equal(argumentText(' _ '), undefined); + assert.equal(argumentText(undefined), undefined); + }); +}); + +describe('resolveTarget', () => { + it('takes an id as it is', () => { + assert.equal(resolveTarget('65a000000000000000000003', CANDIDATES).name, 'FIA24A - SK (Rh)'); + }); + + it('finds a course by a fragment, without the slashes of its name', () => { + assert.equal(resolveTarget('lf07', CANDIDATES).id, '65a000000000000000000001'); + }); + + it('reads words joined with underscores, as Claude Code has to send them', () => { + assert.equal(resolveTarget('FIA24B_SK', CANDIDATES).id, '65a000000000000000000002'); + }); + + it('prefers a name matched word for word over names that contain it', () => { + // Otherwise "Deutsch" could never be chosen while "Deutsch Förderkurs" exists. + assert.equal(resolveTarget('deutsch', CANDIDATES).id, '65a000000000000000000004'); + }); + + it('prefers whole words, so LF1 is not ambiguous with LF10', () => { + assert.equal(resolveTarget('LF1', CANDIDATES).id, '65a000000000000000000007'); + }); + + it('still reports two courses that share the whole word', () => { + assert.match(refusal('LF10').message, /passt auf 2 Einträge/); + }); + + it('finds a code the teacher wrote with a space in it', () => { + assert.equal(resolveTarget('LF11', CANDIDATES).id, '65a00000000000000000000a'); + }); + + it('ignores umlauts, like search does', () => { + assert.equal(resolveTarget('Forderkurs', CANDIDATES).id, '65a000000000000000000005'); + }); + + it('includes rooms', () => { + const room = resolveTarget('AP1', CANDIDATES); + assert.equal(room.kind, 'room'); + }); + + it('refuses an ambiguous fragment and lists the candidates with their ids', () => { + const error = refusal('SK'); + assert.match(error.message, /passt auf 2 Einträge/); + assert.match(error.message, /FIA24A - SK \(Rh\) \(Kurs, ID 65a000000000000000000003\)/); + assert.match(error.message, /FIA24B - SK \(Rh\)/); + }); + + it('refuses a fragment that matches nothing and says what exists', () => { + const error = refusal('Mathe_10b'); + assert.match(error.message, /Kein Kurs und kein Raum passt zu „Mathe 10b“/); + assert.match(error.message, /Prüfungsgruppe AP1 \(Raum\)/); + }); + + it('refuses a query with nothing to match on', () => { + assert.match(refusal(' - ').message, /Gib einen Kurs oder Raum an/); + }); +}); + +describe('summaryPrompt', () => { + const course = CANDIDATES[1]!; + const room = CANDIDATES[5]!; + + it('names the course, sends Claude to its file area and asks for German', () => { + const text = summaryPrompt(course); + assert.match(text, /Fasse den Kurs „FIA24B - SK \(Rh\)“ für mich zusammen/); + assert.match(text, /fs_tree mit dem Pfad "\/courses\/65a000000000000000000002"/); + assert.match(text, /\*\*Aufgaben:\*\*/); + assert.match(text, /Antworte auf Deutsch\./); + assert.doesNotMatch(text, /Konzentriere dich/); + }); + + it('leaves out what a room does not have: topics, tasks, course files', () => { + const text = summaryPrompt(room); + assert.match(text, /Fasse den Raum „Prüfungsgruppe AP1“/); + assert.doesNotMatch(text, /get_lesson|get_task|fs_tree|\*\*Aufgaben:\*\*/); + }); + + it('numbers the steps without gaps when some are left out', () => { + const text = summaryPrompt(room, 'Netzwerke'); + assert.match(text, /1\. Lies die Boards des Raums mit get_board\.\n2\. Wenn es sehr viel Material gibt/); + assert.match(text, /3\. Konzentriere dich auf: Netzwerke\./); + }); +}); + +describe('examPrompt', () => { + const course = CANDIDATES[0]!; + + it('carries the topic, the date and today, and plans day by day', () => { + const text = examPrompt(course, { topic: 'Subnetting', date: '02.10.2026', today: 'Dienstag, 15.09.2026' }); + assert.match(text, /Prüfung im Kurs „LF07 - FIA24A\/B - Sb\/Ha“/); + assert.match(text, /Thema der Prüfung: Subnetting/); + assert.match(text, /Prüfungstermin: 02\.10\.2026 \(heute ist Dienstag, 15\.09\.2026\)\./); + assert.match(text, /Mit search findest du das Thema/); + assert.match(text, /\*\*Lernplan:\*\* Tag für Tag bis zur Prüfung/); + assert.match(text, /list_submissions für diesen Kurs/); + }); + + it('asks Claude to work out the scope when no topic is given', () => { + const text = examPrompt(course, { today: 'Dienstag, 15.09.2026' }); + assert.match(text, /Das Thema der Prüfung steht noch nicht fest/); + assert.doesNotMatch(text, /Prüfungstermin|Mit search/); + assert.match(text, /\*\*Lernplan:\*\* eine sinnvolle Reihenfolge der Themen/); + }); +}); + +describe('germanDate', () => { + it('names the weekday and uses German time, not UTC', () => { + // 23:30 UTC on the 14th is already the 15th in Germany. + assert.equal(germanDate(new Date('2026-09-14T23:30:00Z')), 'Dienstag, 15.09.2026'); + }); +});