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:
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