Initial schulcloud-mcp server

Read-only MCP server exposing a Schulcloud account to Claude: courses,
column boards, lessons, tasks, and file downloads with text extraction.

The API surface was verified against the live instance rather than
inferred from upstream source, which changed several design decisions:

- The `jwt` cookie works verbatim as `Authorization: Bearer` and lasts 30
  days, so there is no cookie jar and no refresh-session timer.
- Course contents live at /api/v3/course-rooms/{courseId}/board; there is
  no GET /api/v3/courses/{id}.
- Files are a separate service (/api/v3/file/*) with its own OpenAPI doc.
- Board file elements carry no file id; attachments are resolved by
  listing files-storage with parentType=boardnodes and the element id.

Read-only by construction: every client method is a GET, including the
api_get escape hatch. The endpoint is internet-facing by necessity, so a
leaked token being unable to act as the user is the key safety property.

Deploys as a container behind the Pi's existing Caddy, guarded by a
constant-time bearer check. Stateless — no database.

Verified: 28 unit tests, plus a 30-check end-to-end run driving a real
MCP client over Streamable HTTP against the live account.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-11 23:52:12 +02:00
commit 35125b7683
38 changed files with 6317 additions and 0 deletions

32
src/bin/http.ts Normal file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env node
import { loadConfig } from '../config.ts';
import { createHttpApp } from '../http/server.ts';
/**
* HTTP entry point — the deployed form of this server, sitting behind Caddy.
*/
async function main(): Promise<void> {
const config = loadConfig();
const app = createHttpApp(config);
const server = app.listen(config.port, config.bindHost, () => {
console.log(
`[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` +
`auth ${config.authToken ? 'enabled' : 'DISABLED'}`,
);
});
// Let Docker's SIGTERM drain in-flight requests instead of cutting them off.
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.on(signal, () => {
console.log(`[schulcloud-mcp] ${signal} received, shutting down`);
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 10_000).unref();
});
}
}
main().catch((error: unknown) => {
console.error('[schulcloud-mcp] fatal:', error);
process.exit(1);
});

22
src/bin/stdio.ts Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env node
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { loadConfig } from '../config.ts';
import { createServer } from '../server.ts';
/**
* stdio entry point — for running the server locally against Claude Code or
* Claude Desktop. The remote deployment uses bin/http.ts instead.
*
* Nothing may be written to stdout here except MCP protocol frames.
*/
async function main(): Promise<void> {
const config = loadConfig();
const { server } = createServer(config);
await server.connect(new StdioServerTransport());
console.error(`[schulcloud-mcp] stdio transport ready for ${config.baseUrl}`);
}
main().catch((error: unknown) => {
console.error('[schulcloud-mcp] fatal:', error);
process.exit(1);
});

