google-tools-mcp 1.2.12 → 2.0.0
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 +21 -21
- package/README.md +537 -276
- package/dist/cachedToolsList.js +52 -52
- package/dist/googleDocsApiHelpers.js +76 -12
- package/dist/helpers.js +572 -252
- package/dist/httpAuth.js +296 -0
- package/dist/index.js +162 -11
- package/dist/logger.js +87 -87
- package/dist/markdown-transformer/docsToMarkdown.js +128 -0
- package/dist/markdown-transformer/index.js +17 -7
- package/dist/markdown-transformer/markdownToDocs.js +185 -27
- package/dist/readTracker.js +136 -17
- package/dist/sessionContext.js +37 -0
- package/dist/setup.js +187 -9
- package/dist/tools/calendar/getBusy.js +64 -64
- package/dist/tools/calendar/getEvents.js +141 -141
- package/dist/tools/calendar/getFree.js +225 -225
- package/dist/tools/calendar/index.js +19 -19
- package/dist/tools/calendar/listCalendars.js +38 -38
- package/dist/tools/calendar/listRecurringInstances.js +83 -83
- package/dist/tools/calendar/manageCalendar.js +121 -121
- package/dist/tools/calendar/manageEvent.js +258 -258
- package/dist/tools/calendar/moveEvent.js +66 -66
- package/dist/tools/docs/addTab.js +10 -12
- package/dist/tools/docs/appendToGoogleDoc.js +9 -4
- package/dist/tools/docs/deleteRange.js +9 -4
- package/dist/tools/docs/findAndReplace.js +9 -4
- package/dist/tools/docs/formatting/applyParagraphStyle.js +4 -1
- package/dist/tools/docs/formatting/getFormatting.js +171 -171
- package/dist/tools/docs/insertImage.js +15 -2
- package/dist/tools/docs/insertPageBreak.js +4 -1
- package/dist/tools/docs/insertTable.js +4 -1
- package/dist/tools/docs/insertTableWithData.js +4 -1
- package/dist/tools/docs/modifyText.js +14 -4
- package/dist/tools/docs/modifyText.test.js +84 -84
- package/dist/tools/docs/readGoogleDoc.js +71 -11
- package/dist/tools/docs/renameTab.js +12 -14
- package/dist/tools/drafts.js +165 -165
- package/dist/tools/drive/createDocument.js +12 -0
- package/dist/tools/drive/downloadFile.js +268 -268
- package/dist/tools/drive/getFileInfo.js +48 -48
- package/dist/tools/drive/getFilePath.js +50 -50
- package/dist/tools/drive/listDriveFiles.js +112 -112
- package/dist/tools/drive/listSharedDrives.js +52 -52
- package/dist/tools/drive/listSharedWithMe.js +97 -97
- package/dist/tools/drive/uploadFile.js +111 -111
- package/dist/tools/extras/index.js +9 -9
- package/dist/tools/extras/readDriveFile.js +197 -196
- package/dist/tools/extras/readFile.js +84 -84
- package/dist/tools/extras/searchFileContents.js +81 -81
- package/dist/tools/forms/batchUpdateForm.js +81 -81
- package/dist/tools/forms/createForm.js +54 -54
- package/dist/tools/forms/getForm.js +118 -118
- package/dist/tools/forms/getFormResponse.js +45 -45
- package/dist/tools/forms/index.js +15 -15
- package/dist/tools/forms/listFormResponses.js +56 -56
- package/dist/tools/forms/setPublishSettings.js +59 -59
- package/dist/tools/gmail/drafts.js +165 -165
- package/dist/tools/gmail/labels.js +54 -83
- package/dist/tools/gmail/messages.js +444 -441
- package/dist/tools/gmail/settings.js +358 -528
- package/dist/tools/gmail/threads.js +286 -164
- package/dist/tools/index.js +511 -473
- package/dist/tools/labels.js +103 -103
- package/dist/tools/legacyAliases.js +426 -0
- package/dist/tools/maps/directions.js +72 -0
- package/dist/tools/maps/geocode.js +16 -0
- package/dist/tools/maps/index.js +15 -0
- package/dist/tools/maps/mapsClient.js +83 -0
- package/dist/tools/maps/placeDetails.js +16 -0
- package/dist/tools/maps/reverseGeocode.js +19 -0
- package/dist/tools/maps/searchNearby.js +54 -0
- package/dist/tools/maps/searchPlaces.js +24 -0
- package/dist/tools/messages.js +448 -448
- package/dist/tools/settings.js +528 -528
- package/dist/tools/slides/createPresentation.js +104 -104
- package/dist/tools/slides/createShape.js +92 -92
- package/dist/tools/slides/createTextBox.js +82 -82
- package/dist/tools/slides/deleteSlide.js +30 -30
- package/dist/tools/slides/duplicateSlide.js +37 -37
- package/dist/tools/slides/exportThumbnail.js +42 -42
- package/dist/tools/slides/formatParagraph.js +72 -72
- package/dist/tools/slides/formatText.js +84 -84
- package/dist/tools/slides/getPresentation.js +87 -87
- package/dist/tools/slides/index.js +33 -33
- package/dist/tools/slides/reorderSlides.js +41 -41
- package/dist/tools/slides/replaceAllText.js +46 -46
- package/dist/tools/slides/setBackground.js +58 -58
- package/dist/tools/slides/speakerNotes.js +102 -102
- package/dist/tools/slides/styleShape.js +111 -111
- package/dist/tools/slides/updatePresentation.js +128 -128
- package/dist/tools/threads.js +145 -145
- package/dist/tools/utils/appendMarkdownToGoogleDoc.js +30 -6
- package/dist/tools/utils/replaceDocumentWithMarkdown.js +71 -8
- package/dist/updateCheck.js +202 -0
- package/dist/workspace.js +122 -0
- package/package.json +82 -81
|
@@ -1,164 +1,286 @@
|
|
|
1
|
-
// Gmail Thread tools
|
|
2
|
-
import { z } from 'zod';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
includeBodyHtml: z.boolean().optional().describe("In full mode only: whether to include parsed HTML body parts"),
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
1
|
+
// Gmail Thread tools
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { UserError } from 'fastmcp';
|
|
4
|
+
import { getGmailClient } from '../../clients.js';
|
|
5
|
+
import { processMessagePart, formatMessageClean, formatMessageMetadata, capToResponseBudget, makeOmissionStub, DEFAULT_MAX_RESPONSE_CHARS } from '../../helpers.js';
|
|
6
|
+
|
|
7
|
+
// A single message can exceed maxResponseChars on its own (maxBodyChars only
|
|
8
|
+
// caps each MIME part independently, and headers/metadata add more on top).
|
|
9
|
+
// Rather than ship it unbounded or drop it with no explanation, replace it
|
|
10
|
+
// with a bounded stub naming the message id and how to fetch it directly.
|
|
11
|
+
const messageOmissionStub = (maxResponseChars) => (message, budgetForStub) => makeOmissionStub(message, budgetForStub, id =>
|
|
12
|
+
`This message alone is larger than maxResponseChars (${maxResponseChars}), even with per-part caps applied. Fetch it directly with messageIds: ["${id}"] and a larger maxResponseChars, or use format: 'metadata' for headers only.`);
|
|
13
|
+
|
|
14
|
+
// A single thread can exceed maxResponseChars on its own even after every
|
|
15
|
+
// message inside it has already been capped (e.g. maxResponseChars is small
|
|
16
|
+
// enough that even the per-thread floor does not fit). Same treatment as a
|
|
17
|
+
// single oversized message: a bounded stub rather than an unbounded thread
|
|
18
|
+
// or a silently dropped one.
|
|
19
|
+
const threadOmissionStub = (maxResponseChars) => (thread, budgetForStub) => makeOmissionStub(thread, budgetForStub, id =>
|
|
20
|
+
`This thread alone is larger than maxResponseChars (${maxResponseChars}), even after per-message capping. Fetch it directly with getThread using id: "${id}" and a larger maxResponseChars, a smaller maxMessages, or format: 'metadata'.`);
|
|
21
|
+
|
|
22
|
+
// Truncates fullNote to whatever room remains so that adding it to an object
|
|
23
|
+
// already measured at baseSize (before the note key exists) keeps the whole
|
|
24
|
+
// thing at or under maxChars. The same "measure the real remaining room,
|
|
25
|
+
// don't guess a constant" approach makeOmissionStub already uses for its
|
|
26
|
+
// reason text. Returns '' when there is no room left at all, so the caller
|
|
27
|
+
// can skip attaching the key entirely rather than add a useless empty note.
|
|
28
|
+
const boundedNote = (fullNote, baseSize, maxChars) => {
|
|
29
|
+
if (!maxChars || maxChars <= 0) return fullNote;
|
|
30
|
+
// Reserve a little extra for the "truncationNote" key, its quotes, and
|
|
31
|
+
// the comma joining it to what is already in the object.
|
|
32
|
+
const room = Math.max(0, maxChars - baseSize - 20);
|
|
33
|
+
return room > 0 ? fullNote.slice(0, room) : '';
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Applies the whole-response character budget to a thread's formatted
|
|
37
|
+
// messages array, keeping the latest messages (dropping oldest-first),
|
|
38
|
+
// stamping truncation metadata directly on the thread object (degrading its
|
|
39
|
+
// own note text to whatever room is left rather than a fixed length), and
|
|
40
|
+
// replacing a still-oversized last message with a bounded stub. Caps against
|
|
41
|
+
// the real serialized size of the whole thread object (not just its messages
|
|
42
|
+
// array), so headers, snippet, and every other field the Gmail API attaches
|
|
43
|
+
// are accounted for too.
|
|
44
|
+
//
|
|
45
|
+
// If even the smallest possible response for this thread, every message
|
|
46
|
+
// stubbed, the note dropped, still cannot fit maxResponseChars, there is no
|
|
47
|
+
// honest payload to return: this throws a UserError naming the measured
|
|
48
|
+
// minimum instead of silently shipping something far larger than requested.
|
|
49
|
+
const capThreadMessages = (thread, maxResponseChars) => {
|
|
50
|
+
if (!thread.messages) return thread;
|
|
51
|
+
const items = thread.messages;
|
|
52
|
+
const result = capToResponseBudget(items, maxResponseChars, 'start', messageOmissionStub(maxResponseChars), (capped, truncated, totalCount, includedCount, maxChars) => {
|
|
53
|
+
thread.messages = capped;
|
|
54
|
+
if (truncated) {
|
|
55
|
+
thread.responseTruncated = true;
|
|
56
|
+
thread.totalMessages = totalCount;
|
|
57
|
+
thread.includedMessages = includedCount;
|
|
58
|
+
const fullNote = `Showing the latest ${includedCount} of ${totalCount} messages to stay under maxResponseChars (${maxResponseChars}). Use maxMessages, messageIds, format:'metadata', or a smaller maxBodyChars to fetch specific messages, or raise/zero maxResponseChars for the rest.`;
|
|
59
|
+
const note = boundedNote(fullNote, JSON.stringify(thread).length, maxChars);
|
|
60
|
+
if (note) thread.truncationNote = note;
|
|
61
|
+
else delete thread.truncationNote;
|
|
62
|
+
} else {
|
|
63
|
+
delete thread.responseTruncated;
|
|
64
|
+
delete thread.totalMessages;
|
|
65
|
+
delete thread.includedMessages;
|
|
66
|
+
delete thread.truncationNote;
|
|
67
|
+
}
|
|
68
|
+
return thread;
|
|
69
|
+
});
|
|
70
|
+
if (!result.ok) {
|
|
71
|
+
throw new UserError(`maxResponseChars (${maxResponseChars}) is too small to return this thread, even with every message reduced to a bare size-omission stub. The smallest possible response for this thread is about ${result.minimumViableChars} characters. Retry with a larger maxResponseChars (at least ${result.minimumViableChars}), or use format: 'metadata' for a much smaller response.`);
|
|
72
|
+
}
|
|
73
|
+
return thread;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function register(server) {
|
|
77
|
+
server.addTool({
|
|
78
|
+
name: 'getThread',
|
|
79
|
+
description: 'Get a specific thread by ID. Clean mode removes quoted reply history when it can be safely identified; an ambiguous Outlook-style quote (no ">" prefixes to mark where it ends) is left in the body rather than guessed at, and flagged via quotedHistoryAmbiguous/quotedHistoryNote. Full mode returns raw MIME trees with decoded text bodies limited by maxBodyChars. Use maxMessages (latest N) or messageIds to fetch only the messages you need. Note: maxBodyChars caps each message body independently, not the total response size. maxResponseChars bounds the aggregate response and truncates (keeping the latest messages) with a truncationNote when the thread is too large.',
|
|
80
|
+
parameters: z.object({
|
|
81
|
+
id: z.string().describe("The ID of the thread to retrieve"),
|
|
82
|
+
format: z.enum(['full', 'clean', 'metadata']).optional().default('clean').describe("Response format for each message: clean (default), metadata (headers only), or full (raw MIME tree)"),
|
|
83
|
+
maxBodyChars: z.number().optional().default(3000).describe("Max decoded chars per text body — per message in clean mode, per MIME text part in full mode; not a whole-response cap. Oversized undecoded parts (e.g. HTML) are omitted with a totalChars note. 0 = unlimited."),
|
|
84
|
+
includeQuoted: z.boolean().optional().default(false).describe("In clean mode: skip quote detection entirely and always return the full body, including any quoted reply history. Default false. Use this if quotedHistoryAmbiguous keeps showing up and you'd rather have the full text every time than a per-message flag."),
|
|
85
|
+
includeBodyHtml: z.boolean().optional().describe("In full mode only: whether to include parsed HTML body parts"),
|
|
86
|
+
messageIds: z.array(z.string()).optional().describe("Only include messages with these IDs in the thread response. An empty array is treated as no filter."),
|
|
87
|
+
maxMessages: z.number().optional().describe("Only include the latest N messages of the thread (applied after messageIds). Omit for all. Use 1-2 for the usual 'just the latest reply' case."),
|
|
88
|
+
maxResponseChars: z.number().optional().default(DEFAULT_MAX_RESPONSE_CHARS).describe(`Whole-response character budget across all messages combined (unlike maxBodyChars, which only caps each message independently). When exceeded, the oldest messages are dropped (keeping the latest) and the response reports responseTruncated/totalMessages/includedMessages/truncationNote. Default ${DEFAULT_MAX_RESPONSE_CHARS}. 0 = unlimited.`),
|
|
89
|
+
}),
|
|
90
|
+
execute: async (params) => {
|
|
91
|
+
const gmail = await getGmailClient();
|
|
92
|
+
const { data } = await gmail.users.threads.get({ userId: 'me', id: params.id, format: 'full' });
|
|
93
|
+
if (data.messages) {
|
|
94
|
+
if (params.messageIds?.length) data.messages = data.messages.filter(message => params.messageIds.includes(message.id));
|
|
95
|
+
if (params.maxMessages > 0) data.messages = data.messages.slice(-params.maxMessages);
|
|
96
|
+
data.messages = data.messages.map(message => {
|
|
97
|
+
if (params.format === 'clean') return formatMessageClean(message, params.maxBodyChars, params.includeQuoted);
|
|
98
|
+
if (params.format === 'metadata') return formatMessageMetadata(message);
|
|
99
|
+
if (message.payload) message.payload = processMessagePart(message.payload, params.includeBodyHtml, params.maxBodyChars);
|
|
100
|
+
return message;
|
|
101
|
+
});
|
|
102
|
+
capThreadMessages(data, params.maxResponseChars);
|
|
103
|
+
}
|
|
104
|
+
return JSON.stringify(data);
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
server.addTool({
|
|
109
|
+
name: 'listThreads',
|
|
110
|
+
description: 'List threads in the user\'s mailbox. Clean mode removes quoted reply history when it can be safely identified; an ambiguous Outlook-style quote (no ">" prefixes to mark where it ends) is left in the body rather than guessed at, and flagged via quotedHistoryAmbiguous/quotedHistoryNote. Full mode limits decoded text bodies with maxBodyChars. Omit format to get bare thread stubs. maxResponseChars bounds the aggregate response size across all fetched threads combined and truncates (dropping the lowest-priority threads, then oldest messages within a thread) with a truncationNote when the call is too large.',
|
|
111
|
+
parameters: z.object({
|
|
112
|
+
maxResults: z.number().optional().describe("Maximum number of threads to return"),
|
|
113
|
+
pageToken: z.string().optional().describe("Page token to retrieve a specific page of results"),
|
|
114
|
+
q: z.string().optional().describe("Only return threads matching the specified query"),
|
|
115
|
+
labelIds: z.array(z.string()).optional().describe("Only return threads with labels that match all specified label IDs"),
|
|
116
|
+
includeSpamTrash: z.boolean().optional().describe("Include threads from SPAM and TRASH"),
|
|
117
|
+
format: z.enum(['full', 'clean', 'metadata']).optional().describe("When set, auto-fetches full thread details. metadata=headers only (default when set), clean=with bodies, full=raw MIME tree."),
|
|
118
|
+
maxBodyChars: z.number().optional().default(3000).describe("Max decoded chars per text body — per message in clean mode, per MIME text part in full mode; not a whole-response cap. Oversized undecoded parts (e.g. HTML) are omitted with a totalChars note. 0 = unlimited."),
|
|
119
|
+
includeQuoted: z.boolean().optional().default(false).describe("In clean mode: skip quote detection entirely and always return the full body, including any quoted reply history. Default false. Use this if quotedHistoryAmbiguous keeps showing up and you'd rather have the full text every time than a per-message flag."),
|
|
120
|
+
includeBodyHtml: z.boolean().optional().describe("In full mode only: whether to include parsed HTML body parts"),
|
|
121
|
+
maxMessages: z.number().optional().describe("Only include the latest N messages per thread. Omit for all."),
|
|
122
|
+
maxResponseChars: z.number().optional().default(DEFAULT_MAX_RESPONSE_CHARS).describe(`Whole-response character budget across every thread this call fetches combined (unlike maxBodyChars, which only caps each message independently, and applies per-thread besides). When exceeded, whole threads are dropped from the end of the list first; each retained thread is also capped individually. Reports responseTruncated/totalThreads/includedThreads/truncationNote at the top level, and the same per-thread when an individual thread's messages were cut. Default ${DEFAULT_MAX_RESPONSE_CHARS}. 0 = unlimited.`),
|
|
123
|
+
}),
|
|
124
|
+
execute: async (params) => {
|
|
125
|
+
const gmail = await getGmailClient();
|
|
126
|
+
const { data } = await gmail.users.threads.list({
|
|
127
|
+
userId: 'me',
|
|
128
|
+
maxResults: params.maxResults,
|
|
129
|
+
pageToken: params.pageToken,
|
|
130
|
+
q: params.q,
|
|
131
|
+
labelIds: params.labelIds,
|
|
132
|
+
includeSpamTrash: params.includeSpamTrash,
|
|
133
|
+
});
|
|
134
|
+
if (params.format && data.threads?.length) {
|
|
135
|
+
data.threads = await Promise.all(
|
|
136
|
+
data.threads.map(async ({ id }) => {
|
|
137
|
+
try {
|
|
138
|
+
const { data: thread } = await gmail.users.threads.get({ userId: 'me', id, format: 'full' });
|
|
139
|
+
if (thread.messages) {
|
|
140
|
+
if (params.maxMessages > 0) thread.messages = thread.messages.slice(-params.maxMessages);
|
|
141
|
+
thread.messages = thread.messages.map(message => {
|
|
142
|
+
if (params.format === 'clean') return formatMessageClean(message, params.maxBodyChars, params.includeQuoted);
|
|
143
|
+
if (params.format === 'metadata') return formatMessageMetadata(message);
|
|
144
|
+
if (message.payload) message.payload = processMessagePart(message.payload, params.includeBodyHtml, params.maxBodyChars);
|
|
145
|
+
return message;
|
|
146
|
+
});
|
|
147
|
+
capThreadMessages(thread, params.maxResponseChars);
|
|
148
|
+
}
|
|
149
|
+
return thread;
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return { id, error: e.message || 'Failed to retrieve thread' };
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
const threads = data.threads;
|
|
156
|
+
const threadsResult = capToResponseBudget(threads, params.maxResponseChars, 'end', threadOmissionStub(params.maxResponseChars), (capped, truncated, totalCount, includedCount, maxChars) => {
|
|
157
|
+
data.threads = capped;
|
|
158
|
+
if (truncated) {
|
|
159
|
+
data.responseTruncated = true;
|
|
160
|
+
data.totalThreads = totalCount;
|
|
161
|
+
data.includedThreads = includedCount;
|
|
162
|
+
const fullNote = `Showing ${includedCount} of ${totalCount} threads fetched this call to stay under maxResponseChars (${params.maxResponseChars}). Use pageToken to continue, a smaller maxResults/maxMessages/maxBodyChars, or raise/zero maxResponseChars for the rest.`;
|
|
163
|
+
const note = boundedNote(fullNote, JSON.stringify(data).length, maxChars);
|
|
164
|
+
if (note) data.truncationNote = note;
|
|
165
|
+
else delete data.truncationNote;
|
|
166
|
+
}
|
|
167
|
+
return data;
|
|
168
|
+
});
|
|
169
|
+
if (!threadsResult.ok) {
|
|
170
|
+
throw new UserError(`maxResponseChars (${params.maxResponseChars}) is too small to return any threads for this call, even a single thread reduced to a bare size-omission stub. The smallest possible response is about ${threadsResult.minimumViableChars} characters. Retry with a larger maxResponseChars, fewer maxResults, or format: 'metadata'.`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return JSON.stringify(data);
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
server.addTool({
|
|
178
|
+
name: 'batchGetThreads',
|
|
179
|
+
description: 'Get multiple threads by ID in parallel. Clean mode removes quoted reply history when it can be safely identified; an ambiguous Outlook-style quote (no ">" prefixes to mark where it ends) is left in the body rather than guessed at, and flagged via quotedHistoryAmbiguous/quotedHistoryNote. Full mode limits decoded text bodies with maxBodyChars. maxResponseChars bounds the aggregate response size across all requested threads combined: whole threads are dropped from the end of the ids list first, then each retained thread is capped individually, with truncation reported on the last returned thread (batchResponseTruncated/totalThreadsRequested/includedThreads/truncationNote).',
|
|
180
|
+
parameters: z.object({
|
|
181
|
+
ids: z.array(z.string()).describe("The IDs of the threads to retrieve"),
|
|
182
|
+
format: z.enum(['full', 'clean', 'metadata']).optional().default('clean').describe("Response format for each message: clean (default), metadata (headers only), or full (raw MIME tree)"),
|
|
183
|
+
maxBodyChars: z.number().optional().default(3000).describe("Max decoded chars per text body — per message in clean mode, per MIME text part in full mode; not a whole-response cap. Oversized undecoded parts (e.g. HTML) are omitted with a totalChars note. 0 = unlimited."),
|
|
184
|
+
includeQuoted: z.boolean().optional().default(false).describe("In clean mode: skip quote detection entirely and always return the full body, including any quoted reply history. Default false. Use this if quotedHistoryAmbiguous keeps showing up and you'd rather have the full text every time than a per-message flag."),
|
|
185
|
+
includeBodyHtml: z.boolean().optional().describe("In full mode only: whether to include parsed HTML body parts"),
|
|
186
|
+
maxMessages: z.number().optional().describe("Only include the latest N messages per thread. Omit for all."),
|
|
187
|
+
maxResponseChars: z.number().optional().default(DEFAULT_MAX_RESPONSE_CHARS).describe(`Whole-response character budget across every requested thread combined (unlike maxBodyChars, which only caps each message independently, and applies per-thread besides). When exceeded, whole threads are dropped from the end of the ids list first; each retained thread is also capped individually. Truncation is reported on the last returned thread via batchResponseTruncated/totalThreadsRequested/includedThreads/truncationNote, and per-thread when an individual thread's messages were cut. Default ${DEFAULT_MAX_RESPONSE_CHARS}. 0 = unlimited.`),
|
|
188
|
+
}),
|
|
189
|
+
execute: async (params) => {
|
|
190
|
+
const gmail = await getGmailClient();
|
|
191
|
+
const results = await Promise.all(
|
|
192
|
+
params.ids.map(async (id) => {
|
|
193
|
+
try {
|
|
194
|
+
const { data } = await gmail.users.threads.get({ userId: 'me', id, format: 'full' });
|
|
195
|
+
if (data.messages) {
|
|
196
|
+
if (params.maxMessages > 0) data.messages = data.messages.slice(-params.maxMessages);
|
|
197
|
+
data.messages = data.messages.map(message => {
|
|
198
|
+
if (params.format === 'clean') return formatMessageClean(message, params.maxBodyChars, params.includeQuoted);
|
|
199
|
+
if (params.format === 'metadata') return formatMessageMetadata(message);
|
|
200
|
+
if (message.payload) message.payload = processMessagePart(message.payload, params.includeBodyHtml, params.maxBodyChars);
|
|
201
|
+
return message;
|
|
202
|
+
});
|
|
203
|
+
capThreadMessages(data, params.maxResponseChars);
|
|
204
|
+
}
|
|
205
|
+
return data;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return { id, error: error.message || 'Failed to retrieve thread' };
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
);
|
|
211
|
+
const batchResult = capToResponseBudget(results, params.maxResponseChars, 'end', threadOmissionStub(params.maxResponseChars), (capped, truncated, totalCount, includedCount, maxChars) => {
|
|
212
|
+
if (!truncated) return capped;
|
|
213
|
+
const withMetadata = capped.slice();
|
|
214
|
+
const lastIndex = withMetadata.length - 1;
|
|
215
|
+
withMetadata[lastIndex] = {
|
|
216
|
+
...withMetadata[lastIndex],
|
|
217
|
+
batchResponseTruncated: true,
|
|
218
|
+
totalThreadsRequested: totalCount,
|
|
219
|
+
includedThreads: includedCount,
|
|
220
|
+
};
|
|
221
|
+
const fullNote = `Only ${includedCount} of ${totalCount} requested threads are included to stay under maxResponseChars (${params.maxResponseChars}). Re-run with fewer ids, a smaller maxMessages/maxBodyChars, or raise/zero maxResponseChars for the rest.`;
|
|
222
|
+
const note = boundedNote(fullNote, JSON.stringify(withMetadata).length, maxChars);
|
|
223
|
+
if (note) withMetadata[lastIndex] = { ...withMetadata[lastIndex], truncationNote: note };
|
|
224
|
+
return withMetadata;
|
|
225
|
+
});
|
|
226
|
+
if (!batchResult.ok) {
|
|
227
|
+
throw new UserError(`maxResponseChars (${params.maxResponseChars}) is too small to return any of the requested threads, even a single thread reduced to a bare size-omission stub. The smallest possible response is about ${batchResult.minimumViableChars} characters. Retry with a larger maxResponseChars, fewer ids, or format: 'metadata'.`);
|
|
228
|
+
}
|
|
229
|
+
return JSON.stringify(batchResult.payload);
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
server.addTool({
|
|
234
|
+
name: 'modifyThread',
|
|
235
|
+
description: 'Modify the labels applied to a thread',
|
|
236
|
+
parameters: z.object({
|
|
237
|
+
id: z.string().describe("The ID of the thread to modify"),
|
|
238
|
+
addLabelIds: z.array(z.string()).optional().describe("Label IDs to add"),
|
|
239
|
+
removeLabelIds: z.array(z.string()).optional().describe("Label IDs to remove"),
|
|
240
|
+
}),
|
|
241
|
+
execute: async (params) => {
|
|
242
|
+
const { id, ...threadData } = params;
|
|
243
|
+
const gmail = await getGmailClient();
|
|
244
|
+
const { data } = await gmail.users.threads.modify({ userId: 'me', id, requestBody: threadData });
|
|
245
|
+
return JSON.stringify(data);
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
server.addTool({
|
|
250
|
+
name: 'deleteThread',
|
|
251
|
+
description: 'Delete a thread',
|
|
252
|
+
parameters: z.object({
|
|
253
|
+
id: z.string().describe("The ID of the thread to delete"),
|
|
254
|
+
}),
|
|
255
|
+
execute: async (params) => {
|
|
256
|
+
const gmail = await getGmailClient();
|
|
257
|
+
const { data } = await gmail.users.threads.delete({ userId: 'me', id: params.id });
|
|
258
|
+
return JSON.stringify(data || { success: true });
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
server.addTool({
|
|
263
|
+
name: 'trashThread',
|
|
264
|
+
description: 'Move one or more threads to the trash or restore them. Pass a single id or an array of ids.',
|
|
265
|
+
parameters: z.object({
|
|
266
|
+
ids: z.union([z.string(), z.array(z.string())]).describe("Thread ID or array of thread IDs"),
|
|
267
|
+
action: z.enum(['trash', 'untrash']).describe("'trash' to move to trash, 'untrash' to restore"),
|
|
268
|
+
}),
|
|
269
|
+
execute: async (params) => {
|
|
270
|
+
const gmail = await getGmailClient();
|
|
271
|
+
const ids = Array.isArray(params.ids) ? params.ids : [params.ids];
|
|
272
|
+
const fn = params.action === 'untrash' ? 'untrash' : 'trash';
|
|
273
|
+
const results = await Promise.all(
|
|
274
|
+
ids.map(async (id) => {
|
|
275
|
+
try {
|
|
276
|
+
const { data } = await gmail.users.threads[fn]({ userId: 'me', id });
|
|
277
|
+
return data;
|
|
278
|
+
} catch (e) {
|
|
279
|
+
return { id, error: e.message || `Failed to ${fn} thread` };
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
);
|
|
283
|
+
return JSON.stringify(ids.length === 1 ? results[0] : results);
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
}
|