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; }, ): Promise { 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])); }