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:
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,
|
||||
|
||||
Reference in New Issue
Block a user