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>
This commit is contained in:
MechaCat02
2026-09-16 20:19:16 +02:00
parent 10c6544579
commit bed3923902
32 changed files with 2696 additions and 106 deletions

View File

@@ -4,6 +4,7 @@ import { ServerContext } from '../context.ts';
import type { Services } from '../services.ts';
import { registerContentTools } from './tools/content.ts';
import { registerFileTools } from './tools/files.ts';
import { registerFilesystemTools } from './tools/filesystem.ts';
import { registerOverviewTools } from './tools/overview.ts';
import { registerRawTool } from './tools/raw.ts';
import { registerIndexTools } from './tools/index-tools.ts';
@@ -26,6 +27,11 @@ How the content is organised, and the usual path through it:
- **Tasks** ("Aufgaben") — homework. list_tasks across all courses, get_task for one.
- **Files** hang off boards, lessons and tasks. Every listing shows file ids; download_file fetches one and
extracts its text (PDF, Word, Excel, PowerPoint, OpenDocument) or returns an image inline.
- **The file manager ("Dateien")** is a separate store with a real folder tree, browsed with the fs_* tools:
/my (Persönliche Dateien), /courses/<course> (Kurs-Dateien), /teams/<team> (Team-Dateien) and /shared
(Geteilte Dateien). **Many teachers put their material only here**, so when a course page looks empty or the
worksheets are not on its boards, look in /courses/<course name>. fs_list and fs_tree browse, fs_find finds by
name, fs_read opens a file. list_files and download_file do not see these files.
- **Submissions** ("Abgaben") — what the user handed in. get_task shows that task's submission: the files,
the graded flag, the grade, what the user wrote, and the teacher's written feedback. A grade is a
percentage (0-100) or absent — there is no textual grade — and teachers often grade with the written
@@ -51,6 +57,7 @@ export function createServer(config: Config, services?: Services): { server: Mcp
registerContentTools(server, context);
registerRoomTools(server, context);
registerFileTools(server, context);
registerFilesystemTools(server, context);
registerSearchTool(server, context);
registerSubmissionTools(server, context);
registerIndexTools(server, context);

View File

@@ -7,6 +7,7 @@ import { dueLabel, formatDate, heading, htmlToText, joinSections, normalizeObjec
import { assembleBoard, type AssembledBoard, type AssembledElement } from '../../core/board.ts';
import { forEachLimited } from '../../core/crawl.ts';
import { fetchLessonPadText } from '../../core/etherpad.ts';
import type { FmListing } from '../../core/legacy-files.ts';
import { fetchLessonTaskLinks, withScrapedIds } from '../../core/lesson-page.ts';
import type {
CourseBoardResponse,
@@ -38,17 +39,21 @@ export function registerContentTools(server: McpServer, context: ServerContext):
},
async ({ courseId }) => {
try {
const [board, legacy] = await Promise.all([
const [board, legacy, courseFiles] = await Promise.all([
context.client.getCourseBoard(courseId),
// The v3 projection carries no description, teachers, members or
// timetable; /api/v1/courses still does. Optional on purpose — it
// is a legacy route, so its absence must cost detail, not the call.
context.client.getLegacyCourse(courseId).catch(() => undefined),
// The course's file-manager area is a different store from the page.
// Teachers who only upload files there leave the page itself empty,
// and reporting "empty" then sends the reader away from the material.
context.files.list({ area: 'courses', ownerId: courseId }).catch(() => undefined),
]);
const teachers = legacy
? await context.resolveNames([...(legacy.teacherIds ?? []), ...(legacy.substitutionIds ?? [])])
: { names: [], unresolved: 0 };
return text(formatCourseBoard(board, legacy, teachers));
return text(formatCourseBoard(board, legacy, teachers, courseFiles));
} catch (error) {
return toToolError(error, `read course ${courseId}`);
}
@@ -266,6 +271,7 @@ function formatCourseBoard(
board: CourseBoardResponse,
legacy?: LegacyCourse,
teachers: { names: string[]; unresolved: number } = { names: [], unresolved: 0 },
courseFiles?: FmListing,
): string {
const boards: string[] = [];
const lessons: string[] = [];
@@ -293,8 +299,18 @@ function formatCourseBoard(
formatCourseTimes(legacy?.times),
]);
const filesSection = formatCourseFiles(board.roomId, courseFiles);
if (boards.length + lessons.length + tasks.length === 0) {
return joinSections([heading(2, board.title), about, 'This course page is empty.']);
return joinSections([
heading(2, board.title),
`Course id: \`${board.roomId}\``,
about,
filesSection
? 'No boards, topics or tasks on the course page — the material is in the course files instead.'
: 'This course page is empty, and the course has no files in the file manager either.',
filesSection,
]);
}
return joinSections([
@@ -304,6 +320,25 @@ function formatCourseBoard(
boards.length > 0 && joinSections([heading(3, `Boards (${boards.length})`), boards.join('\n'), 'Read one with get_board.']),
lessons.length > 0 && joinSections([heading(3, `Topics (${lessons.length})`), lessons.join('\n'), 'Read one with get_lesson.']),
tasks.length > 0 && joinSections([heading(3, `Tasks (${tasks.length})`), tasks.join('\n'), 'Read one with get_task.']),
filesSection,
]);
}
/**
* The course's own file-manager area ("Kurs-Dateien"), when it holds anything.
*
* Only the top level is fetched — one page — so this says how much is there
* and where, rather than listing it; fs_tree does that.
*/
function formatCourseFiles(courseId: string, listing: FmListing | undefined): string | undefined {
if (!listing || listing.directories.length + listing.files.length === 0) return undefined;
const names = [...listing.directories.map((entry) => `${entry.name}/`), ...listing.files.map((entry) => entry.name)];
const shown = names.slice(0, 8).map((name) => `- ${name}`).join('\n');
return joinSections([
heading(3, 'Course files (Kurs-Dateien)'),
`${listing.directories.length} folder(s) and ${listing.files.length} file(s) at the top level, newest first:`,
shown + (names.length > 8 ? `\n- … and ${names.length - 8} more` : ''),
`See everything with fs_tree path "/courses/${courseId}", read one with fs_read.`,
]);
}

View File

@@ -2,6 +2,7 @@ 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';
@@ -16,9 +17,11 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
{
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).',
'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[]])
@@ -61,9 +64,10 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
{
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.',
'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
@@ -116,76 +120,99 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
.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)_',
]),
);
}
// 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.
if (record.previewStatus === 'preview_possible') {
const preview = await context.client.getFilePreview(record, 500).catch(() => undefined);
if (preview && preview.mimeType.startsWith('image/')) {
const result: CallToolResult = {
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 download_file with raw=true to get the bytes.', ''),
"Showing the instance's own rendered preview below, which is readable as a picture.",
]),
},
{ type: 'image', data: preview.bytes.toString('base64'), mimeType: preview.mimeType },
],
};
return result;
}
}
return text(joinSections([header, extraction.note]));
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]));
}

