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:
32
src/bin/http.ts
Normal file
32
src/bin/http.ts
Normal 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
22
src/bin/stdio.ts
Normal 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
52
src/config.ts
Normal 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
38
src/context.ts
Normal 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
223
src/extract.ts
Normal 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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
|
||||
.replace(/&/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
50
src/http/auth.ts
Normal 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
144
src/http/server.ts
Normal 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
81
src/render.ts
Normal 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(/ /g, ' ')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/&/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
153
src/schulcloud/board.ts
Normal 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
315
src/schulcloud/client.ts
Normal 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
243
src/schulcloud/types.ts
Normal 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
45
src/server.ts
Normal 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
341
src/tools/content.ts
Normal 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
149
src/tools/files.ts
Normal 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
217
src/tools/overview.ts
Normal 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
70
src/tools/raw.ts
Normal 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
42
src/tools/result.ts
Normal 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
234
src/tools/search.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user