52
src/config.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* Runtime configuration, read once from the environment.
*
* The two Schulcloud values are named after the browser artefacts they come
* from (`TSC_URL`, `TSC_JWT_COOKIE`) so that copying a fresh token out of
* DevTools stays an obvious, mechanical step — see docs/AUTH.md.
*/
export interface Config {
/** Instance base URL, no trailing slash, e.g. `https://schulcloud-thueringen.de`. */
baseUrl: string;
/** Raw JWT from the instance's `jwt` cookie. Sent as `Authorization: Bearer`. */
jwt: string;
/** Shared secret callers must present to this MCP server. Unused in stdio mode. */
authToken: string | undefined;
port: number;
bindHost: string;
/** Hard ceiling on how many bytes `download_file` will pull from the instance. */
maxDownloadBytes: number;
/** Characters of extracted text returned before truncation kicks in. */
maxExtractedChars: number;
requestTimeoutMs: number;
}
function required(name: string): string {
const value = process.env[name]?.trim();
if (!value) throw new Error(`Missing required environment variable ${name}`);
return value;
}
function int(name: string, fallback: number): number {
const raw = process.env[name]?.trim();
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Environment variable ${name} must be a positive integer, got ${raw}`);
}
return parsed;
}
export function loadConfig(): Config {
return {
baseUrl: required('TSC_URL').replace(/\/+$/, ''),
jwt: required('TSC_JWT_COOKIE'),
authToken: process.env.MCP_AUTH_TOKEN?.trim() || undefined,
port: int('PORT', 8080),
bindHost: process.env.BIND_HOST?.trim() || '0.0.0.0',
maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024),
maxExtractedChars: int('MAX_EXTRACTED_CHARS', 120_000),
requestTimeoutMs: int('REQUEST_TIMEOUT_MS', 30_000),
};
}

38
src/context.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { Config } from './config.ts';
import { SchulcloudClient } from './schulcloud/client.ts';
import type { MeResponse } from './schulcloud/types.ts';
/**
* Per-process state shared by every tool.
*
* The only thing worth holding onto is the identity from `/api/v3/me`: the
* school id is a required path segment for every files-storage call, and it
* cannot change for a given JWT. Everything else is fetched live.
*/
export class ServerContext {
readonly client: SchulcloudClient;
private identity: Promise<MeResponse> | undefined;
constructor(readonly config: Config) {
this.client = new SchulcloudClient(config);
}
/** Cached `/me`. Shared promise, so concurrent first calls make one request. */
me(): Promise<MeResponse> {
this.identity ??= this.client.me().catch((error: unknown) => {
// Don't cache a failure — a replaced JWT should be able to recover.
this.identity = undefined;
throw error;
});
return this.identity;
}
async schoolId(): Promise<string> {
return (await this.me()).school.id;
}
/** Drops the cached identity so the next call re-reads it. */
reset(): void {
this.identity = undefined;
}
}

223
src/extract.ts Normal file
View File

@@ -0,0 +1,223 @@
import { Buffer } from 'node:buffer';
/**
* Turns a downloaded file into something Claude can actually read.
*
* Schulcloud material is overwhelmingly PDF, DOCX and images, so those get
* real extractors; the long tail falls back to a plain-text read when the
* bytes look like text, and to a "binary, not extractable" note otherwise.
* Heavy parsers are imported lazily so that a server that only ever lists
* files never pays for loading them.
*/
export type ExtractionKind = 'text' | 'image' | 'binary';
export interface Extraction {
kind: ExtractionKind;
/** Extracted text, for `kind: 'text'`. */
text?: string;
/** Base64 payload plus its media type, for `kind: 'image'`. */
image?: { base64: string; mimeType: string };
/** Human-readable note about what happened, always present. */
note: string;
truncated: boolean;
}
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
const PLAIN_TEXT_TYPES = new Set([
'text/plain',
'text/markdown',
'text/csv',
'text/html',
'text/xml',
'application/json',
'application/xml',
'application/x-yaml',
'text/yaml',
]);
export async function extractContent(
bytes: Buffer,
mimeType: string,
fileName: string,
maxChars: number,
): Promise<Extraction> {
const type = mimeType.toLowerCase();
const ext = fileName.toLowerCase().split('.').pop() ?? '';
try {
if (IMAGE_TYPES.has(type)) {
return {
kind: 'image',
image: { base64: bytes.toString('base64'), mimeType: type },
note: `Image (${type}, ${formatBytes(bytes.length)}) returned inline.`,
truncated: false,
};
}
if (type === 'application/pdf' || ext === 'pdf') {
return finishText(await extractPdf(bytes), maxChars, 'PDF');
}
if (type.includes('wordprocessingml') || ext === 'docx') {
return finishText(await extractDocx(bytes), maxChars, 'Word document');
}
if (type.includes('spreadsheetml') || ext === 'xlsx' || ext === 'xlsm') {
return finishText(await extractXlsx(bytes), maxChars, 'Excel workbook');
}
if (type.includes('presentationml') || ext === 'pptx') {
return finishText(await extractOoxmlZipText(bytes, /^ppt\/slides\/slide\d+\.xml$/), maxChars, 'PowerPoint deck');
}
if (type.startsWith('application/vnd.oasis.opendocument') || ['odt', 'odp', 'ods'].includes(ext)) {
return finishText(await extractOoxmlZipText(bytes, /^content\.xml$/), maxChars, 'OpenDocument file');
}
if (PLAIN_TEXT_TYPES.has(type) || type.startsWith('text/') || looksLikeUtf8Text(bytes)) {
return finishText(bytes.toString('utf8'), maxChars, 'Text file');
}
} catch (error) {
return {
kind: 'binary',
note:
`Could not extract text from ${fileName} (${type}): ${error instanceof Error ? error.message : String(error)}. ` +
`Use download_file with raw=true to get the bytes.`,
truncated: false,
};
}
return {
kind: 'binary',
note: `${fileName} is ${type} (${formatBytes(bytes.length)}) — no text extractor for this format. Use download_file with raw=true to get base64 bytes.`,
truncated: false,
};
}
function finishText(raw: string, maxChars: number, label: string): Extraction {
const cleaned = normalizeWhitespace(raw);
const truncated = cleaned.length > maxChars;
const text = truncated ? cleaned.slice(0, maxChars) : cleaned;
return {
kind: 'text',
text,
note: truncated
? `${label}: extracted text truncated to ${maxChars} characters (of ${cleaned.length}).`
: `${label}: extracted ${cleaned.length} characters of text.`,
truncated,
};
}
async function extractPdf(bytes: Buffer): Promise<string> {
const { extractText, getDocumentProxy } = await import('unpdf');
const document = await getDocumentProxy(new Uint8Array(bytes));
const { text } = await extractText(document, { mergePages: true });
return Array.isArray(text) ? text.join('\n\n') : text;
}
async function extractDocx(bytes: Buffer): Promise<string> {
const mammoth = (await import('mammoth')).default;
const { value } = await mammoth.extractRawText({ buffer: bytes });
return value;
}
async function extractXlsx(bytes: Buffer): Promise<string> {
const ExcelJS = (await import('exceljs')).default;
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(bytes as unknown as ArrayBuffer);
const parts: string[] = [];
workbook.eachSheet((sheet) => {
parts.push(`## Sheet: ${sheet.name}`);
sheet.eachRow({ includeEmpty: false }, (row) => {
const cells: string[] = [];
row.eachCell({ includeEmpty: true }, (cell) => cells.push(cellText(cell.value)));
// Trailing empties carry no information once the row is tabular.
while (cells.length && cells.at(-1) === '') cells.pop();
if (cells.length) parts.push(cells.join('\t'));
});
parts.push('');
});
return parts.join('\n');
}
function cellText(value: unknown): string {
if (value === null || value === undefined) return '';
if (value instanceof Date) return value.toISOString().slice(0, 10);
if (typeof value === 'object') {
const record = value as Record<string, unknown>;
if (typeof record.text === 'string') return record.text;
if (typeof record.result === 'string' || typeof record.result === 'number') return String(record.result);
if (Array.isArray(record.richText)) {
return record.richText.map((run) => String((run as { text?: unknown }).text ?? '')).join('');
}
if (typeof record.hyperlink === 'string') return record.hyperlink;
return '';
}
return String(value);
}
/**
* Pulls visible text out of an OOXML/ODF container by reading the XML parts
* matching `pattern` and stripping tags. Crude, but these formats put their
* prose in text nodes, which is all we need for "read me this slide deck".
*
* Uses unzipper's random-access API rather than its stream parser: the stream
* emits entries faster than their bodies can be buffered, so a streaming read
* finishes before the contents arrive.
*/
async function extractOoxmlZipText(bytes: Buffer, pattern: RegExp): Promise<string> {
const unzipper = await import('unzipper');
const directory = await unzipper.Open.buffer(bytes);
const wanted = directory.files.filter((file) => file.type === 'File' && pattern.test(file.path));
// slide2 must not sort before slide10's neighbours by string order.
wanted.sort((a, b) => numericSuffix(a.path) - numericSuffix(b.path));
const parts = await Promise.all(
wanted.map(async (file) => xmlToText((await file.buffer()).toString('utf8'))),
);
return parts.filter((part) => part.trim()).join('\n\n');
}
function numericSuffix(path: string): number {
return Number.parseInt(/(\d+)\.xml$/.exec(path)?.[1] ?? '0', 10);
}
function xmlToText(xml: string): string {
return xml
// Paragraph and line-break tags are the only structure worth keeping.
.replace(/<\/(a:p|w:p|text:p|text:h)>/g, '\n')
.replace(/<(a:br|w:br|text:line-break)\b[^>]*\/?>/g, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
.replace(/&amp;/g, '&');
}
function normalizeWhitespace(text: string): string {
return text
.replace(/\r\n?/g, '\n')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/** Heuristic: decodable as UTF-8 and free of NUL bytes in the sampled prefix. */
function looksLikeUtf8Text(bytes: Buffer): boolean {
const sample = bytes.subarray(0, 4096);
if (sample.includes(0)) return false;
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(sample);
return !decoded.includes('<27>');
}
export function formatBytes(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}

50
src/http/auth.ts Normal file
View File

@@ -0,0 +1,50 @@
import { timingSafeEqual } from 'node:crypto';
import type { NextFunction, Request, Response } from 'express';
/**
* Bearer-token gate for the public endpoint.
*
* This server is reachable from the internet by construction — Claude's
* connectors call it from Anthropic's cloud, not from the user's machine — so
* the token is the only thing between a stranger and the account's data.
* Comparison is constant-time, and a miss returns a bare 401 with a
* `WWW-Authenticate` challenge and no detail about why.
*/
export function bearerAuth(expected: string) {
const expectedBytes = Buffer.from(expected, 'utf8');
return function authenticate(req: Request, res: Response, next: NextFunction): void {
const presented = extractToken(req.get('authorization'), req.get('x-api-key'));
if (presented === undefined || !constantTimeEquals(Buffer.from(presented, 'utf8'), expectedBytes)) {
res.setHeader('WWW-Authenticate', 'Bearer realm="schulcloud-mcp"');
res.status(401).json({
jsonrpc: '2.0',
error: { code: -32001, message: 'Unauthorized' },
id: null,
});
return;
}
next();
};
}
function extractToken(authorization: string | undefined, apiKey: string | undefined): string | undefined {
if (authorization) {
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
if (match?.[1]) return match[1].trim();
}
// Some connector UIs only offer a custom header rather than Authorization.
return apiKey?.trim() || undefined;
}
function constantTimeEquals(a: Buffer, b: Buffer): boolean {
// timingSafeEqual throws on length mismatch, which would itself leak length.
// Hash-free equalisation: compare against a padded copy of the same size.
if (a.length !== b.length) {
const padded = Buffer.alloc(b.length);
a.copy(padded, 0, 0, Math.min(a.length, b.length));
timingSafeEqual(padded, b);
return false;
}
return timingSafeEqual(a, b);
}

144
src/http/server.ts Normal file
View File

@@ -0,0 +1,144 @@
import crypto from 'node:crypto';
import express, { type Request, type Response } from 'express';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import type { Config } from '../config.ts';
import { createServer } from '../server.ts';
import { bearerAuth } from './auth.ts';
/**
* Streamable-HTTP front end, for use as a remote MCP connector.
*
* Sessions are stateful: a client POSTs `initialize`, gets an
* `Mcp-Session-Id` back, and reuses it for subsequent POSTs, an optional GET
* (the SSE channel for server-initiated messages), and a DELETE to close.
* Each session owns one McpServer instance, which keeps per-session caches
* (the `/me` lookup) from leaking between callers.
*/
const MCP_PATH = '/mcp';
/** Sessions are dropped after this long without traffic, in case DELETE never arrives. */
const SESSION_IDLE_MS = 30 * 60 * 1000;
interface Session {
transport: StreamableHTTPServerTransport;
close: () => Promise<void>;
lastSeen: number;
}
export function createHttpApp(config: Config): express.Express {
const app = express();
app.disable('x-powered-by');
// Caddy sits in front and terminates TLS; trust its forwarding headers so
// logged client IPs are real rather than the proxy's.
app.set('trust proxy', true);
const sessions = new Map<string, Session>();
const sweep = setInterval(() => {
const cutoff = Date.now() - SESSION_IDLE_MS;
for (const [id, session] of sessions) {
if (session.lastSeen < cutoff) {
sessions.delete(id);
void session.close().catch(() => {});
}
}
}, 60_000);
sweep.unref();
// Liveness probe for Docker/Caddy. Deliberately before auth and free of any
// detail about the instance or the account.
app.get('/healthz', (_req, res) => {
res.json({ status: 'ok', sessions: sessions.size });
});
if (config.authToken) {
app.use(MCP_PATH, bearerAuth(config.authToken));
} else {
console.warn(
'[schulcloud-mcp] MCP_AUTH_TOKEN is not set — the endpoint is UNAUTHENTICATED. ' +
'Only acceptable when bound to localhost or an otherwise private network.',
);
}
app.use(MCP_PATH, express.json({ limit: '4mb' }));
app.post(MCP_PATH, async (req: Request, res: Response) => {
const sessionId = req.get('mcp-session-id');
try {
if (sessionId) {
const session = sessions.get(sessionId);
if (!session) {
res.status(404).json(rpcError(-32001, 'Unknown or expired session. Re-initialize.'));
return;
}
session.lastSeen = Date.now();
await session.transport.handleRequest(req, res, req.body);
return;
}
if (!isInitializeRequest(req.body)) {
res.status(400).json(rpcError(-32000, 'Missing Mcp-Session-Id header; send an initialize request first.'));
return;
}
const { server } = createServer(config);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (id) => {
sessions.set(id, {
transport,
close: async () => {
await transport.close().catch(() => {});
await server.close().catch(() => {});
},
lastSeen: Date.now(),
});
console.log(`[schulcloud-mcp] session ${id} initialized`);
},
});
transport.onclose = () => {
if (transport.sessionId) {
sessions.delete(transport.sessionId);
console.log(`[schulcloud-mcp] session ${transport.sessionId} closed`);
}
};
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('[schulcloud-mcp] POST failed:', error);
if (!res.headersSent) res.status(500).json(rpcError(-32603, 'Internal server error'));
}
});
// GET opens the server→client SSE stream; DELETE ends the session.
const bySession = async (req: Request, res: Response): Promise<void> => {
const sessionId = req.get('mcp-session-id');
const session = sessionId ? sessions.get(sessionId) : undefined;
if (!session) {
res.status(404).json(rpcError(-32001, 'Unknown or expired session.'));
return;
}
session.lastSeen = Date.now();
try {
await session.transport.handleRequest(req, res);
} catch (error) {
console.error('[schulcloud-mcp] session request failed:', error);
if (!res.headersSent) res.status(500).json(rpcError(-32603, 'Internal server error'));
}
};
app.get(MCP_PATH, bySession);
app.delete(MCP_PATH, bySession);
app.use((_req, res) => res.status(404).json({ error: 'not_found' }));
return app;
}
function rpcError(code: number, message: string) {
return { jsonrpc: '2.0' as const, error: { code, message }, id: null };
}

81
src/render.ts Normal file
View File

