google-tools-mcp 1.2.11 → 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.
Files changed (97) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +555 -276
  3. package/dist/cachedToolsList.js +52 -52
  4. package/dist/googleDocsApiHelpers.js +76 -12
  5. package/dist/helpers.js +572 -252
  6. package/dist/httpAuth.js +296 -0
  7. package/dist/index.js +162 -11
  8. package/dist/logger.js +87 -87
  9. package/dist/markdown-transformer/docsToMarkdown.js +222 -21
  10. package/dist/markdown-transformer/index.js +19 -9
  11. package/dist/markdown-transformer/markdownToDocs.js +458 -28
  12. package/dist/readTracker.js +136 -17
  13. package/dist/sessionContext.js +37 -0
  14. package/dist/setup.js +187 -9
  15. package/dist/tools/calendar/getBusy.js +64 -64
  16. package/dist/tools/calendar/getEvents.js +141 -141
  17. package/dist/tools/calendar/getFree.js +225 -225
  18. package/dist/tools/calendar/index.js +19 -19
  19. package/dist/tools/calendar/listCalendars.js +38 -38
  20. package/dist/tools/calendar/listRecurringInstances.js +83 -83
  21. package/dist/tools/calendar/manageCalendar.js +121 -121
  22. package/dist/tools/calendar/manageEvent.js +258 -258
  23. package/dist/tools/calendar/moveEvent.js +66 -66
  24. package/dist/tools/docs/addTab.js +10 -12
  25. package/dist/tools/docs/appendToGoogleDoc.js +9 -4
  26. package/dist/tools/docs/deleteRange.js +9 -4
  27. package/dist/tools/docs/findAndReplace.js +9 -4
  28. package/dist/tools/docs/formatting/applyParagraphStyle.js +4 -1
  29. package/dist/tools/docs/formatting/getFormatting.js +171 -171
  30. package/dist/tools/docs/insertImage.js +15 -2
  31. package/dist/tools/docs/insertPageBreak.js +4 -1
  32. package/dist/tools/docs/insertTable.js +4 -1
  33. package/dist/tools/docs/insertTableWithData.js +4 -1
  34. package/dist/tools/docs/modifyText.js +14 -4
  35. package/dist/tools/docs/modifyText.test.js +84 -84
  36. package/dist/tools/docs/readGoogleDoc.js +71 -11
  37. package/dist/tools/docs/renameTab.js +12 -14
  38. package/dist/tools/drafts.js +165 -165
  39. package/dist/tools/drive/createDocument.js +13 -1
  40. package/dist/tools/drive/downloadFile.js +268 -268
  41. package/dist/tools/drive/getFileInfo.js +48 -48
  42. package/dist/tools/drive/getFilePath.js +50 -50
  43. package/dist/tools/drive/listDriveFiles.js +112 -112
  44. package/dist/tools/drive/listSharedDrives.js +52 -52
  45. package/dist/tools/drive/listSharedWithMe.js +97 -97
  46. package/dist/tools/drive/uploadFile.js +111 -111
  47. package/dist/tools/extras/index.js +9 -9
  48. package/dist/tools/extras/readDriveFile.js +212 -206
  49. package/dist/tools/extras/readFile.js +84 -84
  50. package/dist/tools/extras/searchFileContents.js +81 -81
  51. package/dist/tools/forms/batchUpdateForm.js +81 -81
  52. package/dist/tools/forms/createForm.js +54 -54
  53. package/dist/tools/forms/getForm.js +118 -118
  54. package/dist/tools/forms/getFormResponse.js +45 -45
  55. package/dist/tools/forms/index.js +15 -15
  56. package/dist/tools/forms/listFormResponses.js +56 -56
  57. package/dist/tools/forms/setPublishSettings.js +59 -59
  58. package/dist/tools/gmail/drafts.js +165 -165
  59. package/dist/tools/gmail/labels.js +54 -83
  60. package/dist/tools/gmail/messages.js +444 -441
  61. package/dist/tools/gmail/settings.js +358 -528
  62. package/dist/tools/gmail/threads.js +286 -164
  63. package/dist/tools/index.js +511 -473
  64. package/dist/tools/labels.js +103 -103
  65. package/dist/tools/legacyAliases.js +426 -0
  66. package/dist/tools/maps/directions.js +72 -0
  67. package/dist/tools/maps/geocode.js +16 -0
  68. package/dist/tools/maps/index.js +15 -0
  69. package/dist/tools/maps/mapsClient.js +83 -0
  70. package/dist/tools/maps/placeDetails.js +16 -0
  71. package/dist/tools/maps/reverseGeocode.js +19 -0
  72. package/dist/tools/maps/searchNearby.js +54 -0
  73. package/dist/tools/maps/searchPlaces.js +24 -0
  74. package/dist/tools/messages.js +448 -448
  75. package/dist/tools/settings.js +528 -528
  76. package/dist/tools/slides/createPresentation.js +104 -104
  77. package/dist/tools/slides/createShape.js +92 -92
  78. package/dist/tools/slides/createTextBox.js +82 -82
  79. package/dist/tools/slides/deleteSlide.js +30 -30
  80. package/dist/tools/slides/duplicateSlide.js +37 -37
  81. package/dist/tools/slides/exportThumbnail.js +42 -42
  82. package/dist/tools/slides/formatParagraph.js +72 -72
  83. package/dist/tools/slides/formatText.js +84 -84
  84. package/dist/tools/slides/getPresentation.js +87 -87
  85. package/dist/tools/slides/index.js +33 -33
  86. package/dist/tools/slides/reorderSlides.js +41 -41
  87. package/dist/tools/slides/replaceAllText.js +46 -46
  88. package/dist/tools/slides/setBackground.js +58 -58
  89. package/dist/tools/slides/speakerNotes.js +102 -102
  90. package/dist/tools/slides/styleShape.js +111 -111
  91. package/dist/tools/slides/updatePresentation.js +128 -128
  92. package/dist/tools/threads.js +145 -145
  93. package/dist/tools/utils/appendMarkdownToGoogleDoc.js +31 -7
  94. package/dist/tools/utils/replaceDocumentWithMarkdown.js +72 -9
  95. package/dist/updateCheck.js +202 -0
  96. package/dist/workspace.js +122 -0
  97. package/package.json +82 -80
