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 { extractContent, formatBytes } from '../extract.ts'; import { formatDate, heading, joinSections } from '../render.ts'; import { FILE_PARENT_TYPES, type FileParentType } from '../schulcloud/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: '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).', 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 = await context.client.listFiles({ storageLocationId: schoolId, parentType, parentId }); if (page.data.length === 0) return text(`No files attached to ${parentType} ${parentId}.`); return text( joinSections([ heading(2, `Files on ${parentType} ${parentId} (${page.data.length})`), 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 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.', 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 = await context.client.downloadFile(record); 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)}`, 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'); 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)_', ]), ); } return text(joinSections([header, extraction.note])); } catch (error) { return toToolError(error, `download file ${fileId}`); } }, ); }