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>
150 lines
5.1 KiB
TypeScript
150 lines
5.1 KiB
TypeScript
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}`);
|
|
}
|
|
},
|
|
);
|
|
}
|