@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,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: 'trashMessage',
|
|
7
|
+
description: 'Moves a Gmail message to Trash. This is the same as clicking Delete in the Gmail UI — reversible from the Trash folder for 30 days. Not a permanent delete.',
|
|
8
|
+
parameters: z.strictObject({
|
|
9
|
+
messageId: z.string().describe('The Gmail message ID to move to Trash.'),
|
|
10
|
+
}),
|
|
11
|
+
execute: async (args, { log }) => {
|
|
12
|
+
const gmail = await getGmailClient();
|
|
13
|
+
log.info(`Trashing Gmail message ${args.messageId}`);
|
|
14
|
+
try {
|
|
15
|
+
const response = await gmail.users.messages.trash({
|
|
16
|
+
userId: 'me',
|
|
17
|
+
id: args.messageId,
|
|
18
|
+
});
|
|
19
|
+
return JSON.stringify({
|
|
20
|
+
success: true,
|
|
21
|
+
id: response.data.id,
|
|
22
|
+
threadId: response.data.threadId,
|
|
23
|
+
labelIds: response.data.labelIds ?? [],
|
|
24
|
+
message: `Message ${args.messageId} moved to Trash. Recoverable from the Trash folder in the Gmail UI.`,
|
|
25
|
+
}, null, 2);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
log.error(`Error trashing Gmail message: ${error.message || error}`);
|
|
29
|
+
if (error.code === 404)
|
|
30
|
+
throw new UserError(`Gmail message not found (ID: ${args.messageId}).`);
|
|
31
|
+
if (error.code === 403)
|
|
32
|
+
throw new UserError('Permission denied. Confirm the gmail.modify scope was granted.');
|
|
33
|
+
throw new UserError(`Failed to trash message: ${error.message || 'Unknown error'}`);
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getGmailClient } from '../../clients.js';
|
|
4
|
+
import { findHeaderValue, extractMessageBody, extractDomain } from './helpers.js';
|
|
5
|
+
// "reschedul" is intentionally truncated to catch both "reschedule" and "rescheduling".
|
|
6
|
+
const MEETING_KEYWORD_PATTERN = /\b(meeting|call|invite|invitation|calendar|schedule|reschedul|zoom|google meet|teams)\b/i;
|
|
7
|
+
const QUESTION_PATTERN = /\?/;
|
|
8
|
+
const ACTION_PATTERN = /\b(please|could you|can you|let me know|need|review|approve|sign|deadline|by (mon|tue|wed|thu|fri|sat|sun|today|tomorrow|next week|eod|cob))\b/i;
|
|
9
|
+
function extractTextBody(payload) {
|
|
10
|
+
const { text, html } = extractMessageBody(payload);
|
|
11
|
+
if (text)
|
|
12
|
+
return text;
|
|
13
|
+
// No text/plain part — strip HTML tags as a best-effort fallback so the
|
|
14
|
+
// heuristic regexes still have something to match against.
|
|
15
|
+
return html.replace(/<style[\s\S]*?<\/style>|<script[\s\S]*?<\/script>|<[^>]+>/g, ' ');
|
|
16
|
+
}
|
|
17
|
+
function truncate(text, max) {
|
|
18
|
+
const collapsed = text.replace(/\s+/g, ' ').trim();
|
|
19
|
+
if (collapsed.length <= max)
|
|
20
|
+
return collapsed;
|
|
21
|
+
return collapsed.slice(0, max) + '…';
|
|
22
|
+
}
|
|
23
|
+
export function register(server) {
|
|
24
|
+
server.addTool({
|
|
25
|
+
name: 'triageInbox',
|
|
26
|
+
description: "Composite tool: fetches the user's most recent unread Gmail messages with full content and heuristic categorization in a single call. Returns headers, body excerpts, labels, plus per-message signals (newsletter, meeting reference, contains question, action requested) AND aggregate stats (total unread, top senders, breakdown by category). Designed for AI inbox triage workflows — use the returned data to decide which messages need a reply, can be archived, or warrant a draft response. Pairs naturally with createDraft, modifyMessageLabels, and trashMessage.",
|
|
27
|
+
parameters: z.strictObject({
|
|
28
|
+
maxResults: z
|
|
29
|
+
.number()
|
|
30
|
+
.int()
|
|
31
|
+
.min(1)
|
|
32
|
+
.max(50)
|
|
33
|
+
.optional()
|
|
34
|
+
.default(20)
|
|
35
|
+
.describe('How many unread messages to triage in one pass (1-50). Defaults to 20.'),
|
|
36
|
+
additionalQuery: z
|
|
37
|
+
.string()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe('Optional Gmail query appended to "is:unread", e.g. "newer_than:2d" or "-from:notifications@".'),
|
|
40
|
+
bodyExcerptLength: z
|
|
41
|
+
.number()
|
|
42
|
+
.int()
|
|
43
|
+
.min(0)
|
|
44
|
+
.max(2000)
|
|
45
|
+
.optional()
|
|
46
|
+
.default(400)
|
|
47
|
+
.describe('Max characters of body text to include per message (0 to skip bodies).'),
|
|
48
|
+
}),
|
|
49
|
+
execute: async (args, { log }) => {
|
|
50
|
+
const gmail = await getGmailClient();
|
|
51
|
+
const query = ['is:unread', args.additionalQuery].filter(Boolean).join(' ');
|
|
52
|
+
log.info(`Triaging inbox (max=${args.maxResults}, q="${query}")`);
|
|
53
|
+
try {
|
|
54
|
+
const listResponse = await gmail.users.messages.list({
|
|
55
|
+
userId: 'me',
|
|
56
|
+
maxResults: args.maxResults,
|
|
57
|
+
q: query,
|
|
58
|
+
});
|
|
59
|
+
const totalUnread = listResponse.data.resultSizeEstimate ?? 0;
|
|
60
|
+
const messageRefs = listResponse.data.messages ?? [];
|
|
61
|
+
if (messageRefs.length === 0) {
|
|
62
|
+
return JSON.stringify({
|
|
63
|
+
summary: {
|
|
64
|
+
totalUnread,
|
|
65
|
+
fetched: 0,
|
|
66
|
+
topSenders: [],
|
|
67
|
+
newsletterCount: 0,
|
|
68
|
+
meetingReferenceCount: 0,
|
|
69
|
+
questionCount: 0,
|
|
70
|
+
actionRequestedCount: 0,
|
|
71
|
+
},
|
|
72
|
+
messages: [],
|
|
73
|
+
}, null, 2);
|
|
74
|
+
}
|
|
75
|
+
// allSettled so a single failed message fetch doesn't kill the whole
|
|
76
|
+
// triage — partial results are still actionable for the agent.
|
|
77
|
+
const settled = await Promise.allSettled(messageRefs.map((ref) => gmail.users.messages.get({
|
|
78
|
+
userId: 'me',
|
|
79
|
+
id: ref.id,
|
|
80
|
+
format: 'full',
|
|
81
|
+
})));
|
|
82
|
+
const failedFetches = settled.filter((r) => r.status === 'rejected').length;
|
|
83
|
+
const detailed = [];
|
|
84
|
+
for (const r of settled) {
|
|
85
|
+
if (r.status === 'fulfilled')
|
|
86
|
+
detailed.push(r.value.data);
|
|
87
|
+
}
|
|
88
|
+
const messages = detailed.map((msg) => {
|
|
89
|
+
const headers = msg.payload?.headers;
|
|
90
|
+
const from = findHeaderValue(headers, 'From');
|
|
91
|
+
const subject = findHeaderValue(headers, 'Subject') ?? '(no subject)';
|
|
92
|
+
// Standard newsletter headers per RFC 2369 (List-Unsubscribe) and RFC 2919 (List-Id).
|
|
93
|
+
const hasUnsubscribe = findHeaderValue(headers, 'List-Unsubscribe') !== null ||
|
|
94
|
+
findHeaderValue(headers, 'List-Id') !== null;
|
|
95
|
+
const text = args.bodyExcerptLength > 0 ? extractTextBody(msg.payload) : '';
|
|
96
|
+
const bodyExcerpt = args.bodyExcerptLength > 0 ? truncate(text, args.bodyExcerptLength) : '';
|
|
97
|
+
const searchSurface = `${subject} ${text}`;
|
|
98
|
+
return {
|
|
99
|
+
id: msg.id,
|
|
100
|
+
threadId: msg.threadId,
|
|
101
|
+
from,
|
|
102
|
+
domain: extractDomain(from),
|
|
103
|
+
to: findHeaderValue(headers, 'To'),
|
|
104
|
+
subject,
|
|
105
|
+
date: findHeaderValue(headers, 'Date'),
|
|
106
|
+
snippet: msg.snippet ?? '',
|
|
107
|
+
bodyExcerpt,
|
|
108
|
+
labels: msg.labelIds ?? [],
|
|
109
|
+
isNewsletter: hasUnsubscribe,
|
|
110
|
+
containsMeetingReference: MEETING_KEYWORD_PATTERN.test(searchSurface),
|
|
111
|
+
containsQuestion: QUESTION_PATTERN.test(searchSurface),
|
|
112
|
+
actionRequested: ACTION_PATTERN.test(searchSurface),
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
const senderCounts = new Map();
|
|
116
|
+
for (const m of messages) {
|
|
117
|
+
if (!m.from)
|
|
118
|
+
continue;
|
|
119
|
+
senderCounts.set(m.from, (senderCounts.get(m.from) ?? 0) + 1);
|
|
120
|
+
}
|
|
121
|
+
const topSenders = [...senderCounts.entries()]
|
|
122
|
+
.sort((a, b) => b[1] - a[1])
|
|
123
|
+
.slice(0, 5)
|
|
124
|
+
.map(([from, count]) => ({ from, count }));
|
|
125
|
+
const summary = {
|
|
126
|
+
totalUnread,
|
|
127
|
+
fetched: messages.length,
|
|
128
|
+
failedFetches,
|
|
129
|
+
topSenders,
|
|
130
|
+
newsletterCount: messages.filter((m) => m.isNewsletter).length,
|
|
131
|
+
meetingReferenceCount: messages.filter((m) => m.containsMeetingReference).length,
|
|
132
|
+
questionCount: messages.filter((m) => m.containsQuestion).length,
|
|
133
|
+
actionRequestedCount: messages.filter((m) => m.actionRequested).length,
|
|
134
|
+
};
|
|
135
|
+
return JSON.stringify({ summary, messages }, null, 2);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
log.error(`Error triaging inbox: ${error.message || error}`);
|
|
139
|
+
if (error.code === 401)
|
|
140
|
+
throw new UserError('Gmail authorization failed. Re-authorize to grant the gmail.modify scope.');
|
|
141
|
+
if (error.code === 403)
|
|
142
|
+
throw new UserError('Permission denied. Confirm the gmail.modify scope was granted.');
|
|
143
|
+
throw new UserError(`Failed to triage inbox: ${error.message || 'Unknown error'}`);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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: 'updateDraft',
|
|
8
|
+
description: 'Replaces the contents of an existing Gmail draft. The new contents fully overwrite the old draft (this is a full replace, not a patch). Use this when iterating on an AI-composed draft before sending.',
|
|
9
|
+
parameters: z.strictObject({
|
|
10
|
+
draftId: z.string().describe('The Gmail draft ID to update.'),
|
|
11
|
+
to: z
|
|
12
|
+
.union([z.string(), z.array(z.string()).min(1)])
|
|
13
|
+
.describe('Recipient email address, or an array of recipient email addresses.'),
|
|
14
|
+
subject: z.string().describe('Email subject line.'),
|
|
15
|
+
body: z.string().describe('New plain-text body of the draft.'),
|
|
16
|
+
cc: z.array(z.string()).optional().describe('Optional list of Cc recipients.'),
|
|
17
|
+
bcc: z.array(z.string()).optional().describe('Optional list of Bcc recipients.'),
|
|
18
|
+
replyToMessageId: z
|
|
19
|
+
.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('Optional Gmail message ID to thread the draft with.'),
|
|
22
|
+
}),
|
|
23
|
+
execute: async (args, { log }) => {
|
|
24
|
+
const gmail = await getGmailClient();
|
|
25
|
+
log.info(`Updating Gmail draft ${args.draftId}`);
|
|
26
|
+
try {
|
|
27
|
+
const { raw, threadId, toList } = await prepareMimeRequest(gmail, args);
|
|
28
|
+
const response = await gmail.users.drafts.update({
|
|
29
|
+
userId: 'me',
|
|
30
|
+
id: args.draftId,
|
|
31
|
+
requestBody: {
|
|
32
|
+
message: {
|
|
33
|
+
raw,
|
|
34
|
+
...(threadId ? { threadId } : {}),
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
return JSON.stringify({
|
|
39
|
+
success: true,
|
|
40
|
+
draftId: response.data.id,
|
|
41
|
+
messageId: response.data.message?.id,
|
|
42
|
+
threadId: response.data.message?.threadId,
|
|
43
|
+
to: toList,
|
|
44
|
+
subject: args.subject,
|
|
45
|
+
message: `Draft ${args.draftId} updated.`,
|
|
46
|
+
}, null, 2);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
log.error(`Error updating draft: ${error.message || error}`);
|
|
50
|
+
if (error.code === 404)
|
|
51
|
+
throw new UserError(`Draft not found (ID: ${args.draftId}).`);
|
|
52
|
+
if (error.code === 403)
|
|
53
|
+
throw new UserError('Permission denied. Confirm the gmail.modify scope was granted.');
|
|
54
|
+
if (error.code === 400)
|
|
55
|
+
throw new UserError(`Gmail rejected the draft update: ${error.message || 'Bad request'}`);
|
|
56
|
+
throw new UserError(`Failed to update draft: ${error.message || 'Unknown error'}`);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { registerDocsTools } from './docs/index.js';
|
|
2
|
+
import { registerDriveTools } from './drive/index.js';
|
|
3
|
+
import { registerSheetsTools } from './sheets/index.js';
|
|
4
|
+
import { registerUtilsTools } from './utils/index.js';
|
|
5
|
+
import { registerGmailTools } from './gmail/index.js';
|
|
6
|
+
import { registerCalendarTools } from './calendar/index.js';
|
|
7
|
+
import { registerScriptTools } from './script/index.js';
|
|
8
|
+
export const TOOL_GROUPS = [
|
|
9
|
+
'docs',
|
|
10
|
+
'drive',
|
|
11
|
+
'sheets',
|
|
12
|
+
'utils',
|
|
13
|
+
'gmail',
|
|
14
|
+
'calendar',
|
|
15
|
+
'script',
|
|
16
|
+
];
|
|
17
|
+
const TOOL_GROUP_SET = new Set(TOOL_GROUPS);
|
|
18
|
+
export function parseEnabledToolGroups(raw = process.env.MCP_TOOL_GROUPS) {
|
|
19
|
+
if (!raw?.trim())
|
|
20
|
+
return [...TOOL_GROUPS];
|
|
21
|
+
const requested = raw
|
|
22
|
+
.split(',')
|
|
23
|
+
.map((group) => group.trim().toLowerCase())
|
|
24
|
+
.filter(Boolean);
|
|
25
|
+
if (requested.length === 0 || requested.includes('all')) {
|
|
26
|
+
return [...TOOL_GROUPS];
|
|
27
|
+
}
|
|
28
|
+
const unknown = requested.filter((group) => !TOOL_GROUP_SET.has(group));
|
|
29
|
+
if (unknown.length > 0) {
|
|
30
|
+
throw new Error(`Unknown MCP_TOOL_GROUPS value(s): ${unknown.join(', ')}. Valid groups: ${TOOL_GROUPS.join(', ')}`);
|
|
31
|
+
}
|
|
32
|
+
const selected = new Set(requested);
|
|
33
|
+
return TOOL_GROUPS.filter((group) => selected.has(group));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Registers all tools with the FastMCP server.
|
|
37
|
+
*/
|
|
38
|
+
export function registerAllTools(server, enabledGroups = parseEnabledToolGroups()) {
|
|
39
|
+
for (const group of enabledGroups) {
|
|
40
|
+
switch (group) {
|
|
41
|
+
case 'docs':
|
|
42
|
+
registerDocsTools(server);
|
|
43
|
+
break;
|
|
44
|
+
case 'drive':
|
|
45
|
+
registerDriveTools(server);
|
|
46
|
+
break;
|
|
47
|
+
case 'sheets':
|
|
48
|
+
registerSheetsTools(server);
|
|
49
|
+
break;
|
|
50
|
+
case 'utils':
|
|
51
|
+
registerUtilsTools(server);
|
|
52
|
+
break;
|
|
53
|
+
case 'gmail':
|
|
54
|
+
registerGmailTools(server);
|
|
55
|
+
break;
|
|
56
|
+
case 'calendar':
|
|
57
|
+
registerCalendarTools(server);
|
|
58
|
+
break;
|
|
59
|
+
case 'script':
|
|
60
|
+
registerScriptTools(server);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
/** File types supported by an Apps Script project. */
|
|
4
|
+
export const AppsScriptFileTypeSchema = z.enum(['SERVER_JS', 'HTML', 'JSON']);
|
|
5
|
+
export const AppsScriptFileSchema = z.strictObject({
|
|
6
|
+
name: z
|
|
7
|
+
.string()
|
|
8
|
+
.min(1)
|
|
9
|
+
.describe('File name without extension, e.g. "Code" or "appsscript". The manifest file must be named "appsscript".'),
|
|
10
|
+
type: AppsScriptFileTypeSchema.optional().describe('File type. Inferred from the name when omitted: "appsscript" becomes JSON, names ending in .html become HTML, everything else becomes SERVER_JS.'),
|
|
11
|
+
source: z.string().describe('Full file content.'),
|
|
12
|
+
});
|
|
13
|
+
export const MANIFEST_FILE_NAME = 'appsscript';
|
|
14
|
+
/** Minimal manifest used when a project has none and the caller did not supply one. */
|
|
15
|
+
export const DEFAULT_MANIFEST_SOURCE = JSON.stringify({
|
|
16
|
+
timeZone: 'Etc/GMT',
|
|
17
|
+
dependencies: {},
|
|
18
|
+
exceptionLogging: 'STACKDRIVER',
|
|
19
|
+
runtimeVersion: 'V8',
|
|
20
|
+
}, null, 2);
|
|
21
|
+
/** Strips a trailing extension so callers may pass either "Code" or "Code.gs". */
|
|
22
|
+
function stripExtension(name) {
|
|
23
|
+
return name.replace(/\.(gs|js|html|json)$/i, '');
|
|
24
|
+
}
|
|
25
|
+
/** Derives the Apps Script file type from a file name when the caller omitted it. */
|
|
26
|
+
export function inferFileType(name) {
|
|
27
|
+
const lower = name.toLowerCase();
|
|
28
|
+
if (stripExtension(lower) === MANIFEST_FILE_NAME)
|
|
29
|
+
return 'JSON';
|
|
30
|
+
if (lower.endsWith('.html'))
|
|
31
|
+
return 'HTML';
|
|
32
|
+
return 'SERVER_JS';
|
|
33
|
+
}
|
|
34
|
+
/** Normalises caller input into the shape the Apps Script API expects. */
|
|
35
|
+
export function toApiFiles(files) {
|
|
36
|
+
return files.map((file) => ({
|
|
37
|
+
name: stripExtension(file.name),
|
|
38
|
+
type: file.type ?? inferFileType(file.name),
|
|
39
|
+
source: file.source,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Merges incoming files over the files already in the project.
|
|
44
|
+
* Files with the same name are replaced; every other existing file is kept.
|
|
45
|
+
*/
|
|
46
|
+
export function mergeFiles(existing, incoming) {
|
|
47
|
+
const byName = new Map();
|
|
48
|
+
for (const file of existing) {
|
|
49
|
+
if (file.name)
|
|
50
|
+
byName.set(file.name, file);
|
|
51
|
+
}
|
|
52
|
+
for (const file of incoming) {
|
|
53
|
+
if (file.name)
|
|
54
|
+
byName.set(file.name, file);
|
|
55
|
+
}
|
|
56
|
+
return [...byName.values()];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The API rejects an update whose payload has no manifest, so fall back to the
|
|
60
|
+
* project's current manifest and finally to a minimal default.
|
|
61
|
+
*/
|
|
62
|
+
export function ensureManifest(files, existing) {
|
|
63
|
+
if (files.some((file) => file.name === MANIFEST_FILE_NAME))
|
|
64
|
+
return files;
|
|
65
|
+
const currentManifest = existing.find((file) => file.name === MANIFEST_FILE_NAME);
|
|
66
|
+
return [
|
|
67
|
+
currentManifest ?? {
|
|
68
|
+
name: MANIFEST_FILE_NAME,
|
|
69
|
+
type: 'JSON',
|
|
70
|
+
source: DEFAULT_MANIFEST_SOURCE,
|
|
71
|
+
},
|
|
72
|
+
...files,
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
/** Turns raw Google API failures into messages that say what to do next. */
|
|
76
|
+
export function toUserError(error, action) {
|
|
77
|
+
const message = error?.message ?? String(error);
|
|
78
|
+
const status = error?.code ?? error?.response?.status;
|
|
79
|
+
if (status === 403 && /Apps Script API/i.test(message)) {
|
|
80
|
+
return new UserError('The Apps Script API is turned off for this Google account. Enable it at https://script.google.com/home/usersettings and try again.');
|
|
81
|
+
}
|
|
82
|
+
if (status === 403) {
|
|
83
|
+
return new UserError(`Permission denied while ${action}. The account needs edit access to the project, and the OAuth token needs the script.projects scope - re-authorize if the token predates that scope.`);
|
|
84
|
+
}
|
|
85
|
+
if (status === 404) {
|
|
86
|
+
return new UserError(`Not found while ${action}. Check the script ID - it is the long ID in the /projects/<scriptId>/ part of the Apps Script editor URL, not the spreadsheet ID.`);
|
|
87
|
+
}
|
|
88
|
+
if (status === 400 && /parent/i.test(message)) {
|
|
89
|
+
return new UserError('Invalid parentId. It must be the file ID of an existing Doc, Sheet, Slides or Form that the account can edit.');
|
|
90
|
+
}
|
|
91
|
+
return new UserError(`Failed while ${action}: ${message || 'Unknown error'}`);
|
|
92
|
+
}
|
|
93
|
+
/** Editor URL for a script project. */
|
|
94
|
+
export function scriptUrl(scriptId) {
|
|
95
|
+
return `https://script.google.com/d/${scriptId}/edit`;
|
|
96
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getScriptClient } from '../../clients.js';
|
|
4
|
+
import { AppsScriptFileSchema, ensureManifest, scriptUrl, toApiFiles, toUserError, } from './appsScriptShared.js';
|
|
5
|
+
export function register(server) {
|
|
6
|
+
server.addTool({
|
|
7
|
+
name: 'createAppsScriptProject',
|
|
8
|
+
description: 'Creates an Apps Script project, optionally bound to a Doc, Sheet, Slides or Form so its triggers and custom menus run inside that file. Optionally writes the initial files in the same call. Requires the Apps Script API to be enabled at https://script.google.com/home/usersettings.',
|
|
9
|
+
parameters: z.strictObject({
|
|
10
|
+
title: z.string().min(1).describe('Title of the new script project.'),
|
|
11
|
+
parentId: z
|
|
12
|
+
.string()
|
|
13
|
+
.optional()
|
|
14
|
+
.describe('File ID of the Doc, Sheet, Slides or Form to bind the project to (a container-bound script). Omit to create a standalone project in Drive.'),
|
|
15
|
+
files: z
|
|
16
|
+
.array(AppsScriptFileSchema)
|
|
17
|
+
.optional()
|
|
18
|
+
.describe('Initial files to write after the project is created. A manifest is added automatically when the list has none.'),
|
|
19
|
+
}),
|
|
20
|
+
execute: async (args, { log }) => {
|
|
21
|
+
const script = await getScriptClient();
|
|
22
|
+
log.info(`Creating Apps Script project "${args.title}"${args.parentId ? ` bound to ${args.parentId}` : ' (standalone)'}`);
|
|
23
|
+
let scriptId;
|
|
24
|
+
try {
|
|
25
|
+
const created = await script.projects.create({
|
|
26
|
+
requestBody: {
|
|
27
|
+
title: args.title,
|
|
28
|
+
...(args.parentId ? { parentId: args.parentId } : {}),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
if (!created.data.scriptId) {
|
|
32
|
+
throw new UserError('Project was created but the API returned no scriptId.');
|
|
33
|
+
}
|
|
34
|
+
scriptId = created.data.scriptId;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error instanceof UserError)
|
|
38
|
+
throw error;
|
|
39
|
+
log.error(`Error creating Apps Script project: ${error.message || error}`);
|
|
40
|
+
throw toUserError(error, 'creating the Apps Script project');
|
|
41
|
+
}
|
|
42
|
+
let filesWritten;
|
|
43
|
+
if (args.files && args.files.length > 0) {
|
|
44
|
+
try {
|
|
45
|
+
// A freshly created project already carries a default manifest; keep it
|
|
46
|
+
// unless the caller supplied one of their own.
|
|
47
|
+
const current = await script.projects.getContent({ scriptId });
|
|
48
|
+
const existing = current.data.files ?? [];
|
|
49
|
+
const payload = ensureManifest(toApiFiles(args.files), existing);
|
|
50
|
+
await script.projects.updateContent({
|
|
51
|
+
scriptId,
|
|
52
|
+
requestBody: { files: payload },
|
|
53
|
+
});
|
|
54
|
+
filesWritten = payload.length;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
log.warn(`Project created but writing files failed: ${error.message || error}`);
|
|
58
|
+
return JSON.stringify({
|
|
59
|
+
scriptId,
|
|
60
|
+
url: scriptUrl(scriptId),
|
|
61
|
+
filesWritten: 0,
|
|
62
|
+
warning: `Project created, but writing the initial files failed: ${error.message || error}. Retry with updateAppsScriptContent.`,
|
|
63
|
+
}, null, 2);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return JSON.stringify({
|
|
67
|
+
scriptId,
|
|
68
|
+
url: scriptUrl(scriptId),
|
|
69
|
+
bound: Boolean(args.parentId),
|
|
70
|
+
...(filesWritten !== undefined ? { filesWritten } : {}),
|
|
71
|
+
}, null, 2);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getScriptClient } from '../../clients.js';
|
|
3
|
+
import { scriptUrl, toUserError } from './appsScriptShared.js';
|
|
4
|
+
export function register(server) {
|
|
5
|
+
server.addTool({
|
|
6
|
+
name: 'getAppsScriptContent',
|
|
7
|
+
description: 'Reads the files of an Apps Script project. Use it before updateAppsScriptContent to see what is already there, or to review code that is currently deployed.',
|
|
8
|
+
parameters: z.strictObject({
|
|
9
|
+
scriptId: z
|
|
10
|
+
.string()
|
|
11
|
+
.min(1)
|
|
12
|
+
.describe('Script project ID - the long ID in the /projects/<scriptId>/ part of the Apps Script editor URL. This is not the spreadsheet ID.'),
|
|
13
|
+
versionNumber: z
|
|
14
|
+
.number()
|
|
15
|
+
.int()
|
|
16
|
+
.positive()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe('Read a specific saved version instead of the current draft.'),
|
|
19
|
+
includeSource: z
|
|
20
|
+
.boolean()
|
|
21
|
+
.default(true)
|
|
22
|
+
.describe('Set to false to list file names and types without their content.'),
|
|
23
|
+
}),
|
|
24
|
+
execute: async (args, { log }) => {
|
|
25
|
+
const script = await getScriptClient();
|
|
26
|
+
log.info(`Reading Apps Script project ${args.scriptId}`);
|
|
27
|
+
try {
|
|
28
|
+
const response = await script.projects.getContent({
|
|
29
|
+
scriptId: args.scriptId,
|
|
30
|
+
...(args.versionNumber ? { versionNumber: args.versionNumber } : {}),
|
|
31
|
+
});
|
|
32
|
+
const files = (response.data.files ?? []).map((file) => ({
|
|
33
|
+
name: file.name,
|
|
34
|
+
type: file.type,
|
|
35
|
+
...(args.includeSource ? { source: file.source } : { lines: countLines(file.source) }),
|
|
36
|
+
}));
|
|
37
|
+
return JSON.stringify({
|
|
38
|
+
scriptId: args.scriptId,
|
|
39
|
+
url: scriptUrl(args.scriptId),
|
|
40
|
+
...(args.versionNumber ? { versionNumber: args.versionNumber } : {}),
|
|
41
|
+
fileCount: files.length,
|
|
42
|
+
files,
|
|
43
|
+
}, null, 2);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
log.error(`Error reading Apps Script project: ${error.message || error}`);
|
|
47
|
+
throw toUserError(error, 'reading the Apps Script project');
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function countLines(source) {
|
|
53
|
+
if (!source)
|
|
54
|
+
return 0;
|
|
55
|
+
return source.split('\n').length;
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { register as createAppsScriptProject } from './createAppsScriptProject.js';
|
|
2
|
+
import { register as getAppsScriptContent } from './getAppsScriptContent.js';
|
|
3
|
+
import { register as updateAppsScriptContent } from './updateAppsScriptContent.js';
|
|
4
|
+
/**
|
|
5
|
+
* Registers Apps Script project tools.
|
|
6
|
+
*
|
|
7
|
+
* These require the Apps Script API to be enabled per account at
|
|
8
|
+
* https://script.google.com/home/usersettings and the script.projects OAuth scope.
|
|
9
|
+
*/
|
|
10
|
+
export function registerScriptTools(server) {
|
|
11
|
+
createAppsScriptProject(server);
|
|
12
|
+
getAppsScriptContent(server);
|
|
13
|
+
updateAppsScriptContent(server);
|
|
14
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getScriptClient } from '../../clients.js';
|
|
3
|
+
import { AppsScriptFileSchema, ensureManifest, mergeFiles, scriptUrl, toApiFiles, toUserError, } from './appsScriptShared.js';
|
|
4
|
+
export function register(server) {
|
|
5
|
+
server.addTool({
|
|
6
|
+
name: 'updateAppsScriptContent',
|
|
7
|
+
description: 'Writes files into an existing Apps Script project. Default mode "merge" replaces same-named files and keeps the rest; mode "replace" makes the project contain exactly the files passed in. The manifest is preserved automatically.',
|
|
8
|
+
parameters: z.strictObject({
|
|
9
|
+
scriptId: z
|
|
10
|
+
.string()
|
|
11
|
+
.min(1)
|
|
12
|
+
.describe('Script project ID - the long ID in the /projects/<scriptId>/ part of the Apps Script editor URL.'),
|
|
13
|
+
files: z
|
|
14
|
+
.array(AppsScriptFileSchema)
|
|
15
|
+
.min(1)
|
|
16
|
+
.describe('Files to write. Each one replaces the file of the same name.'),
|
|
17
|
+
mode: z
|
|
18
|
+
.enum(['merge', 'replace'])
|
|
19
|
+
.default('merge')
|
|
20
|
+
.describe('merge: keep files that are not in this call (default, safe). replace: delete every file not listed here.'),
|
|
21
|
+
}),
|
|
22
|
+
execute: async (args, { log }) => {
|
|
23
|
+
const script = await getScriptClient();
|
|
24
|
+
log.info(`Updating Apps Script project ${args.scriptId} (${args.mode}, ${args.files.length} file(s))`);
|
|
25
|
+
try {
|
|
26
|
+
const current = await script.projects.getContent({ scriptId: args.scriptId });
|
|
27
|
+
const existing = current.data.files ?? [];
|
|
28
|
+
const incoming = toApiFiles(args.files);
|
|
29
|
+
const combined = args.mode === 'merge' ? mergeFiles(existing, incoming) : incoming;
|
|
30
|
+
const payload = ensureManifest(combined, existing);
|
|
31
|
+
const response = await script.projects.updateContent({
|
|
32
|
+
scriptId: args.scriptId,
|
|
33
|
+
requestBody: { files: payload },
|
|
34
|
+
});
|
|
35
|
+
const written = response.data.files ?? payload;
|
|
36
|
+
const removed = args.mode === 'replace'
|
|
37
|
+
? existing
|
|
38
|
+
.map((file) => file.name)
|
|
39
|
+
.filter((name) => name && !payload.some((file) => file.name === name))
|
|
40
|
+
: [];
|
|
41
|
+
return JSON.stringify({
|
|
42
|
+
scriptId: args.scriptId,
|
|
43
|
+
url: scriptUrl(args.scriptId),
|
|
44
|
+
mode: args.mode,
|
|
45
|
+
filesInProject: written.length,
|
|
46
|
+
updated: incoming.map((file) => file.name),
|
|
47
|
+
...(removed.length > 0 ? { removed } : {}),
|
|
48
|
+
}, null, 2);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
log.error(`Error updating Apps Script project: ${error.message || error}`);
|
|
52
|
+
throw toUserError(error, 'updating the Apps Script project');
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|