import type { FastMCP } from 'fastmcp'; import { UserError, imageContent } from 'fastmcp'; import { z } from 'zod'; import fs from 'node:fs'; import path from 'node:path'; import { pipeline } from 'node:stream/promises'; import { getDriveClient } from '../../clients.js'; import { requestClients } from '../../remoteWrapper.js'; import { createDownloadToken } from '../../downloadProxy.js'; const isRemote = process.env.MCP_TRANSPORT === 'httpStream'; export const WORKSPACE_EXPORT_DEFAULTS: Record = { 'application/vnd.google-apps.document': 'text/markdown', 'application/vnd.google-apps.spreadsheet': 'text/csv', 'application/vnd.google-apps.presentation': 'text/plain', 'application/vnd.google-apps.drawing': 'image/png', 'application/vnd.google-apps.script': 'application/vnd.google-apps.script+json', }; export const EXPORT_MIME_TO_EXTENSION: Record = { 'text/markdown': '.md', 'text/plain': '.txt', 'text/csv': '.csv', 'text/tab-separated-values': '.tsv', 'application/pdf': '.pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx', 'image/png': '.png', 'image/jpeg': '.jpg', 'application/vnd.google-apps.script+json': '.json', }; const MAX_TEXT_EXTRACT_BYTES = 50_000; export function isTextMimeType(mime: string): boolean { return ( mime.startsWith('text/') || mime === 'application/json' || mime === 'application/vnd.google-apps.script+json' ); } function isWorkspaceMimeType(mime: string): boolean { return mime.startsWith('application/vnd.google-apps.'); } function ensureWithinCwd(filePath: string): string { const cwd = path.resolve(process.cwd()); const resolved = path.resolve(filePath); if (!resolved.startsWith(cwd + path.sep) && resolved !== cwd) { throw new UserError('File path must be within the working directory.'); } return resolved; } const DownloadFileParameters = z.strictObject({ fileId: z.string().describe('The file ID from a Google Drive URL or previous tool result.'), savePath: z .string() .optional() .describe( 'Local file path to save the downloaded file to. Parent directories are created automatically. ' + "If omitted, saves to the current working directory using the file's original name " + '(with an appropriate extension for exported Google Workspace files).' ), exportMimeType: z .string() .optional() .describe( 'For Google Workspace files only: the MIME type to export as. ' + 'E.g. "text/markdown", "application/pdf", "text/csv". ' + 'Defaults: Docs->text/markdown, Sheets->text/csv, Slides->text/plain, Drawings->image/png.' ), extractText: z .boolean() .optional() .default(true) .describe( 'If true, return text content in the response (up to 50KB) alongside saving the file. ' + 'Works for text/*, application/json. For binary files, only the save path is returned.' ), returnAs: z .enum(['url', 'content']) .optional() .default('url') .describe( 'Remote mode only. ' + '"url" (default): returns a short-lived download URL -- use curl -o to save to disk. ' + '"content": returns file content inline (text, image, or base64 resource).' ), }); export function register(server: FastMCP) { server.addTool({ name: 'downloadFile', description: 'Downloads any file from Google Drive to a local path. ' + 'For Google Workspace files (Docs, Sheets, Slides), exports to a specified format ' + '(defaults to text/markdown for Docs, text/csv for Sheets). ' + 'For regular files (PDFs, images, etc.), downloads the raw bytes. ' + 'Optionally extracts and returns text content for text-based files.', parameters: DownloadFileParameters, execute: async (args, { log }) => { const drive = await getDriveClient(); log.info(`Downloading file ${args.fileId}${args.savePath ? ` to ${args.savePath}` : ''}`); let resolvedSavePath: string | undefined; try { // 1. Get file metadata const metadataRes = await drive.files.get({ fileId: args.fileId, fields: 'name,mimeType,size', supportsAllDrives: true, }); const fileName = path.basename(metadataRes.data.name || 'download'); const originalMimeType = metadataRes.data.mimeType || 'application/octet-stream'; const isWorkspace = isWorkspaceMimeType(originalMimeType); // 2. Resolve export MIME type for Workspace files let exportMime: string | undefined; if (isWorkspace) { exportMime = args.exportMimeType || WORKSPACE_EXPORT_DEFAULTS[originalMimeType]; if (!exportMime) { const shortType = originalMimeType.replace('application/vnd.google-apps.', ''); throw new UserError( `Unsupported Google Workspace type "${shortType}". ` + `Provide an exportMimeType parameter to specify the desired export format.` ); } } // ---------- Remote mode ---------- if (isRemote) { const resolvedFileName = isWorkspace && exportMime ? path.parse(fileName).name + (EXPORT_MIME_TO_EXTENSION[exportMime] || '') : fileName; const outputMime = exportMime || originalMimeType; // --- returnAs: "url" (default) -- agent uses curl to save to disk --- if (args.returnAs !== 'content') { const store = requestClients.getStore(); if (!store) throw new UserError('Request context missing.'); const token = createDownloadToken({ fileId: args.fileId, accessToken: store.accessToken, exportMime, fileName: resolvedFileName, mimeType: outputMime, isWorkspace, }); const result: Record = { downloadUrl: `${process.env.BASE_URL}/download/${token}`, fileName: resolvedFileName, originalMimeType, }; if (isWorkspace && exportMime) result.exportedAs = exportMime; if (args.extractText !== false && isTextMimeType(outputMime)) { try { const textRes = isWorkspace && exportMime ? await drive.files.export( { fileId: args.fileId, mimeType: exportMime }, { responseType: 'text' } ) : await drive.files.get( { fileId: args.fileId, alt: 'media', supportsAllDrives: true }, { responseType: 'text' } ); result.textContent = (textRes.data as string).slice(0, MAX_TEXT_EXTRACT_BYTES); } catch { log.warn?.('Could not extract text content'); } } return JSON.stringify(result, null, 2); } // --- returnAs: "content" -- inline via MCP content types --- let fileBuffer: Buffer; if (isWorkspace && exportMime) { log.info(`Exporting Workspace file as ${exportMime}`); const res = await drive.files.export( { fileId: args.fileId, mimeType: exportMime }, { responseType: 'arraybuffer' } ); fileBuffer = Buffer.from(res.data as ArrayBuffer); } else { log.info('Downloading blob file into memory'); const res = await drive.files.get( { fileId: args.fileId, alt: 'media', supportsAllDrives: true }, { responseType: 'arraybuffer' } ); fileBuffer = Buffer.from(res.data as ArrayBuffer); } const MAX_INLINE_BYTES = 100 * 1024 * 1024; const content: any[] = []; if (isTextMimeType(outputMime)) { content.push({ type: 'text' as const, text: fileBuffer.toString('utf-8').slice(0, MAX_TEXT_EXTRACT_BYTES), }); } else if (outputMime.startsWith('image/') && fileBuffer.length <= MAX_INLINE_BYTES) { content.push(await imageContent({ buffer: fileBuffer })); } else if (fileBuffer.length <= MAX_INLINE_BYTES) { content.push({ type: 'resource' as const, resource: { uri: `gdrive:///${args.fileId}/${resolvedFileName}`, blob: fileBuffer.toString('base64'), mimeType: outputMime, }, }); } else { throw new UserError( `File too large for inline transfer (${(fileBuffer.length / 1024 / 1024).toFixed(1)}MB, limit 100MB).` ); } content.push({ type: 'text' as const, text: JSON.stringify({ fileName: resolvedFileName, originalMimeType, sizeBytes: fileBuffer.length, }), }); return { content }; } // ---------- Stdio mode: write to local disk ---------- resolvedSavePath = args.savePath; if (resolvedSavePath) resolvedSavePath = ensureWithinCwd(resolvedSavePath); if (!resolvedSavePath) { if (isWorkspace && exportMime) { const baseName = path.parse(fileName).name; const ext = EXPORT_MIME_TO_EXTENSION[exportMime] || ''; resolvedSavePath = path.join(process.cwd(), baseName + ext); } else { resolvedSavePath = path.join(process.cwd(), fileName); } } resolvedSavePath = ensureWithinCwd(resolvedSavePath); fs.mkdirSync(path.dirname(resolvedSavePath), { recursive: true }); let outputMime: string; if (isWorkspace && exportMime) { log.info(`Exporting Workspace file as ${exportMime}`); const res = await drive.files.export( { fileId: args.fileId, mimeType: exportMime }, { responseType: 'stream' } ); await pipeline(res.data as any, fs.createWriteStream(resolvedSavePath)); outputMime = exportMime; } else { log.info(`Downloading blob file`); const res = await drive.files.get( { fileId: args.fileId, alt: 'media', supportsAllDrives: true }, { responseType: 'stream' } ); await pipeline(res.data as any, fs.createWriteStream(resolvedSavePath)); outputMime = originalMimeType; } const sizeBytes = fs.statSync(resolvedSavePath).size; let textContent: string | undefined; if (args.extractText !== false && isTextMimeType(outputMime)) { try { const raw = fs.readFileSync(resolvedSavePath, 'utf-8'); textContent = raw.slice(0, MAX_TEXT_EXTRACT_BYTES); } catch { log.warn?.(`Could not read text from ${resolvedSavePath}`); } } const result: Record = { savedTo: resolvedSavePath, fileName, originalMimeType, sizeBytes, }; if (isWorkspace && exportMime) result.exportedAs = exportMime; if (textContent !== undefined) result.textContent = textContent; return JSON.stringify(result, null, 2); } catch (error: any) { if (!isRemote && resolvedSavePath) { try { fs.unlinkSync(resolvedSavePath); } catch { /* file may not exist yet */ } } log.error(`Error downloading file ${args.fileId}: ${error.message || error}`); if (error instanceof UserError) throw error; if (error.code === 404) throw new UserError(`File not found (ID: ${args.fileId}). Check the file ID.`); if (error.code === 403) throw new UserError( `Permission denied for file ${args.fileId}. Ensure the authenticated user has access.` ); throw new UserError(`Failed to download file: ${error.message || 'Unknown error'}`); } }, }); }