@@ -0,0 +1,81 @@
/**
* Formatting helpers shared by the tools.
*
* Tool results are read by a model, so everything renders to compact Markdown
* rather than raw JSON: ids stay visible (Claude needs them for follow-up
* calls) but the surrounding noise — display colours, positions, buffer-shaped
* Mongo ids — is dropped.
*/
/** Collapses Schulcloud's CKEditor HTML into plain text, keeping link targets. */
export function htmlToText(html: string | undefined | null): string {
if (!html) return '';
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|h[1-6]|li|tr)>/gi, '\n')
.replace(/<li[^>]*>/gi, '- ')
// Keep the href when the anchor text does not already contain it.
.replace(/<a\b[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, (_, href: string, label: string) => {
const text = label.replace(/<[^>]+>/g, '').trim();
if (!text) return href;
return text === href ? href : `${text} (${href})`;
})
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&amp;/g, '&')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/** `2026-08-17T08:00:00.000Z` → `2026-08-17 08:00`; passes other values through. */
export function formatDate(value: string | null | undefined): string {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toISOString().replace('T', ' ').slice(0, 16);
}
/** Days from now until `value`; negative when overdue. `undefined` if unset. */
export function daysUntil(value: string | null | undefined): number | undefined {
if (!value) return undefined;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return undefined;
return Math.round((date.getTime() - Date.now()) / 86_400_000);
}
export function dueLabel(dueDate: string | null | undefined): string {
const days = daysUntil(dueDate);
if (days === undefined) return 'no due date';
if (days < 0) return `due ${formatDate(dueDate)} (${Math.abs(days)}d overdue)`;
if (days === 0) return `due ${formatDate(dueDate)} (today)`;
return `due ${formatDate(dueDate)} (in ${days}d)`;
}
export function heading(level: number, text: string): string {
return `${'#'.repeat(level)} ${text}`;
}
/** Joins sections, dropping empties, with exactly one blank line between them. */
export function joinSections(parts: (string | undefined | null | false)[]): string {
return parts.filter((part): part is string => Boolean(part && part.trim())).join('\n\n');
}
/**
* Mongo ObjectIds sometimes come back from the legacy lesson API serialised as
* `{ buffer: { type: 'Buffer', data: [...] } }` instead of a hex string.
*/
export function normalizeObjectId(value: unknown): string | undefined {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') {
const data = (value as { buffer?: { data?: unknown } }).buffer?.data;
if (Array.isArray(data)) {
return data.map((byte) => Number(byte).toString(16).padStart(2, '0')).join('');
}
}
return undefined;
}

153
src/schulcloud/board.ts Normal file
View File

@@ -0,0 +1,153 @@
import type { SchulcloudClient } from './client.ts';
import { SchulcloudApiError } from './client.ts';
import type { BoardSkeleton, CardResponse, ContentElement, FileRecord } from './types.ts';
/**
* Assembles a column board into one self-contained structure.
*
* The API deliberately splits this across three calls — skeleton, card bodies,
* and (per file element) a files-storage lookup — because the web client
* renders them independently. A model asking "what's on this board" wants all
* of it at once, so this stitches the pieces together and resolves every file
* element to a real file record in parallel.
*/
export interface AssembledElement {
id: string;
type: string;
/** Plain-text body for richText/link elements. */
text?: string;
url?: string;
/** File records attached to this element, for `file` and `fileFolder`. */
files: FileRecord[];
/** Set when this element's files could not be resolved. */
fileError?: string;
raw: Record<string, unknown>;
}
export interface AssembledCard {
id: string;
title: string;
elements: AssembledElement[];
}
export interface AssembledColumn {
id: string;
title: string;
cards: AssembledCard[];
}
export interface AssembledBoard {
id: string;
title: string;
context?: { id: string; type: string };
columns: AssembledColumn[];
fileCount: number;
}
/** Element types whose attachments live under the `boardnodes` parent type. */
const FILE_BEARING_TYPES = new Set(['file', 'fileFolder', 'drawing']);
export async function assembleBoard(
client: SchulcloudClient,
boardId: string,
schoolId: string,
options: { resolveFiles?: boolean } = {},
): Promise<AssembledBoard> {
const resolveFiles = options.resolveFiles ?? true;
const [skeleton, context] = await Promise.all([
client.getBoardSkeleton(boardId),
client.getBoardContext(boardId).catch(() => undefined),
]);
const cardIds = skeleton.columns.flatMap((column) => column.cards.map((card) => card.cardId));
const cards = cardIds.length > 0 ? await client.getCards(cardIds) : [];
const cardsById = new Map(cards.map((card) => [card.id, card]));
const assembled = buildColumns(skeleton, cardsById);
if (resolveFiles) {
await attachFiles(client, assembled, schoolId);
}
const fileCount = assembled
.flatMap((column) => column.cards)
.flatMap((card) => card.elements)
.reduce((sum, element) => sum + element.files.length, 0);
return { id: skeleton.id, title: skeleton.title, context, columns: assembled, fileCount };
}
function buildColumns(skeleton: BoardSkeleton, cardsById: Map<string, CardResponse>): AssembledColumn[] {
return skeleton.columns.map((column) => ({
id: column.id,
title: column.title?.trim() || '(untitled column)',
cards: column.cards
.map((ref) => cardsById.get(ref.cardId))
// A card can be missing if it was deleted between the two calls.
.filter((card): card is CardResponse => card !== undefined)
.map(buildCard),
}));
}
function buildCard(card: CardResponse): AssembledCard {
return {
id: card.id,
title: card.title?.trim() || '(untitled card)',
elements: (card.elements ?? []).map(buildElement),
};
}
function buildElement(element: ContentElement): AssembledElement {
const content = element.content ?? {};
const assembled: AssembledElement = { id: element.id, type: element.type, files: [], raw: content };
if (element.type === 'richText' && typeof content.text === 'string') {
assembled.text = content.text;
}
if (element.type === 'link') {
if (typeof content.url === 'string') assembled.url = content.url;
if (typeof content.title === 'string') assembled.text = content.title;
}
if ((element.type === 'file' || element.type === 'fileFolder') && typeof content.caption === 'string') {
const caption = content.caption.trim();
if (caption) assembled.text = caption;
}
if (element.type === 'collaborativeTextEditor' || element.type === 'externalTool') {
if (typeof content.title === 'string') assembled.text = content.title;
}
return assembled;
}
/**
* Resolves file-bearing elements to file records.
*
* One request per element is unavoidable — files-storage only lists by
* parent — so they all go out at once. A per-element failure is recorded on
* that element rather than failing the whole board: a single blocked or
* deleted attachment shouldn't cost the user the rest of the content.
*/
async function attachFiles(client: SchulcloudClient, columns: AssembledColumn[], schoolId: string): Promise<void> {
const targets = columns
.flatMap((column) => column.cards)
.flatMap((card) => card.elements)
.filter((element) => FILE_BEARING_TYPES.has(element.type));
await Promise.all(
targets.map(async (element) => {
try {
const page = await client.listFiles({
storageLocationId: schoolId,
parentType: 'boardnodes',
parentId: element.id,
});
element.files = page.data;
} catch (error) {
element.fileError =
error instanceof SchulcloudApiError ? `HTTP ${error.status}` : String((error as Error).message ?? error);
}
}),
);
}

315
src/schulcloud/client.ts Normal file
View File

