Files
Schulcloud-MCP/src/mcp/tools/files.ts
MechaCat02 bed3923902 Browse the file manager ("Dateien") as a filesystem
Many teachers never use topics or boards; their material sits in the
course's file area, and the tools answered "0 files" for courses holding
dozens of worksheets — 21 of 26 courses on the live account. Persönliche,
Kurs-, Team- and Geteilte Dateien live in the legacy file store, not in
files-storage, and its service is not in the public ingress. The only way in
is the legacy client: HTML listings, and GET /files/signedurl for a
pre-signed download.

core/legacy-files.ts turns that into one path tree — /my, /courses/<course>,
/teams/<team>, /shared — resolving names that contain "/", ids anywhere in a
path, and wrong or ambiguous names with a message saying what is there. A
listing that does not parse throws; it never reads as an empty folder.

Some of the legacy client's GET routes write (GET /files/share/ mints a
share token), so getFileManagerPage allows only the listing routes, by
pattern. Signed URLs are fetched with no credentials and must be https.

- MCP: fs_list, fs_tree, fs_find and fs_read; get_course lists course files.
- CLI: schulcloud fs ls, tree, find and get, recursive and resumable.
- API: /api/fs/list, tree, find and file.
- Index: the crawl walks the file manager (INDEX_FILE_MANAGER, on by
  default), so search covers the text inside those files and sync mirrors
  them under <course>/Kurs-Dateien.

The local instance gains a fixture for all four areas. It needed a loopback,
so signed URLs open from the host, and a pre-created bucket, since MinIO
does not implement PutBucketCors.

135 tests. Smoke 55/55 live; 57/57 and 55/55 on the local instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:16 +02:00

219 lines
8.2 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 type { DownloadedFile } from '../../core/client.ts';
import { extractContent, formatBytes } from '../../core/extract.ts';
import { formatDate, heading, joinSections } from '../../core/text.ts';
import { FILE_PARENT_TYPES, type FileParentType } from '../../core/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:
'Attachments on one entity in files-storage: a board element, a topic, a task, a submission. Most of the ' +
'time you do not need this — get_board, get_lesson and get_task already list their own attachments. ' +
'**Not for a course\'s files, personal files, team files or shared files**: those live in the file ' +
'manager ("Dateien"), a separate store this tool cannot see — it answers 0 for a course holding dozens of ' +
'worksheets. Use fs_list, fs_tree, fs_find and fs_read for them.',
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, stats] = await Promise.all([
context.client.listFiles({ storageLocationId: schoolId, parentType, parentId }),
// Cheap, and it is the only way to see that a parent holds files
// the listing paged past.
context.client.getParentFileStats(parentType, parentId).catch(() => undefined),
]);
if (page.data.length === 0) return text(`No files attached to ${parentType} ${parentId}.`);
const total =
stats && stats.fileCount > page.data.length
? `${stats.fileCount} in total, ${formatBytes(stats.totalSizeInBytes)}`
: stats
? `${formatBytes(stats.totalSizeInBytes)} in total`
: '';
return text(
joinSections([
heading(2, `Files on ${parentType} ${parentId} (${page.data.length})${total}`),
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 board, topic or task attachment 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. For files from the ' +
'file manager (Persönliche Dateien, Kurs-Dateien, Team-Dateien, Geteilte Dateien) use fs_read instead.',
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, uploader] = await Promise.all([
context.client.downloadFile(record),
// Who put the file there is often the quickest way to tell a
// teacher's material apart from a classmate's upload.
record.creatorId ? context.userName(record.creatorId) : Promise.resolve(undefined),
]);
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)}${uploader ? ` by ${uploader}` : ''}`,
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');
return await renderFileContent(context, header, file, {
name: record.name,
mimeType: record.mimeType,
raw,
maxChars,
// Nothing extractable — but files-storage may still be able to render
// the file as a picture. That is the whole answer for an image-only
// PDF: its pages *are* pictures, so a rasterised preview is readable
// where the bytes are not, and it needs no OCR on our side.
fallbackImage:
record.previewStatus === 'preview_possible'
? async () => {
const preview = await context.client.getFilePreview(record, 500).catch(() => undefined);
return preview && preview.mimeType.startsWith('image/') ? preview : undefined;
}
: undefined,
});
} catch (error) {
return toToolError(error, `download file ${fileId}`);
}
},
);
}
/**
* Renders a downloaded file for a tool result: base64 when asked for, an image
* inline, extracted text, or — for a format with no extractor — its note.
*
* Shared by download_file (files-storage) and fs_read (the file manager), which
* differ only in how the bytes were obtained and in what the header says.
* `fallbackImage` is download_file's preview route; the file manager has none.
*/
export async function renderFileContent(
context: ServerContext,
header: string,
file: DownloadedFile,
options: {
name: string;
mimeType?: string;
raw: boolean;
maxChars?: number;
fallbackImage?: () => Promise<DownloadedFile | undefined>;
},
): Promise<CallToolResult> {
if (options.raw) {
return text(joinSections([header, `Base64 (${file.bytes.length} bytes):`, '```', file.bytes.toString('base64'), '```']));
}
const extraction = await extractContent(
file.bytes,
file.mimeType && file.mimeType !== 'application/octet-stream' ? file.mimeType : (options.mimeType ?? file.mimeType),
options.name,
options.maxChars ?? context.config.maxExtractedChars,
);
if (extraction.kind === 'image' && extraction.image) {
return {
content: [
{ type: 'text', text: joinSections([header, extraction.note]) },
{ type: 'image', data: extraction.image.base64, mimeType: extraction.image.mimeType },
],
};
}
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)_',
]),
);
}
const image = await options.fallbackImage?.();
if (image) {
return {
content: [
{
type: 'text',
text: joinSections([
header,
// The note ends by suggesting raw bytes, which is no longer the
// best answer once a readable rendering is attached.
extraction.note.replace(/ Use \w+ with raw=true to get the bytes\./, ''),
"Showing the instance's own rendered preview below, which is readable as a picture.",
]),
},
{ type: 'image', data: image.bytes.toString('base64'), mimeType: image.mimeType },
],
};
}
return text(joinSections([header, extraction.note]));
}