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:
@@ -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);
|
||||
|
||||
@@ -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.`,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
401
src/mcp/tools/filesystem.ts
Normal 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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user