@@ -0,0 +1,315 @@
import type { Config } from '../config.ts';
import type {
BoardContext,
BoardSkeleton,
CardResponse,
CourseBoardResponse,
CourseMetadata,
DashboardResponse,
FileParentType,
FileRecord,
LessonResponse,
MeResponse,
NewsResponse,
Paginated,
TaskContent,
} from './types.ts';
/** An API response outside the 2xx range, carrying the status for callers to branch on. */
export class SchulcloudApiError extends Error {
constructor(
readonly status: number,
readonly path: string,
readonly body: string,
) {
super(`Schulcloud API ${status} for ${path}${body ? `: ${truncate(body, 400)}` : ''}`);
this.name = 'SchulcloudApiError';
}
/** True when the instance rejected our JWT — the one error the user must act on. */
get isAuthFailure(): boolean {
return this.status === 401;
}
}
function truncate(value: string, max: number): string {
return value.length > max ? `${value.slice(0, max)}` : value;
}
export interface DownloadedFile {
bytes: Buffer;
mimeType: string;
fileName: string;
/** True when the file was longer than `maxDownloadBytes` and got cut short. */
truncated: boolean;
}
/**
* Read-only HTTP client for a Schulcloud instance.
*
* Two services sit behind the same origin and both accept the same bearer
* token: the main server under `/api/v3/*`, and the files-storage service
* under `/api/v3/file/*`. The JWT from the browser's `jwt` cookie works
* verbatim as `Authorization: Bearer` — no cookie jar or session refresh is
* involved, and the token is valid for 30 days (see docs/AUTH.md).
*
* Every method here is a GET. Keeping the client incapable of writing is the
* main safety property of this server: whoever reaches the MCP endpoint can
* read this account's data but cannot act as the user inside Schulcloud.
*/
export class SchulcloudClient {
constructor(private readonly config: Config) {}
// --- transport -------------------------------------------------------
private url(path: string, query?: Record<string, string | number | string[] | undefined>): URL {
const url = new URL(`${this.config.baseUrl}${path}`);
for (const [key, value] of Object.entries(query ?? {})) {
if (value === undefined) continue;
if (Array.isArray(value)) for (const v of value) url.searchParams.append(key, v);
else url.searchParams.set(key, String(value));
}
return url;
}
private async request(url: URL, accept: string): Promise<Response> {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${this.config.jwt}`, Accept: accept },
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
redirect: 'follow',
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new SchulcloudApiError(response.status, url.pathname + url.search, body);
}
return response;
}
/** Authenticated GET returning JSON. `path` is absolute, e.g. `/api/v3/courses`. */
async getJson<T>(path: string, query?: Record<string, string | number | string[] | undefined>): Promise<T> {
const response = await this.request(this.url(path, query), 'application/json');
return (await response.json()) as T;
}
/**
* Authenticated GET returning bytes, capped at `maxDownloadBytes`.
*
* The cap is enforced while streaming rather than via Content-Length, so a
* mis-declared or chunked response still can't exhaust memory.
*/
async getBytes(path: string, fallbackName: string): Promise<DownloadedFile> {
const url = this.url(path);
const response = await this.request(url, '*/*');
const limit = this.config.maxDownloadBytes;
const chunks: Buffer[] = [];
let total = 0;
let truncated = false;
if (response.body) {
const reader = response.body.getReader();
try {
while (total < limit) {
const { done, value } = await reader.read();
if (done) break;
const chunk = Buffer.from(value);
const room = limit - total;
if (chunk.length > room) {
chunks.push(chunk.subarray(0, room));
total = limit;
truncated = true;
break;
}
chunks.push(chunk);
total += chunk.length;
}
if (total >= limit) {
// Anything still queued is beyond the cap; drop the rest.
const { done } = await reader.read();
if (!done) truncated = true;
}
} finally {
await reader.cancel().catch(() => {});
}
}
return {
bytes: Buffer.concat(chunks),
mimeType: response.headers.get('content-type')?.split(';')[0]?.trim() || 'application/octet-stream',
fileName: filenameFromDisposition(response.headers.get('content-disposition')) ?? fallbackName,
truncated,
};
}
// --- identity --------------------------------------------------------
me(): Promise<MeResponse> {
return this.getJson<MeResponse>('/api/v3/me');
}
// --- courses and the classic course board ----------------------------
listCourses(params: { skip?: number; limit?: number } = {}): Promise<Paginated<CourseMetadata>> {
return this.getJson<Paginated<CourseMetadata>>('/api/v3/courses', {
skip: params.skip,
limit: clampPageSize(params.limit),
});
}
/** Every course the account can see, paging past the API's per-page ceiling. */
listAllCourses(max = 500): Promise<CourseMetadata[]> {
return collectPages((skip, limit) => this.listCourses({ skip, limit }), max);
}
/**
* The contents of one course, as the course page shows them: lessons,
* tasks and column boards interleaved. The route is `course-rooms`, and
* its `:roomId` is the *course* id.
*/
getCourseBoard(courseId: string): Promise<CourseBoardResponse> {
return this.getJson<CourseBoardResponse>(`/api/v3/course-rooms/${encodeURIComponent(courseId)}/board`);
}
getDashboard(): Promise<DashboardResponse> {
return this.getJson<DashboardResponse>('/api/v3/dashboard');
}
// --- tasks -----------------------------------------------------------
listTasks(params: { skip?: number; limit?: number } = {}): Promise<Paginated<TaskContent>> {
return this.getJson<Paginated<TaskContent>>('/api/v3/tasks', {
skip: params.skip,
limit: clampPageSize(params.limit),
});
}
listFinishedTasks(params: { skip?: number; limit?: number } = {}): Promise<Paginated<TaskContent>> {
return this.getJson<Paginated<TaskContent>>('/api/v3/tasks/finished', {
skip: params.skip,
limit: clampPageSize(params.limit),
});
}
// --- lessons ---------------------------------------------------------
getLesson(lessonId: string): Promise<LessonResponse> {
return this.getJson<LessonResponse>(`/api/v3/lessons/${encodeURIComponent(lessonId)}`);
}
getLessonTasks(lessonId: string): Promise<Paginated<TaskContent>> {
return this.getJson<Paginated<TaskContent>>(`/api/v3/lessons/${encodeURIComponent(lessonId)}/tasks`);
}
// --- column boards ---------------------------------------------------
getBoardSkeleton(boardId: string): Promise<BoardSkeleton> {
return this.getJson<BoardSkeleton>(`/api/v3/boards/${encodeURIComponent(boardId)}`);
}
getBoardContext(boardId: string): Promise<BoardContext> {
return this.getJson<BoardContext>(`/api/v3/boards/${encodeURIComponent(boardId)}/context`);
}
/**
* Card bodies for the given ids. The upstream endpoint takes repeated
* `ids` query params with no documented ceiling, so we chunk purely to
* keep request URLs a sane length.
*/
async getCards(cardIds: string[]): Promise<CardResponse[]> {
const CHUNK = 40;
const out: CardResponse[] = [];
for (let i = 0; i < cardIds.length; i += CHUNK) {
const chunk = cardIds.slice(i, i + CHUNK);
const page = await this.getJson<{ data: CardResponse[] }>('/api/v3/cards', { ids: chunk });
out.push(...page.data);
}
return out;
}
// --- files -----------------------------------------------------------
/**
* Files attached to one parent entity.
*
* `storageLocationId` is the school id for `storageLocation: 'school'`,
* which is what every parent type in normal use resolves to. Board file
* elements are addressed with `parentType: 'boardnodes'` and the *element*
* id as `parentId`.
*/
listFiles(args: {
storageLocationId: string;
parentType: FileParentType;
parentId: string;
storageLocation?: 'school' | 'instance';
}): Promise<Paginated<FileRecord>> {
const location = args.storageLocation ?? 'school';
const path =
`/api/v3/file/list/${location}/${encodeURIComponent(args.storageLocationId)}` +
`/${args.parentType}/${encodeURIComponent(args.parentId)}`;
return this.getJson<Paginated<FileRecord>>(path);
}
getFileRecord(fileRecordId: string): Promise<FileRecord> {
return this.getJson<FileRecord>(`/api/v3/file/${encodeURIComponent(fileRecordId)}`);
}
downloadFile(record: Pick<FileRecord, 'id' | 'name'>): Promise<DownloadedFile> {
const path = `/api/v3/file/download/${encodeURIComponent(record.id)}/${encodeURIComponent(record.name)}`;
return this.getBytes(path, record.name);
}
// --- misc ------------------------------------------------------------
listNews(params: { skip?: number; limit?: number } = {}): Promise<Paginated<NewsResponse>> {
return this.getJson<Paginated<NewsResponse>>('/api/v3/news', {
skip: params.skip,
limit: clampPageSize(params.limit),
});
}
}
/**
* The list endpoints reject `limit` above 100 and document a maximum of 99, so
* page at 99 and let `collectPages` stitch the results back together.
*/
export const MAX_PAGE_SIZE = 99;
function clampPageSize(limit: number | undefined): number | undefined {
if (limit === undefined) return undefined;
return Math.min(Math.max(1, Math.trunc(limit)), MAX_PAGE_SIZE);
}
/** Follows `skip`/`limit` paging until `max` items or the server runs out. */
async function collectPages<T>(
fetchPage: (skip: number, limit: number) => Promise<Paginated<T>>,
max: number,
): Promise<T[]> {
const items: T[] = [];
let skip = 0;
while (items.length < max) {
const page = await fetchPage(skip, Math.min(MAX_PAGE_SIZE, max - items.length));
items.push(...page.data);
skip += page.data.length;
// Stop on an empty page too, so a server that ignores `skip` can't loop forever.
if (page.data.length === 0 || skip >= page.total) break;
}
return items.slice(0, max);
}
function filenameFromDisposition(header: string | null): string | undefined {
if (!header) return undefined;
// Prefer RFC 5987 `filename*`, which carries the encoding explicitly.
const extended = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(header);
if (extended?.[1]) return safeDecode(extended[1].trim());
const plain = /filename="?([^";]+)"?/.exec(header);
if (plain?.[1]) return safeDecode(plain[1].trim());
return undefined;
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}

243
src/schulcloud/types.ts Normal file
View File

@@ -0,0 +1,243 @@
/**
* Response shapes for the parts of the Schulcloud API this server touches.
*
* These were read off the live instance's OpenAPI documents
* (`/api/v3/docs-json` and `/api/v3/file/docs-json`) and confirmed against
* real responses; they cover only the fields we actually use, so upstream
* additions won't break them.
*/
export interface Paginated<T> {
total: number;
skip: number;
limit: number;
data: T[];
}
export interface MeResponse {
school: { id: string; name: string };
user: { id: string; firstName: string; lastName: string; customAvatarBackgroundColor?: string };
roles: { id: string; name: string }[];
permissions: string[];
language?: string;
}
export interface CourseMetadata {
id: string;
title: string;
shortTitle: string;
displayColor: string;
startDate?: string;
untilDate?: string;
isLocked?: boolean;
}
/** An entry on a *course* board — the classic learnroom view. */
export type CourseBoardElement =
| { type: 'task'; content: TaskContent }
| { type: 'lesson'; content: LessonMetaContent }
| { type: 'column-board'; content: ColumnBoardMetaContent };
export interface CourseBoardResponse {
roomId: string;
title: string;
displayColor: string;
elements: CourseBoardElement[];
isArchived?: boolean;
isSynchronized?: boolean;
}
export interface TaskStatus {
submitted: number;
maxSubmissions: number;
graded: number;
isDraft: boolean;
isSubstitutionTeacher: boolean;
isFinished: boolean;
}
export interface TaskContent {
id: string;
name: string;
courseName?: string;
courseId?: string;
lessonName?: string;
description?: string;
availableDate?: string;
dueDate?: string | null;
createdAt?: string;
updatedAt?: string;
displayColor?: string;
status: TaskStatus;
}
export interface LessonMetaContent {
id: string;
name: string;
hidden: boolean;
createdAt?: string;
updatedAt?: string;
numberOfPublishedTasks?: number;
}
export interface ColumnBoardMetaContent {
id: string;
title: string;
published?: boolean;
createdAt?: string;
updatedAt?: string;
layout?: string;
columnBoardId?: string;
}
/**
* A lesson's body. `contents[].content` varies by `component`
* (`text`, `geoGebra`, `Etherpad`, `resources`, `internal`, `neXboard`).
*/
export interface LessonResponse {
id: string;
name: string;
courseId: string;
hidden: boolean;
position?: number;
contents: LessonContent[];
materials: LessonMaterial[];
}
export interface LessonContent {
id?: unknown;
title?: string;
hidden?: boolean;
component?: string;
content?: Record<string, unknown>;
}
export interface LessonMaterial {
id?: unknown;
title?: string;
url?: string;
client?: string;
description?: string;
merlinReference?: string;
}
/** Board skeleton: structure and card ids only — card bodies come from `/cards`. */
export interface BoardSkeleton {
id: string;
title: string;
layout?: string;
isVisible?: boolean;
readersCanEdit?: boolean;
columns: {
id: string;
title?: string;
cards: { cardId: string; height: number }[];
timestamps?: Timestamps;
}[];
timestamps?: Timestamps;
}
export interface Timestamps {
createdAt?: string;
lastUpdatedAt?: string;
deletedAt?: string;
}
export interface BoardContext {
id: string;
type: string;
}
export interface CardResponse {
id: string;
title?: string;
height: number;
elements: ContentElement[];
visibilitySettings?: Record<string, unknown>;
timestamps?: Timestamps;
}
export interface ContentElement {
id: string;
type: ContentElementType;
content: Record<string, unknown>;
timestamps?: Timestamps;
}
export type ContentElementType =
| 'file'
| 'fileFolder'
| 'drawing'
| 'link'
| 'richText'
| 'externalTool'
| 'collaborativeTextEditor'
| 'videoConference'
| 'h5p'
| 'deleted';
/** A file in the files-storage service. `url` is instance-relative. */
export interface FileRecord {
id: string;
name: string;
parentId: string;
parentType: FileParentType;
url: string;
size: number;
mimeType: string;
securityCheckStatus: 'pending' | 'verified' | 'blocked' | 'wont-check' | string;
previewStatus: string;
creatorId?: string;
isCollaboraEditable?: boolean;
createdAt?: string;
updatedAt?: string;
contentLastModifiedAt?: string;
}
/** Values accepted by files-storage for the `:parentType` path segment. */
export type FileParentType =
| 'users'
| 'schools'
| 'courses'
| 'tasks'
| 'lessons'
| 'submissions'
| 'gradings'
| 'boardnodes'
| 'externaltools';
export const FILE_PARENT_TYPES: FileParentType[] = [
'users',
'schools',
'courses',
'tasks',
'lessons',
'submissions',
'gradings',
'boardnodes',
'externaltools',
];
export interface DashboardResponse {
id: string;
gridElements: {
id: string;
title: string;
shortTitle: string;
displayColor: string;
xPosition: number;
yPosition: number;
groupElements?: { id: string; title: string; shortTitle: string; displayColor: string }[];
}[];
}
export interface NewsResponse {
id: string;
title: string;
content: string;
displayAt: string;
source?: string;
targetId?: string;
creator?: { id: string; firstName?: string; lastName?: string };
createdAt?: string;
}

45
src/server.ts Normal file
View File

@@ -0,0 +1,45 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { Config } from './config.ts';
import { ServerContext } from './context.ts';
import { registerContentTools } from './tools/content.ts';
import { registerFileTools } from './tools/files.ts';
import { registerOverviewTools } from './tools/overview.ts';
import { registerRawTool } from './tools/raw.ts';
import { registerSearchTool } from './tools/search.ts';
export const SERVER_NAME = 'schulcloud-mcp';
export const SERVER_VERSION = '0.1.0';
const INSTRUCTIONS = `Read-only access to a Schulcloud (HPI Schul-Cloud / Schulcloud-Verbund-Software) account.
How the content is organised, and the usual path through it:
- **Courses** ("Kurse") are the top level — list_courses, or get_dashboard for the ones the user has pinned.
- A course page (get_course) holds three kinds of thing:
- **Column boards** — where most current teaching material lives. get_board returns every column, card,
text block, link and attached file in one call.
- **Topics / lessons** ("Themen") — the older format. get_lesson.
- **Tasks** ("Aufgaben") — homework. list_tasks across all courses, get_task for one.
- **Files** hang off boards, lessons and tasks. Every listing shows file ids; download_file fetches one and
extracts its text (PDF, Word, Excel, PowerPoint, OpenDocument) or returns an image inline.
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.
Everything here is read-only; nothing in this server can modify the account.`;
export function createServer(config: Config): { server: McpServer; context: ServerContext } {
const context = new ServerContext(config);
const server = new McpServer(
{ name: SERVER_NAME, version: SERVER_VERSION },
{ capabilities: { tools: {}, logging: {} }, instructions: INSTRUCTIONS },
);
registerOverviewTools(server, context);
registerContentTools(server, context);
registerFileTools(server, context);
registerSearchTool(server, context);
registerRawTool(server, context);
return { server, context };
}

341
src/tools/content.ts Normal file
View File

@@ -0,0 +1,341 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../context.ts';
import { formatBytes } from '../extract.ts';
import { dueLabel, formatDate, heading, htmlToText, joinSections, normalizeObjectId } from '../render.ts';
import { assembleBoard, type AssembledBoard, type AssembledElement } from '../schulcloud/board.ts';
import type { CourseBoardResponse, FileRecord, LessonResponse, TaskContent } from '../schulcloud/types.ts';
import { failure, text, toToolError } from './result.ts';
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
export function registerContentTools(server: McpServer, context: ServerContext): void {
server.registerTool(
'get_course',
{
title: 'Get course contents',
description:
'Everything inside one course: its topics ("Themen"/lessons), tasks, and column boards, in the order ' +
'shown on the course page. Returns ids for each, which get_board, get_lesson and get_task take. ' +
'Most teaching material lives on column boards.',
inputSchema: {
courseId: z.string().describe('Course id from list_courses or get_dashboard.'),
},
annotations: READ_ONLY,
},
async ({ courseId }) => {
try {
const board = await context.client.getCourseBoard(courseId);
return text(formatCourseBoard(board));
} catch (error) {
return toToolError(error, `read course ${courseId}`);
}
},
);
server.registerTool(
'get_board',
{
title: 'Get column board',
description:
'The full contents of a column board: every column, card, text block, link and attached file, with ' +
'file ids ready for download_file. This is where course material actually lives — prefer it over ' +
'poking at cards individually.',
inputSchema: {
boardId: z.string().describe('Board id, from get_course.'),
includeFiles: z
.boolean()
.default(true)
.describe('Resolve attachments to real file records. Turn off for a faster structure-only view.'),
},
annotations: READ_ONLY,
},
async ({ boardId, includeFiles }) => {
try {
const schoolId = await context.schoolId();
const board = await assembleBoard(context.client, boardId, schoolId, { resolveFiles: includeFiles });
return text(formatBoard(board, includeFiles));
} catch (error) {
return toToolError(error, `read board ${boardId}`);
}
},
);
server.registerTool(
'get_lesson',
{
title: 'Get lesson',
description:
'One topic/lesson ("Thema") from a course: its text sections, linked materials, attached files and ' +
'the tasks that belong to it. Lessons are the older content format; newer courses use column boards.',
inputSchema: {
lessonId: z.string().describe('Lesson id, from get_course.'),
},
annotations: READ_ONLY,
},
async ({ lessonId }) => {
try {
const schoolId = await context.schoolId();
const [lesson, tasks, files] = await Promise.all([
context.client.getLesson(lessonId),
context.client.getLessonTasks(lessonId).catch(() => undefined),
context.client
.listFiles({ storageLocationId: schoolId, parentType: 'lessons', parentId: lessonId })
.catch(() => undefined),
]);
return text(formatLesson(lesson, tasks?.data ?? [], files?.data ?? []));
} catch (error) {
return toToolError(error, `read lesson ${lessonId}`);
}
},
);
server.registerTool(
'get_task',
{
title: 'Get task',
description:
'Full detail for one task: description, due date, submission status and attached files. ' +
'The API has no single-task endpoint, so this locates the task through the task lists and its ' +
'course page — pass courseId when you know it to skip the search.',
inputSchema: {
taskId: z.string().describe('Task id, from list_tasks or get_course.'),
courseId: z.string().optional().describe('Course the task belongs to. Optional; speeds up the lookup.'),
},
annotations: READ_ONLY,
},
async ({ taskId, courseId }) => {
try {
const schoolId = await context.schoolId();
const found = await findTask(context, taskId, courseId);
if (!found) {
return failure(
`Task ${taskId} was not found in the open or finished task lists, nor on the given course page. ` +
`It may belong to a course this account cannot see, or the id may be wrong.`,
);
}
const files = await context.client
.listFiles({ storageLocationId: schoolId, parentType: 'tasks', parentId: taskId })
.catch(() => undefined);
return text(formatTask(found, files?.data ?? []));
} catch (error) {
return toToolError(error, `read task ${taskId}`);
}
},
);
}
// --- task lookup -------------------------------------------------------
/**
* Finds a task by id.
*
* There is no `GET /tasks/{id}`, and the list endpoints omit `description`,
* which is only present on the course page's task element. So: use the lists
* to learn which course the task belongs to (unless told), then read the
* description off that course's page.
*/
async function findTask(context: ServerContext, taskId: string, courseId?: string): Promise<TaskContent | undefined> {
if (courseId) {
const fromCourse = await taskFromCourse(context, courseId, taskId);
if (fromCourse) return fromCourse;
}
const [open, finished] = await Promise.all([
context.client.listTasks({ limit: 99 }).catch(() => undefined),
context.client.listFinishedTasks({ limit: 99 }).catch(() => undefined),
]);
const listed = [...(open?.data ?? []), ...(finished?.data ?? [])].find((task) => task.id === taskId);
if (!listed) return undefined;
// The list entry lacks the description; the course page has it.
if (listed.courseId) {
const enriched = await taskFromCourse(context, listed.courseId, taskId);
if (enriched) return { ...listed, ...enriched };
}
return listed;
}
async function taskFromCourse(
context: ServerContext,
courseId: string,
taskId: string,
): Promise<TaskContent | undefined> {
const board = await context.client.getCourseBoard(courseId).catch(() => undefined);
if (!board) return undefined;
for (const element of board.elements) {
if (element.type === 'task' && element.content.id === taskId) {
return { ...element.content, courseId, courseName: element.content.courseName ?? board.title };
}
}
return undefined;
}
// --- formatting --------------------------------------------------------
function formatCourseBoard(board: CourseBoardResponse): string {
const boards: string[] = [];
const lessons: string[] = [];
const tasks: string[] = [];
for (const element of board.elements) {
if (element.type === 'column-board') {
boards.push(`- **${element.content.title}** (\`${element.content.id}\`)`);
} else if (element.type === 'lesson') {
const taskCount = element.content.numberOfPublishedTasks
? `${element.content.numberOfPublishedTasks} task(s)`
: '';
const hidden = element.content.hidden ? ' [hidden]' : '';
lessons.push(`- **${element.content.name}** (\`${element.content.id}\`)${taskCount}${hidden}`);
} else if (element.type === 'task') {
const status = element.content.status.submitted > 0 ? 'submitted' : 'not submitted';
tasks.push(`- **${element.content.name}** (\`${element.content.id}\`) — ${dueLabel(element.content.dueDate)}, ${status}`);
}
}
if (boards.length + lessons.length + tasks.length === 0) {
return `${heading(2, board.title)}\n\nThis course page is empty.`;
}
return joinSections([
heading(2, board.title),
`Course id: \`${board.roomId}\``,
boards.length > 0 && joinSections([heading(3, `Boards (${boards.length})`), boards.join('\n'), 'Read one with get_board.']),
lessons.length > 0 && joinSections([heading(3, `Topics (${lessons.length})`), lessons.join('\n'), 'Read one with get_lesson.']),
tasks.length > 0 && joinSections([heading(3, `Tasks (${tasks.length})`), tasks.join('\n'), 'Read one with get_task.']),
]);
}
function formatBoard(board: AssembledBoard, includeFiles: boolean): string {
const columns = board.columns.map((column) => {
const cards = column.cards.map((card) => {
const body = card.elements
.map((element) => formatElement(element, includeFiles))
.filter(Boolean)
.join('\n');
return joinSections([heading(4, card.title), body || '_(empty card)_']);
});
return joinSections([heading(3, column.title), cards.length > 0 ? cards.join('\n\n') : '_(no cards)_']);
});
const summary =
`Board id: \`${board.id}\`` +
(board.context ? ` — in ${board.context.type} \`${board.context.id}\`` : '') +
(includeFiles ? `${board.fileCount} attached file(s)` : '');
return joinSections([
heading(2, board.title),
summary,
columns.length > 0 ? columns.join('\n\n') : '_(no columns)_',
includeFiles && board.fileCount > 0 ? 'Read any attachment with download_file using its file id.' : undefined,
]);
}
function formatElement(element: AssembledElement, includeFiles: boolean): string {
switch (element.type) {
case 'richText': {
const body = htmlToText(element.text);
return body ? body : '';
}
case 'link': {
const label = element.text?.trim();
return element.url ? `- Link: ${label && label !== element.url ? `${label}${element.url}` : element.url}` : '';
}
case 'file':
case 'fileFolder':
case 'drawing': {
const caption = element.text ? ` — caption: ${element.text}` : '';
if (!includeFiles) return `- ${element.type} element \`${element.id}\`${caption}`;
if (element.fileError) return `- ${element.type} element \`${element.id}\` — could not list files (${element.fileError})`;
if (element.files.length === 0) return `- ${element.type} element \`${element.id}\` — no files${caption}`;
return element.files.map((file) => `- ${formatFileLine(file)}${caption}`).join('\n');
}
case 'collaborativeTextEditor':
return `- Collaborative text document \`${element.id}\`${element.text ? `${element.text}` : ''} (contents not available through the API)`;
case 'externalTool':
return `- External tool${element.text ? `: ${element.text}` : ''} \`${element.id}\``;
case 'videoConference':
return `- Video conference \`${element.id}\``;
case 'h5p':
return `- H5P interactive content \`${element.id}\``;
case 'deleted':
return '- _(deleted element)_';
default:
return `- ${element.type} element \`${element.id}\``;
}
}
export function formatFileLine(file: FileRecord): string {
const blocked = file.securityCheckStatus === 'blocked' ? ' **[virus scan: blocked]**' : '';
const pending = file.securityCheckStatus === 'pending' ? ' _[virus scan pending]_' : '';
return `File: **${file.name}** (\`${file.id}\`, ${file.mimeType}, ${formatBytes(file.size)})${blocked}${pending}`;
}
function formatLesson(lesson: LessonResponse, tasks: TaskContent[], files: FileRecord[]): string {
const sections = (lesson.contents ?? []).map((entry) => {
const title = entry.title?.trim();
const component = entry.component ?? 'unknown';
const hidden = entry.hidden ? ' [hidden]' : '';
const body = formatLessonComponent(component, entry.content ?? {});
return joinSections([heading(4, `${title || component}${hidden}`), body || `_(${component} content, nothing to show)_`]);
});
const materials = (lesson.materials ?? []).map((material) => {
const id = normalizeObjectId(material.id);
return `- ${material.title ?? 'Untitled material'}${material.url ? `${material.url}` : ''}${id ? ` (\`${id}\`)` : ''}`;
});
return joinSections([
heading(2, lesson.name),
`Lesson id: \`${lesson.id}\` — in course \`${lesson.courseId}\`${lesson.hidden ? ' — hidden' : ''}`,
sections.length > 0 ? joinSections([heading(3, 'Contents'), sections.join('\n\n')]) : '_(no text contents)_',
materials.length > 0 && joinSections([heading(3, 'Linked materials'), materials.join('\n')]),
files.length > 0 &&
joinSections([heading(3, `Attached files (${files.length})`), files.map((file) => `- ${formatFileLine(file)}`).join('\n')]),
tasks.length > 0 &&
joinSections([
heading(3, `Tasks in this lesson (${tasks.length})`),
tasks.map((task) => `- **${task.name}** (\`${task.id}\`) — ${dueLabel(task.dueDate)}`).join('\n'),
]),
]);
}
function formatLessonComponent(component: string, content: Record<string, unknown>): string {
if (component === 'text' && typeof content.text === 'string') return htmlToText(content.text);
if (component === 'resources' && Array.isArray(content.resources)) {
return content.resources
.map((resource) => {
const entry = resource as { title?: string; url?: string; description?: string };
return `- ${entry.title ?? 'Resource'}${entry.url ? `${entry.url}` : ''}`;
})
.join('\n');
}
if (typeof content.url === 'string') return `- ${content.url}`;
if (typeof content.title === 'string') return content.title;
return '';
}
function formatTask(task: TaskContent, files: FileRecord[]): string {
const description = htmlToText(task.description);
return joinSections([
heading(2, task.name),
[
`- Task id: \`${task.id}\``,
task.courseName ? `- Course: ${task.courseName}${task.courseId ? ` (\`${task.courseId}\`)` : ''}` : undefined,
task.lessonName ? `- Topic: ${task.lessonName}` : undefined,
`- Available from: ${formatDate(task.availableDate)}`,
`- Due: ${dueLabel(task.dueDate)}`,
`- Submitted: ${task.status.submitted}/${task.status.maxSubmissions}${task.status.graded > 0 ? ', graded' : ''}`,
]
.filter(Boolean)
.join('\n'),
description ? joinSections([heading(3, 'Description'), description]) : '_(no description)_',
files.length > 0
? joinSections([
heading(3, `Attached files (${files.length})`),
files.map((file) => `- ${formatFileLine(file)}`).join('\n'),
'Read one with download_file.',
])
: undefined,
]);
}

149
src/tools/files.ts Normal file
View File

@@ -0,0 +1,149 @@
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../context.ts';
import { extractContent, formatBytes } from '../extract.ts';
import { formatDate, heading, joinSections } from '../render.ts';
import { FILE_PARENT_TYPES, type FileParentType } from '../schulcloud/types.ts';
import { formatFileLine } from './content.ts';
import { failure, text, toToolError } from './result.ts';
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
export function registerFileTools(server: McpServer, context: ServerContext): void {
server.registerTool(
'list_files',
{
title: 'List files of an entity',
description:
'Files attached to one entity. Most of the time you do not need this — get_board, get_lesson and ' +
'get_task already list their own attachments. Reach for it to enumerate a course\'s own file area, ' +
'or a single board element\'s files (parentType "boardnodes", parentId = the element id).',
inputSchema: {
parentType: z
.enum(FILE_PARENT_TYPES as [FileParentType, ...FileParentType[]])
.describe('Kind of entity the files hang off.'),
parentId: z.string().describe('Id of that entity. For "boardnodes" this is a board element id.'),
},
annotations: READ_ONLY,
},
async ({ parentType, parentId }) => {
try {
const schoolId = await context.schoolId();
const page = await context.client.listFiles({ storageLocationId: schoolId, parentType, parentId });
if (page.data.length === 0) return text(`No files attached to ${parentType} ${parentId}.`);
return text(
joinSections([
heading(2, `Files on ${parentType} ${parentId} (${page.data.length})`),
page.data.map((file) => `- ${formatFileLine(file)} — uploaded ${formatDate(file.createdAt)}`).join('\n'),
'Read one with download_file.',
]),
);
} catch (error) {
return toToolError(error, `list files of ${parentType} ${parentId}`);
}
},
);
server.registerTool(
'download_file',
{
title: 'Download and read a file',
description:
'Fetches a file and returns its contents. PDFs, Word, Excel, PowerPoint and OpenDocument files are ' +
'extracted to text; images come back inline so you can look at them; anything else reports its type. ' +
'Pass raw=true to get base64 bytes instead of extracted text.',
inputSchema: {
fileId: z.string().describe('File record id, from get_board, get_task, get_lesson or list_files.'),
raw: z
.boolean()
.default(false)
.describe('Return base64-encoded bytes instead of extracted text. Use for formats with no extractor.'),
maxChars: z
.number()
.int()
.min(500)
.max(500_000)
.optional()
.describe('Override the character limit on extracted text.'),
},
annotations: READ_ONLY,
},
async ({ fileId, raw, maxChars }) => {
try {
const record = await context.client.getFileRecord(fileId);
// The instance scans uploads; serving a known-bad file to the user is
// exactly the thing that scan exists to prevent.
if (record.securityCheckStatus === 'blocked') {
return failure(
`"${record.name}" was blocked by the instance's virus scanner and will not be downloaded.`,
);
}
const file = await context.client.downloadFile(record);
const header = [
heading(2, record.name),
[
`- File id: \`${record.id}\``,
`- Type: ${record.mimeType}`,
`- Size: ${formatBytes(record.size)}`,
`- Attached to: ${record.parentType} \`${record.parentId}\``,
`- Uploaded: ${formatDate(record.createdAt)}`,
record.securityCheckStatus !== 'verified'
? `- Virus scan: ${record.securityCheckStatus}`
: undefined,
file.truncated
? `- **Download was capped at ${formatBytes(context.config.maxDownloadBytes)}; content is incomplete.**`
: undefined,
]
.filter(Boolean)
.join('\n'),
].join('\n\n');
if (raw) {
return text(
joinSections([
header,
`Base64 (${file.bytes.length} bytes):`,
'```',
file.bytes.toString('base64'),
'```',
]),
);
}
const extraction = await extractContent(
file.bytes,
file.mimeType || record.mimeType,
record.name,
maxChars ?? context.config.maxExtractedChars,
);
if (extraction.kind === 'image' && extraction.image) {
const result: CallToolResult = {
content: [
{ type: 'text', text: joinSections([header, extraction.note]) },
{ type: 'image', data: extraction.image.base64, mimeType: extraction.image.mimeType },
],
};
return result;
}
if (extraction.kind === 'text') {
const body = extraction.text?.trim();
return text(
joinSections([
header,
extraction.note,
body ? joinSections([heading(3, 'Contents'), body]) : '_(the file contains no extractable text)_',
]),
);
}
return text(joinSections([header, extraction.note]));
} catch (error) {
return toToolError(error, `download file ${fileId}`);
}
},
);
}

