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