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>
This commit is contained in:
30
CLAUDE.md
30
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 <code>:", 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`
|
||||
|
||||
23
README.md
23
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/<id>`, `schulcloud://rooms/<id>`) 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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] });
|
||||
|
||||
307
src/mcp/prompts.ts
Normal file
307
src/mcp/prompts.ts
Normal file
@@ -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<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');
|
||||
}
|
||||
103
src/mcp/resources.ts
Normal file
103
src/mcp/resources.ts
Normal file
@@ -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<Resource[]>): Promise<Resource[]> {
|
||||
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<string>): Promise<ReadResourceResult> {
|
||||
try {
|
||||
return { contents: [{ uri: uri.href, mimeType: MARKDOWN, text: await render() }] };
|
||||
} catch (error) {
|
||||
throw toProtocolError(error, action);
|
||||
}
|
||||
}
|
||||
@@ -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/<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 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 };
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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 -------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 <code>:" 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)}`;
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
// 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,
|
||||
|
||||
153
test/prompts.test.ts
Normal file
153
test/prompts.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user