217
src/tools/overview.ts Normal file
View File

@@ -0,0 +1,217 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../context.ts';
import { dueLabel, formatDate, heading, htmlToText, joinSections } from '../render.ts';
import type { CourseMetadata, TaskContent } from '../schulcloud/types.ts';
import { text, toToolError } from './result.ts';
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
export function registerOverviewTools(server: McpServer, context: ServerContext): void {
server.registerTool(
'whoami',
{
title: 'Who am I',
description:
'Identity of the Schulcloud account this server is authenticated as: name, school, roles and ' +
'permissions. Useful as a connectivity check and to know whether the account is a student or teacher ' +
'before interpreting other results.',
inputSchema: {},
annotations: READ_ONLY,
},
async () => {
try {
const me = await context.me();
return text(
joinSections([
heading(2, `${me.user.firstName} ${me.user.lastName}`),
[
`- User id: ${me.user.id}`,
`- School: ${me.school.name} (${me.school.id})`,
`- Roles: ${me.roles.map((role) => role.name).join(', ') || 'none'}`,
`- Instance: ${context.config.baseUrl}`,
`- Permissions: ${me.permissions.length}`,
].join('\n'),
]),
);
} catch (error) {
return toToolError(error, 'read the current user');
}
},
);
server.registerTool(
'list_courses',
{
title: 'List courses',
description:
'All courses ("Kurse") the account is enrolled in, with their ids. Start here when the user asks ' +
'about a subject by name — match the name to a course id, then call get_course to see its contents.',
inputSchema: {
limit: z.number().int().min(1).max(500).default(200).describe('Maximum number of courses to return.'),
activeOnly: z
.boolean()
.default(false)
.describe('Only courses whose date range covers today, i.e. currently running ones.'),
},
annotations: READ_ONLY,
},
async ({ limit, activeOnly }) => {
try {
const all = await context.client.listAllCourses(limit);
const courses = activeOnly ? all.filter(isCurrentlyRunning) : all;
if (courses.length === 0) {
return text(activeOnly ? 'No currently running courses.' : 'No courses found for this account.');
}
return text(
joinSections([
heading(2, `Courses (${courses.length}${activeOnly ? ` of ${all.length}` : ''})`),
courses.map(formatCourseLine).join('\n'),
'Use get_course with a course id to see its lessons, tasks and boards.',
]),
);
} catch (error) {
return toToolError(error, 'list courses');
}
},
);
server.registerTool(
'list_tasks',
{
title: 'List tasks',
description:
'Homework and assignments ("Aufgaben") across all courses, newest first, with due dates and ' +
'submission status. This is the tool for "what do I have to hand in". Task descriptions and ' +
'attachments come from get_task.',
inputSchema: {
scope: z
.enum(['open', 'finished'])
.default('open')
.describe('"open" = still outstanding; "finished" = archived/completed tasks.'),
limit: z.number().int().min(1).max(99).default(50).describe('Maximum number of tasks to return.'),
skip: z.number().int().min(0).default(0).describe('Number of tasks to skip, for paging.'),
},
annotations: READ_ONLY,
},
async ({ scope, limit, skip }) => {
try {
const page =
scope === 'finished'
? await context.client.listFinishedTasks({ limit, skip })
: await context.client.listTasks({ limit, skip });
if (page.data.length === 0) return text(`No ${scope} tasks.`);
const sorted = scope === 'open' ? [...page.data].sort(byDueDate) : page.data;
return text(
joinSections([
heading(2, `${scope === 'open' ? 'Open' : 'Finished'} tasks (${page.data.length} of ${page.total})`),
sorted.map(formatTaskLine).join('\n'),
'Use get_task with a task id for the full description and attachments.',
]),
);
} catch (error) {
return toToolError(error, `list ${scope} tasks`);
}
},
);
server.registerTool(
'get_dashboard',
{
title: 'Get dashboard',
description:
'The account\'s dashboard tiles, in the layout the user sees after logging in. Reflects which courses ' +
'the user has pinned and in what order — useful for "what am I currently taking" when list_courses ' +
'returns a long history.',
inputSchema: {},
annotations: READ_ONLY,
},
async () => {
try {
const dashboard = await context.client.getDashboard();
if (dashboard.gridElements.length === 0) return text('The dashboard is empty.');
const tiles = [...dashboard.gridElements]
.sort((a, b) => a.yPosition - b.yPosition || a.xPosition - b.xPosition)
.map((tile) => {
const group = tile.groupElements?.length
? ` — group of ${tile.groupElements.length}: ${tile.groupElements.map((child) => child.title).join(', ')}`
: '';
return `- **${tile.title}** (\`${tile.id}\`)${group}`;
});
return text(joinSections([heading(2, `Dashboard (${tiles.length} tiles)`), tiles.join('\n')]));
} catch (error) {
return toToolError(error, 'read the dashboard');
}
},
);
server.registerTool(
'list_news',
{
title: 'List news',
description: 'School and course announcements ("Neuigkeiten"), newest first.',
inputSchema: {
limit: z.number().int().min(1).max(50).default(20).describe('Maximum number of items to return.'),
skip: z.number().int().min(0).default(0).describe('Number of items to skip, for paging.'),
},
annotations: READ_ONLY,
},
async ({ limit, skip }) => {
try {
const page = await context.client.listNews({ limit, skip });
if (page.data.length === 0) return text('No news items.');
return text(
joinSections([
heading(2, `News (${page.data.length} of ${page.total})`),
page.data
.map((item) =>
joinSections([
heading(3, item.title),
`_${formatDate(item.displayAt)}_`,
htmlToText(item.content),
]),
)
.join('\n\n---\n\n'),
]),
);
} catch (error) {
return toToolError(error, 'list news');
}
},
);
}
function isCurrentlyRunning(course: CourseMetadata): boolean {
const now = Date.now();
const start = course.startDate ? new Date(course.startDate).getTime() : undefined;
const until = course.untilDate ? new Date(course.untilDate).getTime() : undefined;
if (start !== undefined && Number.isFinite(start) && start > now) return false;
if (until !== undefined && Number.isFinite(until) && until < now) return false;
return true;
}
function formatCourseLine(course: CourseMetadata): string {
const range =
course.startDate || course.untilDate
? `${formatDate(course.startDate).slice(0, 10)} to ${formatDate(course.untilDate).slice(0, 10)}`
: '';
return `- **${course.title}** (\`${course.id}\`)${course.isLocked ? ' [locked]' : ''}${range}`;
}
function formatTaskLine(task: TaskContent): string {
const course = task.courseName ? `${task.courseName}` : '';
const lesson = task.lessonName ? ` / ${task.lessonName}` : '';
const submitted = task.status.submitted > 0 ? 'submitted' : 'not submitted';
const graded = task.status.graded > 0 ? ', graded' : '';
return `- **${task.name}** (\`${task.id}\`)${course}${lesson}${dueLabel(task.dueDate)}, ${submitted}${graded}`;
}
function byDueDate(a: TaskContent, b: TaskContent): number {
// Tasks without a due date sort last; they are never urgent.
const left = a.dueDate ? new Date(a.dueDate).getTime() : Number.POSITIVE_INFINITY;
const right = b.dueDate ? new Date(b.dueDate).getTime() : Number.POSITIVE_INFINITY;
return left - right;
}