401
src/mcp/tools/filesystem.ts Normal file
View File

@@ -0,0 +1,401 @@
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 { formatBytes } from '../../core/extract.ts';
import {
areaInfo,
compareNames,
FILE_AREAS,
FileManagerMarkupError,
FsError,
nameMatcher,
type DirectoryRef,
type FmFile,
type FsNode,
type WalkEntry,
} from '../../core/legacy-files.ts';
import { heading, joinSections } from '../../core/text.ts';
import { renderFileContent } from './files.ts';
import { failure, text, toToolError } from './result.ts';
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
/**
* The "Dateien" file manager as filesystem tools: ls, tree, find, read.
*
* Deliberately separate from list_files / download_file, which read
* files-storage — board, topic and task attachments. The two stores do not
* overlap, and conflating them is how a course holding dozens of worksheets
* came to be reported as having 0 files.
*/
const AREA_NOTE =
'The file manager ("Dateien") is separate from course pages and holds four areas: ' +
'/my (Persönliche Dateien), /courses/<course name> (Kurs-Dateien), /teams/<team name> (Team-Dateien) and ' +
'/shared (Geteilte Dateien). Many teachers keep their material only in Kurs-Dateien, so a course whose page ' +
'looks empty often has its worksheets here.';
const PATH_NOTE =
'Paths use the names shown in listings, e.g. "/courses/FIA24B - LF2 (Rh)/Handlungssituation". Names may ' +
'contain "/" and still resolve; any segment may also be the id printed next to it, which is never ambiguous.';
export function registerFilesystemTools(server: McpServer, context: ServerContext): void {
server.registerTool(
'fs_list',
{
title: 'List a folder in the file manager',
description:
`Lists one folder of the Schulcloud file manager, like \`ls\`. ${AREA_NOTE} Start at "/" or ` +
'"/courses" to see what exists. Given a file path instead, shows that file\'s details. ' +
`${PATH_NOTE} Not for attachments on boards, topics or tasks — get_board, get_lesson and get_task list ` +
'those, and download_file reads them.',
inputSchema: {
path: z.string().default('/').describe('Folder to list, e.g. "/", "/courses", "/courses/<course>/<folder>".'),
},
annotations: READ_ONLY,
},
async ({ path }) => {
try {
const node = await context.files.resolve(path);
if (node.kind === 'file') return text(describeFile(node));
return text(await listDirectory(context, node));
} catch (error) {
return fsError(error, `list ${path}`);
}
},
);
server.registerTool(
'fs_tree',
{
title: 'Show a folder tree in the file manager',
description:
`Everything below a folder of the Schulcloud file manager, as an indented tree, like \`tree\`. ${AREA_NOTE} ` +
'Use it to get an overview of a course\'s files in one call — "/courses/<course>" — or of all course ' +
'files at a shallow depth. Each folder costs one page load, so the walk stops at `maxFolders` and says ' +
'so; narrow the path or lower the depth rather than raising the limit. To look for a name, fs_find is ' +
`cheaper. ${PATH_NOTE}`,
inputSchema: {
path: z.string().default('/').describe('Folder to start from.'),
depth: z.number().int().min(1).max(8).default(3).describe('How many levels below the folder to show.'),
maxFolders: z
.number()
.int()
.min(1)
.max(400)
.default(80)
.describe('Stop after listing this many folders.'),
},
annotations: READ_ONLY,
},
async ({ path, depth, maxFolders }) => {
try {
const node = await context.files.resolve(path);
if (node.kind === 'file') return text(describeFile(node));
const result = await context.files.walk(node, { maxDepth: depth, maxDirectories: maxFolders });
return text(renderTree(node, result.entries, { depth, maxFolders, ...result }));
} catch (error) {
return fsError(error, `walk ${path}`);
}
},
);
server.registerTool(
'fs_find',
{
title: 'Find files by name in the file manager',
description:
`Finds files and folders by name anywhere below a folder of the Schulcloud file manager, like \`find\`. ` +
`${AREA_NOTE} Without wildcards it matches any part of the name, case-insensitively. With "*" or "?" the ` +
'whole name must match, as with find -name — so "*.docx", or "*Erben*" for names containing Erben. Scope it with ' +
'`path` (e.g. "/courses/<course>") whenever you know the course: searching all of /courses walks every ' +
'folder of every course. This matches names only — to search inside documents use search, which ' +
`covers file-manager files once they are indexed. ${PATH_NOTE}`,
inputSchema: {
name: z
.string()
.min(1)
.describe('Part of the name ("Erbrecht"), or a whole-name pattern with * and ? ("*.docx", "*Erben*").'),
path: z.string().default('/').describe('Folder to search below. Default: every area.'),
type: z.enum(['any', 'file', 'folder']).default('any').describe('Only files, only folders, or both.'),
maxFolders: z
.number()
.int()
.min(1)
.max(600)
.default(250)
.describe('Stop after listing this many folders.'),
},
annotations: READ_ONLY,
},
async ({ name, path, type, maxFolders }) => {
try {
const node = await context.files.resolve(path);
if (node.kind === 'file') return text(describeFile(node));
const matcher = nameMatcher(name);
const result = await context.files.walk(node, { maxDepth: 12, maxDirectories: maxFolders });
const hits = result.entries
.filter((entry) => (type === 'file' ? entry.file : type === 'folder' ? entry.directory : true))
.filter((entry) => matcher((entry.file ?? entry.directory)?.name ?? ''))
.sort((a, b) => compareNames(a.path, b.path));
const scope = `${result.visited} folder(s) searched`;
const notes = [
result.truncated
? `_Stopped after ${maxFolders} folders, so there may be more matches. Narrow \`path\` to one course._`
: undefined,
failureNote(result.failures),
];
if (hits.length === 0) {
return text(joinSections([`No names matching "${name}" below ${node.path} (${scope}).`, ...notes]));
}
return text(
joinSections([
heading(2, `${hits.length} match(es) for "${name}" below ${node.path}`),
hits.map((entry) => entryLine(entry, { fullPath: true })).join('\n'),
`_${scope}._ Read a file with fs_read, open a folder with fs_list.`,
...notes,
]),
);
} catch (error) {
return fsError(error, `search ${path}`);
}
},
);
server.registerTool(
'fs_read',
{
title: 'Read a file from the file manager',
description:
`Fetches one file from the Schulcloud file manager and returns its contents, like \`cat\`. ${AREA_NOTE} ` +
'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 for base64 bytes. Give the path ' +
'from a listing, or the file id and name. Not for board, topic or task attachments — use download_file ' +
`for those. ${PATH_NOTE}`,
inputSchema: {
path: z.string().optional().describe('File path, e.g. "/courses/<course>/<folder>/Arbeitsblatt.pdf".'),
fileId: z.string().optional().describe('File id from a listing, instead of a path.'),
name: z.string().optional().describe('The file name, when giving fileId; used to recognise the format.'),
raw: z.boolean().default(false).describe('Return base64-encoded bytes instead of extracted text.'),
maxChars: z
.number()
.int()
.min(500)
.max(500_000)
.optional()
.describe('Override the character limit on extracted text.'),
},
annotations: READ_ONLY,
},
async ({ path, fileId, name, raw, maxChars }) => {
try {
let file: Pick<FmFile, 'id' | 'name'> & Partial<FmFile>;
let where: string;
if (path) {
const node = await context.files.resolve(path);
if (node.kind !== 'file') {
return failure(`${node.path} is a folder, not a file. List it with fs_list, or use fs_tree.`);
}
file = node.file;
where = node.path;
} else if (fileId) {
if (!/^[0-9a-f]{24}$/i.test(fileId)) return failure(`"${fileId}" is not a file id.`);
file = { id: fileId, name: name?.trim() || fileId };
where = `file \`${fileId}\``;
} else {
return failure('Give either `path` or `fileId`.');
}
// The instance scans uploads; a file it rejected is not served.
if (file.blocked) {
return failure(`"${file.name}" was blocked by the instance's virus scanner and will not be downloaded.`);
}
const downloaded = await context.files.download(file);
const header = [
heading(2, file.name),
[
`- Path: ${where}`,
`- File id: \`${file.id}\``,
`- Type: ${file.mimeType ?? downloaded.mimeType}`,
`- Size: ${formatBytes(file.size ?? downloaded.bytes.length)}`,
downloaded.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, downloaded, {
name: file.name,
mimeType: file.mimeType,
raw,
maxChars,
});
} catch (error) {
return fsError(error, `read ${path ?? fileId}`);
}
},
);
}
async function listDirectory(context: ServerContext, node: Extract<FsNode, { kind: 'directory' }>): Promise<string> {
if (!node.ref.area) {
return joinSections([
heading(2, '/ — the file manager ("Dateien")'),
FILE_AREAS.map((entry) => `- **/${entry.area}/** — ${entry.label}: ${entry.summary}`).join('\n'),
'Open one with fs_list, e.g. path "/courses". A course\'s own files are under "/courses/<course name>".',
]);
}
const listing = await context.files.list(node.ref);
const area = areaInfo(node.ref.area);
const isOwnerList = (node.ref.area === 'courses' || node.ref.area === 'teams') && !node.ref.ownerId;
const directories = [...listing.directories].sort((a, b) => compareNames(a.name, b.name));
const files = [...listing.files].sort((a, b) => compareNames(a.name, b.name));
const title = heading(2, `${node.path}${area.label}`);
if (directories.length === 0 && files.length === 0) {
return joinSections([
title,
isOwnerList
? `No ${node.ref.area === 'courses' ? 'courses' : 'teams'} with a file area.`
: node.ref.area === 'shared'
? 'Nothing has been shared with you.'
: 'This folder is empty.',
]);
}
const lines = [
...directories.map((directory) => `- **${directory.name}/** (\`${directory.id}\`)`),
...files.map((file) => `- ${fileLine(file)}`),
];
const bytes = files.reduce((sum, file) => sum + file.size, 0);
const summary = isOwnerList
? `${directories.length} ${node.ref.area === 'courses' ? 'course' : 'team'}(s). Their files are inside; fs_tree with depth 2 shows which hold any.`
: `${directories.length} folder(s), ${files.length} file(s)${files.length ? `, ${formatBytes(bytes)}` : ''}.`;
return joinSections([
title,
lines.join('\n'),
summary,
node.ref.area === 'shared' && directories.length > 0
? '_Shared folders cannot be opened — the file manager has no route for them, not even in the browser._'
: undefined,
'Open a folder with fs_list (its name or id appended to this path), read a file with fs_read.',
]);
}
function describeFile(node: Extract<FsNode, { kind: 'file' }>): string {
return joinSections([
heading(2, node.file.name),
[
`- Path: ${node.path}`,
`- File id: \`${node.file.id}\``,
`- Type: ${node.file.mimeType ?? 'unknown'}`,
`- Size: ${formatBytes(node.file.size)}`,
node.file.blocked ? '- **Blocked by the instance virus scanner; it cannot be downloaded.**' : undefined,
]
.filter(Boolean)
.join('\n'),
node.file.blocked ? undefined : 'Read it with fs_read.',
]);
}
function fileLine(file: FmFile): string {
const type = file.mimeType ? `, ${file.mimeType}` : '';
const blocked = file.blocked ? ' **[blocked by virus scan]**' : '';
return `${file.name}${formatBytes(file.size)}${type} (\`${file.id}\`)${blocked}`;
}
function entryLine(entry: WalkEntry, options: { fullPath: boolean }): string {
const label = options.fullPath ? entry.path : (entry.file ?? entry.directory)?.name;
if (entry.directory) return `- **${label}/** (\`${entry.directory.id}\`)`;
if (entry.file) return `- ${fileLine({ ...entry.file, name: label ?? entry.file.name })}`;
return `- ${label}`;
}
/**
* An indented tree, built from each entry's parent rather than from sorting
* path strings — see `FileManager.walk` for why that distinction matters.
*/
function renderTree(
root: { path: string; ref: DirectoryRef },
entries: WalkEntry[],
info: { depth: number; maxFolders: number; visited: number; truncated: boolean; failures: { path: string; reason: string }[] },
): string {
const children = new Map<string, WalkEntry[]>();
for (const entry of entries) {
const list = children.get(entry.parentPath) ?? [];
list.push(entry);
children.set(entry.parentPath, list);
}
const lines: string[] = [];
let files = 0;
let folders = 0;
let bytes = 0;
const visit = (path: string, indent: string) => {
const kids = children.get(path) ?? [];
// The areas under "/" keep their own order (personal, courses, teams,
// shared); everything else sorts folders first, then by name.
if (path !== '/') {
kids.sort((a, b) => {
if (Boolean(a.directory) !== Boolean(b.directory)) return a.directory ? -1 : 1;
return compareNames(a.path, b.path);
});
}
for (const kid of kids) {
if (kid.directory) {
folders++;
// An area's "id" is its slug, not an id anything accepts; leave it out.
const id = /^[0-9a-f]{24}$/i.test(kid.directory.id) ? ` \`${kid.directory.id}\`` : '';
lines.push(`${indent}${kid.directory.name}/${id}`);
visit(kid.path, `${indent} `);
} else if (kid.file) {
files++;
bytes += kid.file.size;
const blocked = kid.file.blocked ? ' [blocked]' : '';
lines.push(`${indent}${kid.file.name} (${formatBytes(kid.file.size)}) \`${kid.file.id}\`${blocked}`);
}
}
};
visit(root.path, '');
const area = root.ref.area ? `${areaInfo(root.ref.area).label}` : '';
if (lines.length === 0) {
return joinSections([heading(2, `${root.path}${area}`), 'Nothing below this folder.', failureNote(info.failures)]);
}
return joinSections([
heading(2, `${root.path}${area}`),
['```', ...lines, '```'].join('\n'),
`${folders} folder(s), ${files} file(s), ${formatBytes(bytes)}${info.visited} folder(s) listed, ${info.depth} level(s) deep.`,
info.truncated
? `_Stopped after listing ${info.maxFolders} folders; the tree is incomplete. Start deeper, e.g. at one course._`
: undefined,
failureNote(info.failures),
'Read a file with fs_read (path = this folder plus the names above).',
]);
}
function failureNote(failures: { path: string; reason: string }[]): string | undefined {
if (failures.length === 0) return undefined;
const shown = failures.slice(0, 5).map((entry) => `${entry.path} (${entry.reason})`).join('; ');
return `_Could not list ${failures.length} folder(s): ${shown}${failures.length > 5 ? '; …' : ''}._`;
}
function fsError(error: unknown, action: string): CallToolResult {
if (error instanceof FsError) return failure(error.message);
if (error instanceof FileManagerMarkupError) {
return failure(
`Could not ${action}: the file manager page did not look like a file listing. Either the session is no ` +
'longer accepted (check whoami) or the page markup changed.',
);
}
return toToolError(error, action);
}

View File

@@ -1,6 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../../context.ts';
import { FILE_AREAS } from '../../core/legacy-files.ts';
import { crawl } from '../../core/crawl.ts';
import { searchSnapshot, type Hit } from '../../core/match.ts';
import { formatDate, heading, joinSections } from '../../core/text.ts';
@@ -144,14 +145,37 @@ function targetIdFor(hit: SearchResult): string {
return hit.nodeId;
}
/** Where to go next for a hit. File-manager files are read by path, not by download_file. */
function nextStep(hit: SearchResult): string {
if (hit.kind === 'file' && hit.meta?.source === 'file-manager') {
const fsPath = typeof hit.meta.fsPath === 'string' ? hit.meta.fsPath : undefined;
return fsPath
? `\`fs_read\` with path \`${fsPath}\``
: `\`fs_read\` with fileId \`${hit.nodeId}\` and name \`${hit.title}\``;
}
// A submission has no id of its own that any tool takes: get_task is
// reached through the *task*, so point at that rather than at the
// submission id, which would simply 404.
return `\`${TOOL_FOR[hit.kind] ?? 'api_get'}\` with id \`${targetIdFor(hit)}\``;
}
/** "Kurs-Dateien, <course>" or the area's own name, from the file's fs path. */
function fileManagerPlace(hit: SearchResult): string {
const area = typeof hit.meta?.fsPath === 'string' ? hit.meta.fsPath.split('/')[1] : undefined;
const known = FILE_AREAS.find((entry) => entry.area === area);
if (!known) return 'the file manager';
return known.area === 'courses' && hit.courseTitle ? `${known.label}, ${hit.courseTitle}` : known.label;
}
function formatIndexed(hit: SearchResult): string {
const where =
hit.kind === 'file' && hit.meta?.source === 'file-manager'
? `file in ${fileManagerPlace(hit)}`
: `${hit.kind} in ${hit.courseTitle || hit.path}`;
return [
`- **${hit.title}** — ${hit.kind} in ${hit.courseTitle || hit.path}`,
`- **${hit.title}** — ${where}`,
hit.snippet && hit.snippet !== hit.title ? ` ${hit.snippet}` : undefined,
// A submission has no id of its own that any tool takes: get_task is
// reached through the *task*, so point at that rather than at the
// submission id, which would simply 404.
`\`${TOOL_FOR[hit.kind] ?? 'api_get'}\` with id \`${targetIdFor(hit)}\``,
nextStep(hit),
]
.filter(Boolean)
.join('\n');