Initial schulcloud-mcp server

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

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

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

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

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

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

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

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

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