Files
Schulcloud-MCP/src/mcp/prompts.ts
MechaCat02 9d0272c622 Offer courses and rooms as MCP resources, with two German prompts
A course or room can now be attached to a message rather than fetched:
schulcloud://courses/<id> and schulcloud://rooms/<id> carry exactly what
get_course and get_room return. Deliberately coarse — a picker lists every
resource at once, which suits some twenty courses and not a thousand files.

Two prompts, in German because the school is: zusammenfassung summarises a
course or room, and pruefungsvorbereitung prepares for an exam with practice
questions and a study plan. Each embeds the overview and says where material
hides and what cannot be read.

Claude Code shaped the details, read from its bundle rather than its docs.
It splits prompt arguments on whitespace and drops extra words, so words
arrive joined with "_", and courses match by fragments, whole words first,
so LF1 is not ambiguous with LF10. Its @ autocomplete shows a resource's
description, so the description carries the name. Errors are ProtocolError,
because McpError's message prefix is doubled by the client.

Verified in interactive Claude Code: @-mention, autocomplete and the prompt
commands. 157 tests. Smoke 67/67 live; 69/69 and 67/67 on the local instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:16 +02:00

308 lines
12 KiB
TypeScript

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<Target> {
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<GetPromptResult> {
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');
}