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>
219 lines
8.2 KiB
TypeScript
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]));
|
|
}
|