google-docs-mcp / src /tools /drive /searchDriveFiles.ts
iFightDucks's picture
Initial HF Space deploy: a-bonus/google-docs-mcp with HF metadata
7dc28be
import type { FastMCP } from 'fastmcp';
import { UserError } from 'fastmcp';
import { z } from 'zod';
import { getDriveClient } from '../../clients.js';
import { escapeDriveQuery } from '../../driveQueryUtils.js';
/**
* Convenience shortcuts for common MIME types.
* Users can also pass any full MIME type string directly.
*/
const MIME_TYPE_SHORTCUTS: Record<string, string> = {
document: 'application/vnd.google-apps.document',
spreadsheet: 'application/vnd.google-apps.spreadsheet',
presentation: 'application/vnd.google-apps.presentation',
folder: 'application/vnd.google-apps.folder',
form: 'application/vnd.google-apps.form',
pdf: 'application/pdf',
zip: 'application/zip',
};
export function register(server: FastMCP) {
server.addTool({
name: 'searchDriveFiles',
description:
'Searches across all file types in Google Drive by name or content. ' +
'Unlike searchDocuments (which only searches Google Docs), this tool finds Sheets, PDFs, ' +
'presentations, folders, and any other Drive file. Supports filtering by MIME type, ' +
'scoping to a specific folder subtree, controllable sort order, and pagination via pageToken.',
parameters: z.strictObject({
query: z.string().min(1).describe('Search term to find in file names or content.'),
searchIn: z
.enum(['name', 'content', 'both'])
.optional()
.default('both')
.describe(
'Where to search: "name" matches file titles only, "content" searches inside files, ' +
'"both" searches names and content (default).'
),
mimeType: z
.string()
.optional()
.describe(
'Restrict search to a specific file type. ' +
'Shortcuts: "document", "spreadsheet", "presentation", "folder", "form", "pdf", "zip". ' +
'Or pass a full MIME type string.'
),
folderId: z
.string()
.optional()
.describe(
'Restrict search to files inside this folder (and its subfolders). ' +
'Use "root" for the top-level Drive. Omit to search all of Drive.'
),
orderBy: z
.enum(['name', 'modifiedTime', 'createdTime'])
.optional()
.default('modifiedTime')
.describe('Field to sort results by.'),
sortDirection: z
.enum(['asc', 'desc'])
.optional()
.default('desc')
.describe('Sort direction: "asc" for oldest first, "desc" for newest first (default).'),
maxResults: z
.number()
.int()
.min(1)
.max(100)
.optional()
.default(10)
.describe('Maximum number of results to return per page (1-100).'),
modifiedAfter: z
.string()
.optional()
.describe(
'Only return files modified after this date (ISO 8601 format, e.g. "2024-01-01").'
),
pageToken: z
.string()
.optional()
.describe(
'Pagination token from a previous searchDriveFiles response. ' +
'Pass this to retrieve the next page of results.'
),
}),
execute: async (args, { log }) => {
const drive = await getDriveClient();
log.info(
`Searching Drive files for: "${args.query}" in ${args.searchIn}, ` +
`mimeType=${args.mimeType || 'any'}, folder=${args.folderId || 'all'}, ` +
`orderBy=${args.orderBy} ${args.sortDirection}`
);
try {
const conditions: string[] = ['trashed=false'];
// Search term
if (args.searchIn === 'name') {
conditions.push(`name contains '${escapeDriveQuery(args.query)}'`);
} else if (args.searchIn === 'content') {
conditions.push(`fullText contains '${escapeDriveQuery(args.query)}'`);
} else {
conditions.push(
`(name contains '${escapeDriveQuery(args.query)}' or fullText contains '${escapeDriveQuery(args.query)}')`
);
}
// Resolve MIME type shortcut or use value as-is
if (args.mimeType) {
const resolved = MIME_TYPE_SHORTCUTS[args.mimeType] ?? args.mimeType;
conditions.push(`mimeType='${escapeDriveQuery(resolved)}'`);
}
// Scope to a specific folder (searches within the folder subtree via fullText,
// but Drive API does not support recursive parent filtering natively — using
// ancestor query instead which covers all descendants)
if (args.folderId) {
conditions.push(`'${escapeDriveQuery(args.folderId)}' in ancestors`);
}
// Date filter
if (args.modifiedAfter) {
const cutoff = new Date(args.modifiedAfter).toISOString();
conditions.push(`modifiedTime > '${escapeDriveQuery(cutoff)}'`);
}
const queryString = conditions.join(' and ');
const orderByParam = args.sortDirection === 'desc' ? `${args.orderBy} desc` : args.orderBy;
const response = await drive.files.list({
q: queryString,
pageSize: args.maxResults,
orderBy: orderByParam,
pageToken: args.pageToken,
fields:
'nextPageToken,files(id,name,mimeType,size,modifiedTime,createdTime,webViewLink,owners(displayName,emailAddress),parents)',
supportsAllDrives: true,
includeItemsFromAllDrives: true,
});
const files = (response.data.files || []).map((file) => ({
id: file.id,
name: file.name,
mimeType: file.mimeType,
size: file.size != null ? Number(file.size) : null,
modifiedTime: file.modifiedTime,
createdTime: file.createdTime,
owner: file.owners?.[0]?.displayName || null,
url: file.webViewLink,
}));
const result: Record<string, unknown> = { files, total: files.length };
if (response.data.nextPageToken) {
result.nextPageToken = response.data.nextPageToken;
result.hasMore = true;
} else {
result.hasMore = false;
}
return JSON.stringify(result, null, 2);
} catch (error: any) {
log.error(`Error searching Drive files: ${error.message || error}`);
if (error.code === 403)
throw new UserError(
'Permission denied. Make sure you have granted Google Drive access to the application.'
);
throw new UserError(`Failed to search files: ${error.message || 'Unknown error'}`);
}
},
});
}