70
src/tools/raw.ts Normal file
View File

@@ -0,0 +1,70 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../context.ts';
import { text, failure, toToolError } from './result.ts';
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
/**
* Escape hatch for the parts of the API that have no dedicated tool.
*
* The instance exposes far more than this server models — groups, teams,
* external tools, school settings. Rather than guess at which of those matter,
* expose a GET-only passthrough and let the model reach them when asked.
* GET-only is the point: it keeps the whole server incapable of writing.
*/
export function registerRawTool(server: McpServer, context: ServerContext): void {
server.registerTool(
'api_get',
{
title: 'Raw API GET',
description:
'Performs an authenticated GET against an arbitrary path on this Schulcloud instance and returns the ' +
'JSON. For API surface the other tools do not cover (groups, teams, school info, tool configs). ' +
'Read-only: only GET is possible. The instance documents itself at /api/v3/docs-json and ' +
'/api/v3/file/docs-json — fetch those to discover paths.',
inputSchema: {
path: z
.string()
.describe('Path beginning with /api/, e.g. "/api/v3/groups/class" or "/api/v3/rooms".'),
maxChars: z
.number()
.int()
.min(500)
.max(200_000)
.default(20_000)
.describe('Truncate the JSON response to this many characters.'),
},
annotations: READ_ONLY,
},
async ({ path, maxChars }) => {
if (!path.startsWith('/api/')) {
return failure(`Path must start with /api/ — got "${path}".`);
}
// A path containing a scheme or authority would escape the configured
// instance entirely, sending the JWT somewhere it does not belong.
if (/^\/api\/\/|:\/\//.test(path)) {
return failure('Path must be a plain path on this instance, with no scheme or host.');
}
try {
const body = await context.client.getJson<unknown>(path);
const json = JSON.stringify(body, null, 2);
const truncated = json.length > maxChars;
return text(
[
`GET ${path} → 200`,
'```json',
truncated ? json.slice(0, maxChars) : json,
'```',
truncated ? `_(truncated from ${json.length} characters)_` : '',
]
.filter(Boolean)
.join('\n'),
);
} catch (error) {
return toToolError(error, `GET ${path}`);
}
},
);
}

