@triffon/google-docs-mcp 1.11.3-triffon
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +7 -0
- package/README.md +567 -0
- package/dist/auth.js +327 -0
- package/dist/cachedToolsList.js +38 -0
- package/dist/clients.js +180 -0
- package/dist/config.js +9 -0
- package/dist/downloadProxy.js +82 -0
- package/dist/driveQueryUtils.js +7 -0
- package/dist/firestoreTokenStorage.js +32 -0
- package/dist/googleDocsApiHelpers.js +1257 -0
- package/dist/googleSheetsApiHelpers.js +849 -0
- package/dist/index.js +240 -0
- package/dist/landingPage.js +103 -0
- package/dist/logger.js +58 -0
- package/dist/markdown-transformer/docsToMarkdown.js +259 -0
- package/dist/markdown-transformer/index.js +128 -0
- package/dist/markdown-transformer/markdownToDocs.js +834 -0
- package/dist/remoteWrapper.js +72 -0
- package/dist/tools/calendar/createEvent.js +89 -0
- package/dist/tools/calendar/deleteEvent.js +49 -0
- package/dist/tools/calendar/helpers.js +19 -0
- package/dist/tools/calendar/index.js +12 -0
- package/dist/tools/calendar/listEvents.js +84 -0
- package/dist/tools/calendar/quickAddEvent.js +55 -0
- package/dist/tools/calendar/updateEvent.js +84 -0
- package/dist/tools/docs/addTab.js +84 -0
- package/dist/tools/docs/appendTableRows.js +89 -0
- package/dist/tools/docs/appendToGoogleDoc.js +85 -0
- package/dist/tools/docs/cloneTable.js +159 -0
- package/dist/tools/docs/comments/addComment.js +83 -0
- package/dist/tools/docs/comments/deleteComment.js +30 -0
- package/dist/tools/docs/comments/getComment.js +45 -0
- package/dist/tools/docs/comments/index.js +14 -0
- package/dist/tools/docs/comments/listComments.js +43 -0
- package/dist/tools/docs/comments/replyToComment.js +35 -0
- package/dist/tools/docs/comments/resolveComment.js +55 -0
- package/dist/tools/docs/deleteRange.js +61 -0
- package/dist/tools/docs/deleteTableRows.js +62 -0
- package/dist/tools/docs/findAndReplace.js +54 -0
- package/dist/tools/docs/findElement.js +43 -0
- package/dist/tools/docs/findSectionsByHeading.js +46 -0
- package/dist/tools/docs/formatting/applyParagraphStyle.js +83 -0
- package/dist/tools/docs/formatting/applyTextStyle.js +49 -0
- package/dist/tools/docs/formatting/batchApplyTextStyle.js +86 -0
- package/dist/tools/docs/formatting/index.js +16 -0
- package/dist/tools/docs/formatting/updateTableBorders.js +81 -0
- package/dist/tools/docs/formatting/updateTableCellStyle.js +85 -0
- package/dist/tools/docs/formatting/updateTableColumnWidth.js +51 -0
- package/dist/tools/docs/formatting/updateTableRowStyle.js +74 -0
- package/dist/tools/docs/getTableStructure.js +48 -0
- package/dist/tools/docs/index.js +66 -0
- package/dist/tools/docs/insertDateChip.js +82 -0
- package/dist/tools/docs/insertImage.js +112 -0
- package/dist/tools/docs/insertPageBreak.js +47 -0
- package/dist/tools/docs/insertPerson.js +53 -0
- package/dist/tools/docs/insertRichLink.js +58 -0
- package/dist/tools/docs/insertSectionBreak.js +60 -0
- package/dist/tools/docs/insertTable.js +42 -0
- package/dist/tools/docs/insertTableWithData.js +125 -0
- package/dist/tools/docs/insertText.js +49 -0
- package/dist/tools/docs/listDocumentTables.js +47 -0
- package/dist/tools/docs/listDocumentTabs.js +59 -0
- package/dist/tools/docs/listSmartChips.js +41 -0
- package/dist/tools/docs/modifyText.js +147 -0
- package/dist/tools/docs/readGoogleDoc.js +164 -0
- package/dist/tools/docs/renameTab.js +47 -0
- package/dist/tools/docs/replaceTableRowData.js +55 -0
- package/dist/tools/docs/smartChipHelpers.js +71 -0
- package/dist/tools/docs/structureHelpers.js +250 -0
- package/dist/tools/docs/tabFieldMasks.js +48 -0
- package/dist/tools/docs/tableRowDataHelpers.js +53 -0
- package/dist/tools/docs/updateSectionStyle.js +148 -0
- package/dist/tools/drive/copyFile.js +63 -0
- package/dist/tools/drive/createDocument.js +105 -0
- package/dist/tools/drive/createFolder.js +48 -0
- package/dist/tools/drive/createFromTemplate.js +82 -0
- package/dist/tools/drive/deleteFile.js +72 -0
- package/dist/tools/drive/downloadFile.js +266 -0
- package/dist/tools/drive/getDocumentInfo.js +48 -0
- package/dist/tools/drive/getFolderInfo.js +48 -0
- package/dist/tools/drive/index.js +34 -0
- package/dist/tools/drive/listDriveFiles.js +129 -0
- package/dist/tools/drive/listFolderContents.js +83 -0
- package/dist/tools/drive/listGoogleDocs.js +70 -0
- package/dist/tools/drive/moveFile.js +54 -0
- package/dist/tools/drive/renameFile.js +39 -0
- package/dist/tools/drive/savePathGuard.js +86 -0
- package/dist/tools/drive/searchDriveFiles.js +148 -0
- package/dist/tools/drive/searchGoogleDocs.js +77 -0
- package/dist/tools/drive/setFilePermission.js +68 -0
- package/dist/tools/gmail/createDraft.js +58 -0
- package/dist/tools/gmail/deleteDraft.js +37 -0
- package/dist/tools/gmail/getDraft.js +52 -0
- package/dist/tools/gmail/getMessage.js +92 -0
- package/dist/tools/gmail/helpers.js +113 -0
- package/dist/tools/gmail/index.js +28 -0
- package/dist/tools/gmail/listDrafts.js +74 -0
- package/dist/tools/gmail/listLabels.js +31 -0
- package/dist/tools/gmail/listMessages.js +87 -0
- package/dist/tools/gmail/modifyMessageLabels.js +54 -0
- package/dist/tools/gmail/sendDraft.js +42 -0
- package/dist/tools/gmail/sendEmail.js +56 -0
- package/dist/tools/gmail/trashMessage.js +37 -0
- package/dist/tools/gmail/triageInbox.js +147 -0
- package/dist/tools/gmail/updateDraft.js +60 -0
- package/dist/tools/index.js +64 -0
- package/dist/tools/script/appsScriptShared.js +96 -0
- package/dist/tools/script/createAppsScriptProject.js +74 -0
- package/dist/tools/script/getAppsScriptContent.js +56 -0
- package/dist/tools/script/index.js +14 -0
- package/dist/tools/script/updateAppsScriptContent.js +56 -0
- package/dist/tools/sheets/addConditionalFormatting.js +143 -0
- package/dist/tools/sheets/addSpreadsheetSheet.js +34 -0
- package/dist/tools/sheets/appendSpreadsheetRows.js +44 -0
- package/dist/tools/sheets/appendTableRows.js +51 -0
- package/dist/tools/sheets/autoResizeColumns.js +67 -0
- package/dist/tools/sheets/autoResizeRows.js +63 -0
- package/dist/tools/sheets/batchWrite.js +61 -0
- package/dist/tools/sheets/clearSpreadsheetRange.js +31 -0
- package/dist/tools/sheets/comments/commentAnchor.js +95 -0
- package/dist/tools/sheets/comments/createSheetsCellNote.js +33 -0
- package/dist/tools/sheets/comments/createSheetsComment.js +168 -0
- package/dist/tools/sheets/comments/deleteSheetsComment.js +32 -0
- package/dist/tools/sheets/comments/getSheetsComment.js +51 -0
- package/dist/tools/sheets/comments/index.js +16 -0
- package/dist/tools/sheets/comments/listSheetsComments.js +177 -0
- package/dist/tools/sheets/comments/replyToSheetsComment.js +37 -0
- package/dist/tools/sheets/comments/resolveSheetsComment.js +53 -0
- package/dist/tools/sheets/copyFormatting.js +59 -0
- package/dist/tools/sheets/copySheetTo.js +36 -0
- package/dist/tools/sheets/createSpreadsheet.js +72 -0
- package/dist/tools/sheets/createTable.js +120 -0
- package/dist/tools/sheets/deleteChart.js +41 -0
- package/dist/tools/sheets/deleteConditionalFormatting.js +46 -0
- package/dist/tools/sheets/deleteSheet.js +43 -0
- package/dist/tools/sheets/deleteTable.js +56 -0
- package/dist/tools/sheets/duplicateSheet.js +53 -0
- package/dist/tools/sheets/formatCells.js +122 -0
- package/dist/tools/sheets/freezeRowsAndColumns.js +58 -0
- package/dist/tools/sheets/getConditionalFormatting.js +98 -0
- package/dist/tools/sheets/getSpreadsheetInfo.js +44 -0
- package/dist/tools/sheets/getTable.js +48 -0
- package/dist/tools/sheets/groupRows.js +62 -0
- package/dist/tools/sheets/index.js +84 -0
- package/dist/tools/sheets/insertChart.js +225 -0
- package/dist/tools/sheets/listGoogleSheets.js +66 -0
- package/dist/tools/sheets/listTables.js +55 -0
- package/dist/tools/sheets/protectRange.js +59 -0
- package/dist/tools/sheets/readCellFormat.js +143 -0
- package/dist/tools/sheets/readSpreadsheet.js +36 -0
- package/dist/tools/sheets/renameSheet.js +48 -0
- package/dist/tools/sheets/setCellBorders.js +94 -0
- package/dist/tools/sheets/setColumnWidths.js +43 -0
- package/dist/tools/sheets/setDropdownValidation.js +51 -0
- package/dist/tools/sheets/setRowHeights.js +63 -0
- package/dist/tools/sheets/ungroupAllRows.js +66 -0
- package/dist/tools/sheets/updateTableRange.js +51 -0
- package/dist/tools/sheets/writeSpreadsheet.js +45 -0
- package/dist/tools/utils/appendMarkdownToGoogleDoc.js +95 -0
- package/dist/tools/utils/index.js +8 -0
- package/dist/tools/utils/replaceDocumentWithMarkdown.js +161 -0
- package/dist/tools/utils/replaceRangeWithMarkdown.js +72 -0
- package/dist/types.js +208 -0
- package/dist/upstreamAuth.js +62 -0
- package/package.json +51 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getDriveClient } from '../../clients.js';
|
|
4
|
+
import { escapeDriveQuery } from '../../driveQueryUtils.js';
|
|
5
|
+
export function register(server) {
|
|
6
|
+
server.addTool({
|
|
7
|
+
name: 'listDocuments',
|
|
8
|
+
description: 'Lists Google Documents in your Drive, optionally filtered by name or content. Use modifiedAfter to find recently changed documents.',
|
|
9
|
+
parameters: z.strictObject({
|
|
10
|
+
maxResults: z
|
|
11
|
+
.number()
|
|
12
|
+
.int()
|
|
13
|
+
.min(1)
|
|
14
|
+
.max(100)
|
|
15
|
+
.optional()
|
|
16
|
+
.default(20)
|
|
17
|
+
.describe('Maximum number of documents to return (1-100).'),
|
|
18
|
+
query: z.string().optional().describe('Search query to filter documents by name or content.'),
|
|
19
|
+
orderBy: z
|
|
20
|
+
.enum(['name', 'modifiedTime', 'createdTime'])
|
|
21
|
+
.optional()
|
|
22
|
+
.default('modifiedTime')
|
|
23
|
+
.describe('Sort order for results.'),
|
|
24
|
+
modifiedAfter: z
|
|
25
|
+
.string()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe('Only return documents modified after this date (ISO 8601 format, e.g., "2024-01-01").'),
|
|
28
|
+
}),
|
|
29
|
+
execute: async (args, { log }) => {
|
|
30
|
+
const drive = await getDriveClient();
|
|
31
|
+
log.info(`Listing Google Docs. Query: ${args.query || 'none'}, Max: ${args.maxResults}, Order: ${args.orderBy}`);
|
|
32
|
+
try {
|
|
33
|
+
// Build the query string for Google Drive API
|
|
34
|
+
let queryString = "mimeType='application/vnd.google-apps.document' and trashed=false";
|
|
35
|
+
if (args.query) {
|
|
36
|
+
queryString += ` and (name contains '${escapeDriveQuery(args.query)}' or fullText contains '${escapeDriveQuery(args.query)}')`;
|
|
37
|
+
}
|
|
38
|
+
if (args.modifiedAfter) {
|
|
39
|
+
const cutoffDate = new Date(args.modifiedAfter).toISOString();
|
|
40
|
+
queryString += ` and modifiedTime > '${escapeDriveQuery(cutoffDate)}'`;
|
|
41
|
+
}
|
|
42
|
+
const response = await drive.files.list({
|
|
43
|
+
q: queryString,
|
|
44
|
+
pageSize: args.maxResults,
|
|
45
|
+
// Drive rejects `orderBy` when `q` contains a `fullText` clause
|
|
46
|
+
// ("Sorting is not supported for queries with fullText terms").
|
|
47
|
+
...(args.query ? {} : { orderBy: args.orderBy === 'name' ? 'name' : args.orderBy }),
|
|
48
|
+
fields: 'files(id,name,modifiedTime,createdTime,size,webViewLink,owners(displayName,emailAddress))',
|
|
49
|
+
supportsAllDrives: true,
|
|
50
|
+
includeItemsFromAllDrives: true,
|
|
51
|
+
});
|
|
52
|
+
const files = response.data.files || [];
|
|
53
|
+
const documents = files.map((file) => ({
|
|
54
|
+
id: file.id,
|
|
55
|
+
name: file.name,
|
|
56
|
+
modifiedTime: file.modifiedTime,
|
|
57
|
+
owner: file.owners?.[0]?.displayName || null,
|
|
58
|
+
url: file.webViewLink,
|
|
59
|
+
}));
|
|
60
|
+
return JSON.stringify({ documents }, null, 2);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
log.error(`Error listing Google Docs: ${error.message || error}`);
|
|
64
|
+
if (error.code === 403)
|
|
65
|
+
throw new UserError('Permission denied. Make sure you have granted Google Drive access to the application.');
|
|
66
|
+
throw new UserError(`Failed to list documents: ${error.message || 'Unknown error'}`);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getDriveClient } from '../../clients.js';
|
|
4
|
+
export function register(server) {
|
|
5
|
+
server.addTool({
|
|
6
|
+
name: 'moveFile',
|
|
7
|
+
description: 'Moves a file or folder to a different Drive folder. By default adds the new parent while keeping existing parents; set removeFromAllParents=true for a true move.',
|
|
8
|
+
parameters: z.strictObject({
|
|
9
|
+
fileId: z
|
|
10
|
+
.string()
|
|
11
|
+
.describe('The file or folder ID from a Google Drive URL or a previous tool result.'),
|
|
12
|
+
newParentId: z.string().describe('ID of the destination folder. Use "root" for Drive root.'),
|
|
13
|
+
removeFromAllParents: z
|
|
14
|
+
.boolean()
|
|
15
|
+
.optional()
|
|
16
|
+
.default(false)
|
|
17
|
+
.describe('If true, removes from all current parents. If false, adds to new parent while keeping existing parents.'),
|
|
18
|
+
}),
|
|
19
|
+
execute: async (args, { log }) => {
|
|
20
|
+
const drive = await getDriveClient();
|
|
21
|
+
log.info(`Moving file ${args.fileId} to folder ${args.newParentId}`);
|
|
22
|
+
try {
|
|
23
|
+
// First get the current parents
|
|
24
|
+
const fileInfo = await drive.files.get({
|
|
25
|
+
fileId: args.fileId,
|
|
26
|
+
fields: 'name,parents',
|
|
27
|
+
supportsAllDrives: true,
|
|
28
|
+
});
|
|
29
|
+
const fileName = fileInfo.data.name;
|
|
30
|
+
const currentParents = fileInfo.data.parents || [];
|
|
31
|
+
let updateParams = {
|
|
32
|
+
fileId: args.fileId,
|
|
33
|
+
addParents: args.newParentId,
|
|
34
|
+
fields: 'id,name,parents',
|
|
35
|
+
supportsAllDrives: true,
|
|
36
|
+
};
|
|
37
|
+
if (args.removeFromAllParents && currentParents.length > 0) {
|
|
38
|
+
updateParams.removeParents = currentParents.join(',');
|
|
39
|
+
}
|
|
40
|
+
const response = await drive.files.update(updateParams);
|
|
41
|
+
const action = args.removeFromAllParents ? 'moved' : 'copied';
|
|
42
|
+
return `Successfully ${action} "${fileName}" to new location.\nFile ID: ${response.data.id}`;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
log.error(`Error moving file: ${error.message || error}`);
|
|
46
|
+
if (error.code === 404)
|
|
47
|
+
throw new UserError('File or destination folder not found. Check the IDs.');
|
|
48
|
+
if (error.code === 403)
|
|
49
|
+
throw new UserError('Permission denied. Make sure you have write access to both source and destination.');
|
|
50
|
+
throw new UserError(`Failed to move file: ${error.message || 'Unknown error'}`);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getDriveClient } from '../../clients.js';
|
|
4
|
+
export function register(server) {
|
|
5
|
+
server.addTool({
|
|
6
|
+
name: 'renameFile',
|
|
7
|
+
description: 'Renames a file or folder in Google Drive. Returns the updated file info.',
|
|
8
|
+
parameters: z.strictObject({
|
|
9
|
+
fileId: z
|
|
10
|
+
.string()
|
|
11
|
+
.describe('The file or folder ID from a Google Drive URL or a previous tool result.'),
|
|
12
|
+
newName: z.string().min(1).describe('New name for the file or folder.'),
|
|
13
|
+
}),
|
|
14
|
+
execute: async (args, { log }) => {
|
|
15
|
+
const drive = await getDriveClient();
|
|
16
|
+
log.info(`Renaming file ${args.fileId} to "${args.newName}"`);
|
|
17
|
+
try {
|
|
18
|
+
const response = await drive.files.update({
|
|
19
|
+
fileId: args.fileId,
|
|
20
|
+
requestBody: {
|
|
21
|
+
name: args.newName,
|
|
22
|
+
},
|
|
23
|
+
fields: 'id,name,webViewLink',
|
|
24
|
+
supportsAllDrives: true,
|
|
25
|
+
});
|
|
26
|
+
const file = response.data;
|
|
27
|
+
return `Successfully renamed to "${file.name}" (ID: ${file.id})\nLink: ${file.webViewLink}`;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
log.error(`Error renaming file: ${error.message || error}`);
|
|
31
|
+
if (error.code === 404)
|
|
32
|
+
throw new UserError('File not found. Check the file ID.');
|
|
33
|
+
if (error.code === 403)
|
|
34
|
+
throw new UserError('Permission denied. Make sure you have write access to this file.');
|
|
35
|
+
throw new UserError(`Failed to rename file: ${error.message || 'Unknown error'}`);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Path boundary for downloadFile's `savePath`.
|
|
2
|
+
//
|
|
3
|
+
// downloadFile runs on behalf of an AI agent that routinely reads untrusted
|
|
4
|
+
// content (Drive documents, exported text). Both `fileId` and `savePath` are
|
|
5
|
+
// therefore attacker-influenceable through indirect prompt injection, and in
|
|
6
|
+
// stdio mode `savePath` reaches fs.createWriteStream() directly — an arbitrary
|
|
7
|
+
// file write primitive (CWE-22 / CWE-73) if it is not constrained.
|
|
8
|
+
//
|
|
9
|
+
// v1.10.0 confined writes to the working directory with ensureWithinCwd();
|
|
10
|
+
// v1.11.0 dropped it. This restores the boundary, resolves symlinks so a
|
|
11
|
+
// symlinked directory cannot be used to escape, and makes the allowed roots
|
|
12
|
+
// configurable for people who legitimately save outside the working directory.
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
/**
|
|
16
|
+
* Parses GOOGLE_DOCS_MCP_DOWNLOAD_ROOTS: a platform-delimited list of
|
|
17
|
+
* directories that downloadFile may write to. Defaults to the working
|
|
18
|
+
* directory, which is the v1.10.0 behavior.
|
|
19
|
+
*/
|
|
20
|
+
export function parseDownloadRoots(value, cwd = process.cwd()) {
|
|
21
|
+
const raw = value ?? process.env.GOOGLE_DOCS_MCP_DOWNLOAD_ROOTS ?? '';
|
|
22
|
+
const entries = raw
|
|
23
|
+
.split(path.delimiter)
|
|
24
|
+
.map((entry) => entry.trim())
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.map((entry) => path.resolve(cwd, entry));
|
|
27
|
+
return entries.length > 0 ? entries : [path.resolve(cwd)];
|
|
28
|
+
}
|
|
29
|
+
function isInside(child, parent) {
|
|
30
|
+
if (child === parent)
|
|
31
|
+
return true;
|
|
32
|
+
return child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
|
|
33
|
+
}
|
|
34
|
+
/** Deepest ancestor of `target` that exists on disk, as a literal path. */
|
|
35
|
+
function nearestExistingAncestor(target) {
|
|
36
|
+
let current = target;
|
|
37
|
+
for (;;) {
|
|
38
|
+
if (fs.existsSync(current))
|
|
39
|
+
return current;
|
|
40
|
+
const parent = path.dirname(current);
|
|
41
|
+
if (parent === current)
|
|
42
|
+
return current;
|
|
43
|
+
current = parent;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Canonicalizes a path that may not exist yet: resolves symlinks as far as the
|
|
48
|
+
* filesystem allows, then re-appends the portion that has still to be created.
|
|
49
|
+
*
|
|
50
|
+
* Both the destination and the allowed roots go through this, so a root behind
|
|
51
|
+
* a symlink (on macOS `/tmp` is a link to `/private/tmp`) compares correctly,
|
|
52
|
+
* and a symlinked directory inside a root cannot redirect a write out of it.
|
|
53
|
+
*/
|
|
54
|
+
function canonicalize(target) {
|
|
55
|
+
const absolute = path.resolve(target);
|
|
56
|
+
const ancestor = nearestExistingAncestor(absolute);
|
|
57
|
+
let realAncestor;
|
|
58
|
+
try {
|
|
59
|
+
realAncestor = fs.realpathSync(ancestor);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return absolute;
|
|
63
|
+
}
|
|
64
|
+
return path.resolve(realAncestor, path.relative(ancestor, absolute));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolves `savePath` and throws if it would write outside the allowed roots.
|
|
68
|
+
*
|
|
69
|
+
* Comparison happens after symlinks are resolved on both sides, which rejects
|
|
70
|
+
* `../` traversal and symlinked-directory escapes alike.
|
|
71
|
+
*
|
|
72
|
+
* @throws Error when the destination escapes every allowed root.
|
|
73
|
+
*/
|
|
74
|
+
export function ensureWithinDownloadRoots(savePath, roots) {
|
|
75
|
+
const resolved = path.resolve(savePath);
|
|
76
|
+
const realTarget = canonicalize(resolved);
|
|
77
|
+
const realRoots = roots.map((root) => canonicalize(root));
|
|
78
|
+
if (!realRoots.some((root) => isInside(realTarget, root))) {
|
|
79
|
+
throw new Error(`savePath must stay inside the allowed download directory. ` +
|
|
80
|
+
`"${savePath}" resolves to "${realTarget}", which is outside ${realRoots
|
|
81
|
+
.map((root) => `"${root}"`)
|
|
82
|
+
.join(', ')}. ` +
|
|
83
|
+
`Pass a path inside that directory, or set GOOGLE_DOCS_MCP_DOWNLOAD_ROOTS to allow another one.`);
|
|
84
|
+
}
|
|
85
|
+
return resolved;
|
|
86
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getDriveClient } from '../../clients.js';
|
|
4
|
+
import { escapeDriveQuery } from '../../driveQueryUtils.js';
|
|
5
|
+
/**
|
|
6
|
+
* Convenience shortcuts for common MIME types.
|
|
7
|
+
* Users can also pass any full MIME type string directly.
|
|
8
|
+
*/
|
|
9
|
+
const MIME_TYPE_SHORTCUTS = {
|
|
10
|
+
document: 'application/vnd.google-apps.document',
|
|
11
|
+
spreadsheet: 'application/vnd.google-apps.spreadsheet',
|
|
12
|
+
presentation: 'application/vnd.google-apps.presentation',
|
|
13
|
+
folder: 'application/vnd.google-apps.folder',
|
|
14
|
+
form: 'application/vnd.google-apps.form',
|
|
15
|
+
pdf: 'application/pdf',
|
|
16
|
+
zip: 'application/zip',
|
|
17
|
+
};
|
|
18
|
+
export function register(server) {
|
|
19
|
+
server.addTool({
|
|
20
|
+
name: 'searchDriveFiles',
|
|
21
|
+
description: 'Searches across all file types in Google Drive by name or content. ' +
|
|
22
|
+
'Unlike searchDocuments (which only searches Google Docs), this tool finds Sheets, PDFs, ' +
|
|
23
|
+
'presentations, folders, and any other Drive file. Supports filtering by MIME type, ' +
|
|
24
|
+
'scoping to a specific folder subtree, controllable sort order, and pagination via pageToken.',
|
|
25
|
+
parameters: z.strictObject({
|
|
26
|
+
query: z.string().min(1).describe('Search term to find in file names or content.'),
|
|
27
|
+
searchIn: z
|
|
28
|
+
.enum(['name', 'content', 'both'])
|
|
29
|
+
.optional()
|
|
30
|
+
.default('both')
|
|
31
|
+
.describe('Where to search: "name" matches file titles only, "content" searches inside files, ' +
|
|
32
|
+
'"both" searches names and content (default).'),
|
|
33
|
+
mimeType: z
|
|
34
|
+
.string()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe('Restrict search to a specific file type. ' +
|
|
37
|
+
'Shortcuts: "document", "spreadsheet", "presentation", "folder", "form", "pdf", "zip". ' +
|
|
38
|
+
'Or pass a full MIME type string.'),
|
|
39
|
+
folderId: z
|
|
40
|
+
.string()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe('Restrict search to files inside this folder (and its subfolders). ' +
|
|
43
|
+
'Use "root" for the top-level Drive. Omit to search all of Drive.'),
|
|
44
|
+
orderBy: z
|
|
45
|
+
.enum(['name', 'modifiedTime', 'createdTime'])
|
|
46
|
+
.optional()
|
|
47
|
+
.default('modifiedTime')
|
|
48
|
+
.describe('Field to sort results by.'),
|
|
49
|
+
sortDirection: z
|
|
50
|
+
.enum(['asc', 'desc'])
|
|
51
|
+
.optional()
|
|
52
|
+
.default('desc')
|
|
53
|
+
.describe('Sort direction: "asc" for oldest first, "desc" for newest first (default).'),
|
|
54
|
+
maxResults: z
|
|
55
|
+
.number()
|
|
56
|
+
.int()
|
|
57
|
+
.min(1)
|
|
58
|
+
.max(100)
|
|
59
|
+
.optional()
|
|
60
|
+
.default(10)
|
|
61
|
+
.describe('Maximum number of results to return per page (1-100).'),
|
|
62
|
+
modifiedAfter: z
|
|
63
|
+
.string()
|
|
64
|
+
.optional()
|
|
65
|
+
.describe('Only return files modified after this date (ISO 8601 format, e.g. "2024-01-01").'),
|
|
66
|
+
pageToken: z
|
|
67
|
+
.string()
|
|
68
|
+
.optional()
|
|
69
|
+
.describe('Pagination token from a previous searchDriveFiles response. ' +
|
|
70
|
+
'Pass this to retrieve the next page of results.'),
|
|
71
|
+
}),
|
|
72
|
+
execute: async (args, { log }) => {
|
|
73
|
+
const drive = await getDriveClient();
|
|
74
|
+
log.info(`Searching Drive files for: "${args.query}" in ${args.searchIn}, ` +
|
|
75
|
+
`mimeType=${args.mimeType || 'any'}, folder=${args.folderId || 'all'}, ` +
|
|
76
|
+
`orderBy=${args.orderBy} ${args.sortDirection}`);
|
|
77
|
+
try {
|
|
78
|
+
const conditions = ['trashed=false'];
|
|
79
|
+
// Search term
|
|
80
|
+
if (args.searchIn === 'name') {
|
|
81
|
+
conditions.push(`name contains '${escapeDriveQuery(args.query)}'`);
|
|
82
|
+
}
|
|
83
|
+
else if (args.searchIn === 'content') {
|
|
84
|
+
conditions.push(`fullText contains '${escapeDriveQuery(args.query)}'`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
conditions.push(`(name contains '${escapeDriveQuery(args.query)}' or fullText contains '${escapeDriveQuery(args.query)}')`);
|
|
88
|
+
}
|
|
89
|
+
// Resolve MIME type shortcut or use value as-is
|
|
90
|
+
if (args.mimeType) {
|
|
91
|
+
const resolved = MIME_TYPE_SHORTCUTS[args.mimeType] ?? args.mimeType;
|
|
92
|
+
conditions.push(`mimeType='${escapeDriveQuery(resolved)}'`);
|
|
93
|
+
}
|
|
94
|
+
// Scope to a specific folder (searches within the folder subtree via fullText,
|
|
95
|
+
// but Drive API does not support recursive parent filtering natively — using
|
|
96
|
+
// ancestor query instead which covers all descendants)
|
|
97
|
+
if (args.folderId) {
|
|
98
|
+
conditions.push(`'${escapeDriveQuery(args.folderId)}' in ancestors`);
|
|
99
|
+
}
|
|
100
|
+
// Date filter
|
|
101
|
+
if (args.modifiedAfter) {
|
|
102
|
+
const cutoff = new Date(args.modifiedAfter).toISOString();
|
|
103
|
+
conditions.push(`modifiedTime > '${escapeDriveQuery(cutoff)}'`);
|
|
104
|
+
}
|
|
105
|
+
const queryString = conditions.join(' and ');
|
|
106
|
+
const orderByParam = args.sortDirection === 'desc' ? `${args.orderBy} desc` : args.orderBy;
|
|
107
|
+
// Drive rejects `orderBy` when `q` contains a `fullText` clause
|
|
108
|
+
// ("Sorting is not supported for queries with fullText terms") —
|
|
109
|
+
// only the "name" search mode avoids fullText.
|
|
110
|
+
const includesFullText = args.searchIn !== 'name';
|
|
111
|
+
const response = await drive.files.list({
|
|
112
|
+
q: queryString,
|
|
113
|
+
pageSize: args.maxResults,
|
|
114
|
+
...(includesFullText ? {} : { orderBy: orderByParam }),
|
|
115
|
+
pageToken: args.pageToken,
|
|
116
|
+
fields: 'nextPageToken,files(id,name,mimeType,size,modifiedTime,createdTime,webViewLink,owners(displayName,emailAddress),parents)',
|
|
117
|
+
supportsAllDrives: true,
|
|
118
|
+
includeItemsFromAllDrives: true,
|
|
119
|
+
});
|
|
120
|
+
const files = (response.data.files || []).map((file) => ({
|
|
121
|
+
id: file.id,
|
|
122
|
+
name: file.name,
|
|
123
|
+
mimeType: file.mimeType,
|
|
124
|
+
size: file.size != null ? Number(file.size) : null,
|
|
125
|
+
modifiedTime: file.modifiedTime,
|
|
126
|
+
createdTime: file.createdTime,
|
|
127
|
+
owner: file.owners?.[0]?.displayName || null,
|
|
128
|
+
url: file.webViewLink,
|
|
129
|
+
}));
|
|
130
|
+
const result = { files, total: files.length };
|
|
131
|
+
if (response.data.nextPageToken) {
|
|
132
|
+
result.nextPageToken = response.data.nextPageToken;
|
|
133
|
+
result.hasMore = true;
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
result.hasMore = false;
|
|
137
|
+
}
|
|
138
|
+
return JSON.stringify(result, null, 2);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
log.error(`Error searching Drive files: ${error.message || error}`);
|
|
142
|
+
if (error.code === 403)
|
|
143
|
+
throw new UserError('Permission denied. Make sure you have granted Google Drive access to the application.');
|
|
144
|
+
throw new UserError(`Failed to search files: ${error.message || 'Unknown error'}`);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getDriveClient } from '../../clients.js';
|
|
4
|
+
import { escapeDriveQuery } from '../../driveQueryUtils.js';
|
|
5
|
+
export function register(server) {
|
|
6
|
+
server.addTool({
|
|
7
|
+
name: 'searchDocuments',
|
|
8
|
+
description: 'Searches for documents by name, content, or both. Use listDocuments for browsing and this tool for targeted queries.',
|
|
9
|
+
parameters: z.strictObject({
|
|
10
|
+
query: z.string().min(1).describe('Search term to find in document names or content.'),
|
|
11
|
+
searchIn: z
|
|
12
|
+
.enum(['name', 'content', 'both'])
|
|
13
|
+
.optional()
|
|
14
|
+
.default('both')
|
|
15
|
+
.describe('Where to search: document names, content, or both.'),
|
|
16
|
+
maxResults: z
|
|
17
|
+
.number()
|
|
18
|
+
.int()
|
|
19
|
+
.min(1)
|
|
20
|
+
.max(50)
|
|
21
|
+
.optional()
|
|
22
|
+
.default(10)
|
|
23
|
+
.describe('Maximum number of results to return.'),
|
|
24
|
+
modifiedAfter: z
|
|
25
|
+
.string()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe('Only return documents modified after this date (ISO 8601 format, e.g., "2024-01-01").'),
|
|
28
|
+
}),
|
|
29
|
+
execute: async (args, { log }) => {
|
|
30
|
+
const drive = await getDriveClient();
|
|
31
|
+
log.info(`Searching Google Docs for: "${args.query}" in ${args.searchIn}`);
|
|
32
|
+
try {
|
|
33
|
+
let queryString = "mimeType='application/vnd.google-apps.document' and trashed=false";
|
|
34
|
+
// Add search criteria
|
|
35
|
+
if (args.searchIn === 'name') {
|
|
36
|
+
queryString += ` and name contains '${escapeDriveQuery(args.query)}'`;
|
|
37
|
+
}
|
|
38
|
+
else if (args.searchIn === 'content') {
|
|
39
|
+
queryString += ` and fullText contains '${escapeDriveQuery(args.query)}'`;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
queryString += ` and (name contains '${escapeDriveQuery(args.query)}' or fullText contains '${escapeDriveQuery(args.query)}')`;
|
|
43
|
+
}
|
|
44
|
+
// Add date filter if provided
|
|
45
|
+
if (args.modifiedAfter) {
|
|
46
|
+
queryString += ` and modifiedTime > '${escapeDriveQuery(args.modifiedAfter)}'`;
|
|
47
|
+
}
|
|
48
|
+
const response = await drive.files.list({
|
|
49
|
+
q: queryString,
|
|
50
|
+
pageSize: args.maxResults,
|
|
51
|
+
// Drive rejects `orderBy` when `q` contains a `fullText` clause
|
|
52
|
+
// ("Sorting is not supported for queries with fullText terms") —
|
|
53
|
+
// only the "name" search mode avoids fullText.
|
|
54
|
+
...(args.searchIn === 'name' ? { orderBy: 'modifiedTime desc' } : {}),
|
|
55
|
+
fields: 'files(id,name,modifiedTime,createdTime,webViewLink,owners(displayName),parents)',
|
|
56
|
+
supportsAllDrives: true,
|
|
57
|
+
includeItemsFromAllDrives: true,
|
|
58
|
+
});
|
|
59
|
+
const files = response.data.files || [];
|
|
60
|
+
const documents = files.map((file) => ({
|
|
61
|
+
id: file.id,
|
|
62
|
+
name: file.name,
|
|
63
|
+
modifiedTime: file.modifiedTime,
|
|
64
|
+
owner: file.owners?.[0]?.displayName || null,
|
|
65
|
+
url: file.webViewLink,
|
|
66
|
+
}));
|
|
67
|
+
return JSON.stringify({ documents }, null, 2);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
log.error(`Error searching Google Docs: ${error.message || error}`);
|
|
71
|
+
if (error.code === 403)
|
|
72
|
+
throw new UserError('Permission denied. Make sure you have granted Google Drive access to the application.');
|
|
73
|
+
throw new UserError(`Failed to search documents: ${error.message || 'Unknown error'}`);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getDriveClient } from '../../clients.js';
|
|
4
|
+
export function register(server) {
|
|
5
|
+
server.addTool({
|
|
6
|
+
name: 'setFilePermission',
|
|
7
|
+
description: "Sets a sharing permission on a Drive file or folder. Common case: type='anyone' with role='reader' enables 'anyone with the link can view'. Use type='user' with emailAddress to grant a specific person access. Returns the created permission record.",
|
|
8
|
+
parameters: z.strictObject({
|
|
9
|
+
fileId: z
|
|
10
|
+
.string()
|
|
11
|
+
.describe('The file or folder ID from a Drive URL or a previous tool result.'),
|
|
12
|
+
role: z
|
|
13
|
+
.enum(['reader', 'commenter', 'writer'])
|
|
14
|
+
.describe('Access level granted. "reader" = view only, "commenter" = view + comment, "writer" = full edit.'),
|
|
15
|
+
type: z
|
|
16
|
+
.enum(['user', 'group', 'domain', 'anyone'])
|
|
17
|
+
.describe('Who this permission applies to. "anyone" means anyone with the link (combine with allowFileDiscovery=false to keep it unlisted).'),
|
|
18
|
+
emailAddress: z.string().optional().describe("Required when type is 'user' or 'group'."),
|
|
19
|
+
domain: z.string().optional().describe("Required when type is 'domain'."),
|
|
20
|
+
sendNotificationEmail: z
|
|
21
|
+
.boolean()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe('If true, Google sends a notification email when granting access to a user or group. Defaults to false.'),
|
|
24
|
+
allowFileDiscovery: z
|
|
25
|
+
.boolean()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe("Only relevant for type='anyone' or 'domain'. If true, the file is discoverable via search; if false (default), only people with the direct link can find it."),
|
|
28
|
+
}),
|
|
29
|
+
execute: async (args, { log }) => {
|
|
30
|
+
const drive = await getDriveClient();
|
|
31
|
+
log.info(`Setting ${args.type}/${args.role} permission on file ${args.fileId}`);
|
|
32
|
+
if ((args.type === 'user' || args.type === 'group') && !args.emailAddress) {
|
|
33
|
+
throw new UserError(`emailAddress is required when type is "${args.type}".`);
|
|
34
|
+
}
|
|
35
|
+
if (args.type === 'domain' && !args.domain) {
|
|
36
|
+
throw new UserError('domain is required when type is "domain".');
|
|
37
|
+
}
|
|
38
|
+
const requestBody = {
|
|
39
|
+
role: args.role,
|
|
40
|
+
type: args.type,
|
|
41
|
+
};
|
|
42
|
+
if (args.emailAddress)
|
|
43
|
+
requestBody.emailAddress = args.emailAddress;
|
|
44
|
+
if (args.domain)
|
|
45
|
+
requestBody.domain = args.domain;
|
|
46
|
+
if (typeof args.allowFileDiscovery === 'boolean')
|
|
47
|
+
requestBody.allowFileDiscovery = args.allowFileDiscovery;
|
|
48
|
+
try {
|
|
49
|
+
const response = await drive.permissions.create({
|
|
50
|
+
fileId: args.fileId,
|
|
51
|
+
requestBody,
|
|
52
|
+
sendNotificationEmail: args.sendNotificationEmail ?? false,
|
|
53
|
+
supportsAllDrives: true,
|
|
54
|
+
fields: 'id,type,role,emailAddress,domain,allowFileDiscovery',
|
|
55
|
+
});
|
|
56
|
+
return JSON.stringify(response.data, null, 2);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
log.error(`Error setting permission: ${error.message || error}`);
|
|
60
|
+
if (error.code === 404)
|
|
61
|
+
throw new UserError('File not found. Check the file ID.');
|
|
62
|
+
if (error.code === 403)
|
|
63
|
+
throw new UserError('Permission denied. You may not have rights to share this file, or the requested permission conflicts with org policy.');
|
|
64
|
+
throw new UserError(`Failed to set permission: ${error.message || 'Unknown error'}`);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getGmailClient } from '../../clients.js';
|
|
4
|
+
import { prepareMimeRequest } from './helpers.js';
|
|
5
|
+
export function register(server) {
|
|
6
|
+
server.addTool({
|
|
7
|
+
name: 'createDraft',
|
|
8
|
+
description: 'Creates a Gmail draft (does NOT send). Use this for AI-composed emails that the user should review before sending. The draft appears in the Gmail Drafts folder and can be sent later with sendDraft, edited with updateDraft, or deleted with deleteDraft. Supports threading via replyToMessageId.',
|
|
9
|
+
parameters: z.strictObject({
|
|
10
|
+
to: z
|
|
11
|
+
.union([z.string(), z.array(z.string()).min(1)])
|
|
12
|
+
.describe('Recipient email address, or an array of recipient email addresses.'),
|
|
13
|
+
subject: z.string().describe('Email subject line.'),
|
|
14
|
+
body: z.string().describe('Plain-text body of the draft.'),
|
|
15
|
+
cc: z.array(z.string()).optional().describe('Optional list of Cc recipients.'),
|
|
16
|
+
bcc: z.array(z.string()).optional().describe('Optional list of Bcc recipients.'),
|
|
17
|
+
replyToMessageId: z
|
|
18
|
+
.string()
|
|
19
|
+
.optional()
|
|
20
|
+
.describe('Optional Gmail message ID to draft a reply to. The draft is threaded with the original.'),
|
|
21
|
+
}),
|
|
22
|
+
execute: async (args, { log }) => {
|
|
23
|
+
const gmail = await getGmailClient();
|
|
24
|
+
try {
|
|
25
|
+
const { raw, threadId, toList } = await prepareMimeRequest(gmail, args);
|
|
26
|
+
log.info(`Creating Gmail draft to ${toList.join(', ')}${args.replyToMessageId ? ` (reply to ${args.replyToMessageId})` : ''}`);
|
|
27
|
+
const response = await gmail.users.drafts.create({
|
|
28
|
+
userId: 'me',
|
|
29
|
+
requestBody: {
|
|
30
|
+
message: {
|
|
31
|
+
raw,
|
|
32
|
+
...(threadId ? { threadId } : {}),
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
return JSON.stringify({
|
|
37
|
+
success: true,
|
|
38
|
+
draftId: response.data.id,
|
|
39
|
+
messageId: response.data.message?.id,
|
|
40
|
+
threadId: response.data.message?.threadId,
|
|
41
|
+
to: toList,
|
|
42
|
+
subject: args.subject,
|
|
43
|
+
message: `Draft created. Use sendDraft with draftId="${response.data.id}" to send it, or updateDraft to edit it first.`,
|
|
44
|
+
}, null, 2);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
log.error(`Error creating draft: ${error.message || error}`);
|
|
48
|
+
if (error.code === 401)
|
|
49
|
+
throw new UserError('Gmail authorization failed. Re-authorize to grant the gmail.modify scope.');
|
|
50
|
+
if (error.code === 403)
|
|
51
|
+
throw new UserError('Permission denied. Confirm the gmail.modify scope was granted.');
|
|
52
|
+
if (error.code === 400)
|
|
53
|
+
throw new UserError(`Gmail rejected the draft: ${error.message || 'Bad request'}`);
|
|
54
|
+
throw new UserError(`Failed to create draft: ${error.message || 'Unknown error'}`);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getGmailClient } from '../../clients.js';
|
|
4
|
+
export function register(server) {
|
|
5
|
+
server.addTool({
|
|
6
|
+
name: 'deleteDraft',
|
|
7
|
+
description: 'Permanently deletes a Gmail draft. This is irreversible — the draft is removed entirely, not moved to Trash. Use when an AI-composed draft was rejected or replaced.',
|
|
8
|
+
parameters: z.strictObject({
|
|
9
|
+
draftId: z
|
|
10
|
+
.string()
|
|
11
|
+
.describe('The Gmail draft ID to delete (from createDraft or listDrafts).'),
|
|
12
|
+
}),
|
|
13
|
+
execute: async (args, { log }) => {
|
|
14
|
+
const gmail = await getGmailClient();
|
|
15
|
+
log.info(`Deleting Gmail draft ${args.draftId}`);
|
|
16
|
+
try {
|
|
17
|
+
await gmail.users.drafts.delete({
|
|
18
|
+
userId: 'me',
|
|
19
|
+
id: args.draftId,
|
|
20
|
+
});
|
|
21
|
+
return JSON.stringify({
|
|
22
|
+
success: true,
|
|
23
|
+
draftId: args.draftId,
|
|
24
|
+
message: `Draft ${args.draftId} permanently deleted.`,
|
|
25
|
+
}, null, 2);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
log.error(`Error deleting draft: ${error.message || error}`);
|
|
29
|
+
if (error.code === 404)
|
|
30
|
+
throw new UserError(`Draft not found (ID: ${args.draftId}).`);
|
|
31
|
+
if (error.code === 403)
|
|
32
|
+
throw new UserError('Permission denied. Confirm the gmail.modify scope was granted.');
|
|
33
|
+
throw new UserError(`Failed to delete draft: ${error.message || 'Unknown error'}`);
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|