@@ -1,164 +1,286 @@
1
- // Gmail Thread tools
2
- import { z } from 'zod';
3
- import { getGmailClient } from '../../clients.js';
4
- import { processMessagePart, formatMessageClean, formatMessageMetadata } from '../../helpers.js';
5
-
6
- export function register(server) {
7
- server.addTool({
8
- name: 'get_thread',
9
- description: 'Get a specific thread by ID. format="clean" (default) returns each message as from/to/subject/date/body. format="metadata" returns headers only. format="full" returns raw MIME trees.',
10
- parameters: z.object({
11
- id: z.string().describe("The ID of the thread to retrieve"),
12
- 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)"),
13
- maxBodyChars: z.number().optional().default(3000).describe("Max body characters per message in clean mode. 0 = unlimited."),
14
- includeBodyHtml: z.boolean().optional().describe("In full mode only: whether to include parsed HTML body parts"),
15
- }),
16
- execute: async (params) => {
17
- const gmail = await getGmailClient();
18
- const { data } = await gmail.users.threads.get({ userId: 'me', id: params.id, format: 'full' });
19
- if (data.messages) {
20
- data.messages = data.messages.map(message => {
21
- if (params.format === 'clean') return formatMessageClean(message, params.maxBodyChars);
22
- if (params.format === 'metadata') return formatMessageMetadata(message);
23
- if (message.payload) message.payload = processMessagePart(message.payload, params.includeBodyHtml);
24
- return message;
25
- });
26
- }
27
- return JSON.stringify(data);
28
- },
29
- });
30
-
31
- server.addTool({
32
- name: 'list_threads',
33
- description: 'List threads in the user\'s mailbox. format="metadata" (default) auto-fetches thread details with headers only. format="clean" includes message bodies. format="full" returns raw MIME data. Omit format to get bare thread stubs (id/snippet only).',
34
- parameters: z.object({
35
- maxResults: z.number().optional().describe("Maximum number of threads to return"),
36
- pageToken: z.string().optional().describe("Page token to retrieve a specific page of results"),
37
- q: z.string().optional().describe("Only return threads matching the specified query"),
38
- labelIds: z.array(z.string()).optional().describe("Only return threads with labels that match all specified label IDs"),
39
- includeSpamTrash: z.boolean().optional().describe("Include threads from SPAM and TRASH"),
40
- 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."),
41
- maxBodyChars: z.number().optional().default(3000).describe("Max body characters per message in clean mode. 0 = unlimited."),
42
- includeBodyHtml: z.boolean().optional().describe("In full mode only: whether to include parsed HTML body parts"),
43
- }),
44
- execute: async (params) => {
45
- const gmail = await getGmailClient();
46
- const { data } = await gmail.users.threads.list({
47
- userId: 'me',
48
- maxResults: params.maxResults,
49
- pageToken: params.pageToken,
50
- q: params.q,
51
- labelIds: params.labelIds,
52
- includeSpamTrash: params.includeSpamTrash,
53
- });
54
- if (params.format && data.threads?.length) {
55
- data.threads = await Promise.all(
56
- data.threads.map(async ({ id }) => {
57
- try {
58
- const { data: thread } = await gmail.users.threads.get({ userId: 'me', id, format: 'full' });
59
- if (thread.messages) {
60
- thread.messages = thread.messages.map(message => {
61
- if (params.format === 'clean') return formatMessageClean(message, params.maxBodyChars);
62
- if (params.format === 'metadata') return formatMessageMetadata(message);
63
- if (message.payload) message.payload = processMessagePart(message.payload, params.includeBodyHtml);
64
- return message;
65
- });
66
- }
67
- return thread;
68
- } catch (e) {
69
- return { id, error: e.message || 'Failed to retrieve thread' };
70
- }
71
- })
72
- );
73
- }
74
- return JSON.stringify(data);
75
- },
76
- });
77
-
78
- server.addTool({
79
- name: 'batch_get_threads',
80
- description: 'Get multiple threads by ID in parallel. More efficient than calling get_thread multiple times.',
81
- parameters: z.object({
82
- ids: z.array(z.string()).describe("The IDs of the threads to retrieve"),
83
- 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)"),
84
- maxBodyChars: z.number().optional().default(3000).describe("Max body characters per message in clean mode. 0 = unlimited."),
85
- includeBodyHtml: z.boolean().optional().describe("In full mode only: whether to include parsed HTML body parts"),
86
- }),
87
- execute: async (params) => {
88
- const gmail = await getGmailClient();
89
- const results = await Promise.all(
90
- params.ids.map(async (id) => {
91
- try {
92
- const { data } = await gmail.users.threads.get({ userId: 'me', id, format: 'full' });
93
- if (data.messages) {
94
- data.messages = data.messages.map(message => {
95
- if (params.format === 'clean') return formatMessageClean(message, params.maxBodyChars);
96
- if (params.format === 'metadata') return formatMessageMetadata(message);
97
- if (message.payload) message.payload = processMessagePart(message.payload, params.includeBodyHtml);
98
- return message;
99
- });
100
- }
101
- return data;
102
- } catch (error) {
103
- return { id, error: error.message || 'Failed to retrieve thread' };
104
- }
105
- })
106
- );
107
- return JSON.stringify(results);
108
- },
109
- });
110
-
111
- server.addTool({
112
- name: 'modify_thread',
113
- description: 'Modify the labels applied to a thread',
114
- parameters: z.object({
115
- id: z.string().describe("The ID of the thread to modify"),
116
- addLabelIds: z.array(z.string()).optional().describe("Label IDs to add"),
117
- removeLabelIds: z.array(z.string()).optional().describe("Label IDs to remove"),
118
- }),
119
- execute: async (params) => {
120
- const { id, ...threadData } = params;
121
- const gmail = await getGmailClient();
122
- const { data } = await gmail.users.threads.modify({ userId: 'me', id, requestBody: threadData });
123
- return JSON.stringify(data);
124
- },
125
- });
126
-
127
- server.addTool({
128
- name: 'delete_thread',
129
- description: 'Delete a thread',
130
- parameters: z.object({
131
- id: z.string().describe("The ID of the thread to delete"),
132
- }),
133
- execute: async (params) => {
134
- const gmail = await getGmailClient();
135
- const { data } = await gmail.users.threads.delete({ userId: 'me', id: params.id });
136
- return JSON.stringify(data || { success: true });
137
- },
138
- });
139
-
140
- server.addTool({
141
- name: 'trash_thread',
142
- description: 'Move one or more threads to the trash or restore them. Pass a single id or an array of ids.',
143
- parameters: z.object({
144
- ids: z.union([z.string(), z.array(z.string())]).describe("Thread ID or array of thread IDs"),
145
- action: z.enum(['trash', 'untrash']).describe("'trash' to move to trash, 'untrash' to restore"),
146
- }),
147
- execute: async (params) => {
148
- const gmail = await getGmailClient();
149
- const ids = Array.isArray(params.ids) ? params.ids : [params.ids];
150
- const fn = params.action === 'untrash' ? 'untrash' : 'trash';
151
- const results = await Promise.all(
152
- ids.map(async (id) => {
153
- try {
154
- const { data } = await gmail.users.threads[fn]({ userId: 'me', id });
155
- return data;
156
- } catch (e) {
157
- return { id, error: e.message || `Failed to ${fn} thread` };
158
- }
159
- })
160
- );
161
- return JSON.stringify(ids.length === 1 ? results[0] : results);
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
+ }