42
src/tools/result.ts Normal file
View File

@@ -0,0 +1,42 @@
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { SchulcloudApiError } from '../schulcloud/client.ts';
export function text(body: string): CallToolResult {
return { content: [{ type: 'text', text: body }] };
}
export function failure(body: string): CallToolResult {
return { content: [{ type: 'text', text: body }], isError: true };
}
/**
* Turns a thrown error into a tool result the model can act on.
*
* The distinction that matters is 401 — an expired JWT is the one failure the
* user has to fix by hand, and it otherwise looks identical to "this course
* doesn't exist". 403 is separated out for the same reason: it means the
* account genuinely lacks access, not that the call was malformed.
*/
export function toToolError(error: unknown, action: string): CallToolResult {
if (error instanceof SchulcloudApiError) {
if (error.isAuthFailure) {
return failure(
`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.`,
);
}
if (error.status === 403) {
return failure(`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 failure(`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 failure(`Failed to ${action}: ${error instanceof Error ? error.message : String(error)}`);
}

234
src/tools/search.ts Normal file
View File

@@ -0,0 +1,234 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../context.ts';
import { heading, htmlToText, joinSections } from '../render.ts';
import { assembleBoard } from '../schulcloud/board.ts';
import type { CourseMetadata } from '../schulcloud/types.ts';
import { text, toToolError } from './result.ts';
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
interface Hit {
course: string;
courseId: string;
where: string;
/** Id the model should pass to a follow-up tool to see this hit in context. */
target: string;
targetTool: string;
snippet: string;
}
export function registerSearchTool(server: McpServer, context: ServerContext): void {
server.registerTool(
'search',
{
title: 'Search across courses',
description:
'Keyword search over course titles, board and card titles, board text, file names, lesson titles and ' +
'task names. The Schulcloud API has no search endpoint, so this walks the courses and matches ' +
'client-side: thorough, but it takes a few seconds. Use it when the user names a topic rather than a ' +
'course ("where is the stuff about encryption?"). Matching is case- and accent-insensitive.',
inputSchema: {
query: z.string().min(2).describe('Words to look for. All of them must appear somewhere in the item.'),
scope: z
.enum(['boards', 'everything'])
.default('boards')
.describe('"boards" searches course pages and column boards; "everything" also opens each lesson.'),
courseId: z.string().optional().describe('Restrict the search to a single course.'),
limit: z.number().int().min(1).max(100).default(30).describe('Maximum number of hits to return.'),
},
annotations: READ_ONLY,
},
async ({ query, scope, courseId, limit }) => {
try {
const terms = tokenize(query);
if (terms.length === 0) return text('Query contained no searchable words.');
const schoolId = await context.schoolId();
const courses = courseId
? [{ id: courseId, title: courseId } as CourseMetadata]
: await context.client.listAllCourses();
const hits: Hit[] = [];
await forEachLimited(courses, 6, async (course) => {
await searchCourse(context, schoolId, course, terms, scope, hits);
});
if (hits.length === 0) {
return text(
`No matches for "${query}" across ${courses.length} course(s).` +
(scope === 'boards' ? ' Try scope="everything" to also search inside lessons.' : ''),
);
}
const shown = hits.slice(0, limit);
return text(
joinSections([
heading(2, `${hits.length} match(es) for "${query}"${hits.length > shown.length ? `, showing ${shown.length}` : ''}`),
shown.map(formatHit).join('\n\n'),
]),
);
} catch (error) {
return toToolError(error, `search for "${query}"`);
}
},
);
}
async function searchCourse(
context: ServerContext,
schoolId: string,
course: CourseMetadata,
terms: string[],
scope: 'boards' | 'everything',
hits: Hit[],
): Promise<void> {
const page = await context.client.getCourseBoard(course.id).catch(() => undefined);
if (!page) return;
const courseTitle = page.title || course.title;
if (matches(courseTitle, terms)) {
hits.push({
course: courseTitle,
courseId: course.id,
where: 'course title',
target: course.id,
targetTool: 'get_course',
snippet: courseTitle,
});
}
const boardIds: string[] = [];
for (const element of page.elements) {
if (element.type === 'column-board') {
boardIds.push(element.content.id);
if (matches(element.content.title, terms)) {
hits.push({
course: courseTitle,
courseId: course.id,
where: 'board title',
target: element.content.id,
targetTool: 'get_board',
snippet: element.content.title,
});
}
} else if (element.type === 'task') {
const haystack = `${element.content.name} ${htmlToText(element.content.description)}`;
if (matches(haystack, terms)) {
hits.push({
course: courseTitle,
courseId: course.id,
where: 'task',
target: element.content.id,
targetTool: 'get_task',
snippet: snippet(haystack, terms),
});
}
} else if (element.type === 'lesson') {
if (matches(element.content.name, terms)) {
hits.push({
course: courseTitle,
courseId: course.id,
where: 'lesson title',
target: element.content.id,
targetTool: 'get_lesson',
snippet: element.content.name,
});
}
if (scope === 'everything') {
const lesson = await context.client.getLesson(element.content.id).catch(() => undefined);
const body = (lesson?.contents ?? [])
.map((entry) => `${entry.title ?? ''} ${htmlToText(String(entry.content?.text ?? ''))}`)
.join('\n');
if (body.trim() && matches(body, terms)) {
hits.push({
course: courseTitle,
courseId: course.id,
where: `lesson "${element.content.name}"`,
target: element.content.id,
targetTool: 'get_lesson',
snippet: snippet(body, terms),
});
}
}
}
}
await forEachLimited(boardIds, 4, async (boardId) => {
const board = await assembleBoard(context.client, boardId, schoolId, { resolveFiles: true }).catch(() => undefined);
if (!board) return;
for (const column of board.columns) {
for (const card of column.cards) {
const parts = [card.title];
for (const element of card.elements) {
if (element.text) parts.push(htmlToText(element.text));
if (element.url) parts.push(element.url);
for (const file of element.files) parts.push(file.name);
}
const haystack = parts.join('\n');
if (matches(haystack, terms)) {
hits.push({
course: courseTitle,
courseId: course.id,
where: `board "${board.title}" → card "${card.title}"`,
target: board.id,
targetTool: 'get_board',
snippet: snippet(haystack, terms),
});
}
}
}
});
}
function formatHit(hit: Hit): string {
return [
`- **${hit.course}** — ${hit.where}`,
` ${hit.snippet}`,
`\`${hit.targetTool}\` with id \`${hit.target}\``,
].join('\n');
}
/** Lowercases and strips diacritics so "Verschlusselung" finds "Verschlüsselung". */
function fold(value: string): string {
return value
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/ß/g, 'ss')
.toLowerCase();
}
function tokenize(query: string): string[] {
return fold(query)
.split(/[^\p{L}\p{N}]+/u)
.filter((token) => token.length >= 2);
}
function matches(haystack: string | undefined, terms: string[]): boolean {
if (!haystack) return false;
const folded = fold(haystack);
return terms.every((term) => folded.includes(term));
}
/** A one-line excerpt centred on the first matching term. */
function snippet(haystack: string, terms: string[], width = 180): string {
const flat = haystack.replace(/\s+/g, ' ').trim();
const folded = fold(flat);
const at = terms.map((term) => folded.indexOf(term)).filter((index) => index >= 0);
const centre = at.length > 0 ? Math.min(...at) : 0;
const start = Math.max(0, centre - width / 3);
const excerpt = flat.slice(start, start + width);
return `${start > 0 ? '…' : ''}${excerpt}${start + width < flat.length ? '…' : ''}`;
}
/** Runs `task` over `items` with at most `limit` in flight, preserving no order. */
async function forEachLimited<T>(items: T[], limit: number, task: (item: T) => Promise<void>): Promise<void> {
let cursor = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const item = items[cursor++];
if (item !== undefined) await task(item);
}
});
await Promise.all(workers);
}