google-tools-mcp 1.2.6 → 1.2.8

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/dist/helpers.js CHANGED
@@ -212,3 +212,41 @@ export const getPlainTextBody = (messagePart) => {
212
212
  }
213
213
  return '';
214
214
  };
215
+
216
+ export const formatMessageClean = (message, maxBodyChars = 3000) => {
217
+ const headers = message.payload?.headers || [];
218
+ const get = (name) => findHeader(headers, name);
219
+ let body = getPlainTextBody(message.payload) || '';
220
+ const totalChars = body.length;
221
+ const truncated = maxBodyChars > 0 && body.length > maxBodyChars;
222
+ if (truncated) body = body.slice(0, maxBodyChars);
223
+ return {
224
+ id: message.id,
225
+ threadId: message.threadId,
226
+ labelIds: message.labelIds,
227
+ snippet: message.snippet,
228
+ from: get('from'),
229
+ to: get('to'),
230
+ cc: get('cc'),
231
+ subject: get('subject'),
232
+ date: get('date'),
233
+ body,
234
+ ...(truncated ? { bodyTruncated: true, totalChars } : {}),
235
+ };
236
+ };
237
+
238
+ export const formatMessageMetadata = (message) => {
239
+ const headers = message.payload?.headers || [];
240
+ const get = (name) => findHeader(headers, name);
241
+ return {
242
+ id: message.id,
243
+ threadId: message.threadId,
244
+ labelIds: message.labelIds,
245
+ snippet: message.snippet,
246
+ from: get('from'),
247
+ to: get('to'),
248
+ cc: get('cc'),
249
+ subject: get('subject'),
250
+ date: get('date'),
251
+ };
252
+ };
@@ -0,0 +1,57 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getDriveClient } from '../../clients.js';
4
+ export function register(server) {
5
+ server.addTool({
6
+ name: 'getRevision',
7
+ description: 'Gets details about a specific revision of a Drive file, including who made it, when, and an optional temporary download link to retrieve the content at that point in time.',
8
+ parameters: z.object({
9
+ fileId: z.string().describe('The ID of the Drive file.'),
10
+ revisionId: z.string().describe('The revision ID (from listRevisions).'),
11
+ includeDownloadLink: z.boolean().optional().default(false).describe('If true, includes a short-lived download URL for the revision content.'),
12
+ }),
13
+ execute: async (args, { log }) => {
14
+ const drive = await getDriveClient();
15
+ log.info(`Getting revision ${args.revisionId} for file: ${args.fileId}`);
16
+ try {
17
+ const fields = [
18
+ 'id', 'modifiedTime', 'lastModifyingUser(displayName,emailAddress)',
19
+ 'keepForever', 'published', 'publishedOutsideDomain', 'publishAuto',
20
+ 'mimeType', 'size',
21
+ ...(args.includeDownloadLink ? ['exportLinks', 'downloadUrl'] : []),
22
+ ].join(',');
23
+ const response = await drive.revisions.get({
24
+ fileId: args.fileId,
25
+ revisionId: args.revisionId,
26
+ fields,
27
+ });
28
+ const r = response.data;
29
+ const result = {
30
+ revisionId: r.id,
31
+ modifiedTime: r.modifiedTime,
32
+ modifiedBy: r.lastModifyingUser?.displayName || null,
33
+ modifiedByEmail: r.lastModifyingUser?.emailAddress || null,
34
+ mimeType: r.mimeType,
35
+ sizeBytes: r.size ? parseInt(r.size, 10) : null,
36
+ keepForever: r.keepForever || false,
37
+ published: r.published || false,
38
+ publishedOutsideDomain: r.publishedOutsideDomain || false,
39
+ publishAuto: r.publishAuto || false,
40
+ ...(args.includeDownloadLink ? {
41
+ downloadUrl: r.downloadUrl || null,
42
+ exportLinks: r.exportLinks || null,
43
+ } : {}),
44
+ };
45
+ return JSON.stringify(result, null, 2);
46
+ }
47
+ catch (error) {
48
+ log.error(`Error getting revision: ${error.message || error}`);
49
+ if (error.code === 404)
50
+ throw new UserError(`File or revision not found (fileId: ${args.fileId}, revisionId: ${args.revisionId}).`);
51
+ if (error.code === 403)
52
+ throw new UserError('Permission denied. Make sure you have access to this file.');
53
+ throw new UserError(`Failed to get revision: ${error.message || 'Unknown error'}`);
54
+ }
55
+ },
56
+ });
57
+ }
@@ -19,6 +19,9 @@ import { register as listPermissions } from './listPermissions.js';
19
19
  import { register as addPermission } from './addPermission.js';
20
20
  import { register as removePermission } from './removePermission.js';
21
21
  import { register as updatePermission } from './updatePermission.js';
22
+ import { register as listRevisions } from './listRevisions.js';
23
+ import { register as getRevision } from './getRevision.js';
24
+ import { register as updateRevision } from './updateRevision.js';
22
25
  export function registerDriveTools(server) {
23
26
  listDriveFiles(server);
24
27
  searchGoogleDocs(server);
@@ -41,4 +44,7 @@ export function registerDriveTools(server) {
41
44
  addPermission(server);
42
45
  removePermission(server);
43
46
  updatePermission(server);
47
+ listRevisions(server);
48
+ getRevision(server);
49
+ updateRevision(server);
44
50
  }
@@ -0,0 +1,50 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getDriveClient } from '../../clients.js';
4
+ export function register(server) {
5
+ server.addTool({
6
+ name: 'listRevisions',
7
+ description: 'Lists all saved versions (revision history) of a Drive file such as a Google Doc, Sheet, or Slide. Returns each revision\'s ID, modifier, timestamp, and whether it is kept forever.',
8
+ parameters: z.object({
9
+ fileId: z.string().describe('The ID of the Drive file to list revisions for.'),
10
+ }),
11
+ execute: async (args, { log }) => {
12
+ const drive = await getDriveClient();
13
+ log.info(`Listing revisions for file: ${args.fileId}`);
14
+ try {
15
+ const revisions = [];
16
+ let pageToken;
17
+ do {
18
+ const response = await drive.revisions.list({
19
+ fileId: args.fileId,
20
+ fields: 'nextPageToken,revisions(id,modifiedTime,lastModifyingUser(displayName,emailAddress),keepForever,published,size)',
21
+ pageSize: 200,
22
+ ...(pageToken ? { pageToken } : {}),
23
+ });
24
+ const page = response.data.revisions || [];
25
+ revisions.push(...page);
26
+ pageToken = response.data.nextPageToken;
27
+ } while (pageToken);
28
+ const result = revisions.map((r, i) => ({
29
+ revisionId: r.id,
30
+ index: i + 1,
31
+ modifiedTime: r.modifiedTime,
32
+ modifiedBy: r.lastModifyingUser?.displayName || null,
33
+ modifiedByEmail: r.lastModifyingUser?.emailAddress || null,
34
+ keepForever: r.keepForever || false,
35
+ published: r.published || false,
36
+ sizeBytes: r.size ? parseInt(r.size, 10) : null,
37
+ }));
38
+ return JSON.stringify({ total: result.length, revisions: result }, null, 2);
39
+ }
40
+ catch (error) {
41
+ log.error(`Error listing revisions: ${error.message || error}`);
42
+ if (error.code === 404)
43
+ throw new UserError(`File not found (ID: ${args.fileId}).`);
44
+ if (error.code === 403)
45
+ throw new UserError('Permission denied. Make sure you have access to this file.');
46
+ throw new UserError(`Failed to list revisions: ${error.message || 'Unknown error'}`);
47
+ }
48
+ },
49
+ });
50
+ }
@@ -0,0 +1,51 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getDriveClient } from '../../clients.js';
4
+ export function register(server) {
5
+ server.addTool({
6
+ name: 'updateRevision',
7
+ description: 'Updates metadata for a specific revision of a Drive file. Use this to pin a revision so it is kept forever (never auto-deleted by Drive), or to control publishing settings for Google Docs/Slides.',
8
+ parameters: z.object({
9
+ fileId: z.string().describe('The ID of the Drive file.'),
10
+ revisionId: z.string().describe('The revision ID to update (from listRevisions).'),
11
+ keepForever: z.boolean().optional().describe('If true, this revision is kept indefinitely and will not be automatically deleted. Only applies to files with a binary content type; Google Docs revisions are always kept.'),
12
+ published: z.boolean().optional().describe('Whether this revision is published. Only applies to Google Docs and Slides.'),
13
+ publishAuto: z.boolean().optional().describe('Whether subsequent revisions are automatically republished. Only applies to Google Docs and Slides.'),
14
+ }),
15
+ execute: async (args, { log }) => {
16
+ const drive = await getDriveClient();
17
+ log.info(`Updating revision ${args.revisionId} for file: ${args.fileId}`);
18
+ const body = {};
19
+ if (args.keepForever !== undefined) body.keepForever = args.keepForever;
20
+ if (args.published !== undefined) body.published = args.published;
21
+ if (args.publishAuto !== undefined) body.publishAuto = args.publishAuto;
22
+ if (Object.keys(body).length === 0) {
23
+ throw new UserError('No fields to update. Provide at least one of: keepForever, published, publishAuto.');
24
+ }
25
+ try {
26
+ const response = await drive.revisions.update({
27
+ fileId: args.fileId,
28
+ revisionId: args.revisionId,
29
+ fields: 'id,keepForever,published,publishAuto,modifiedTime',
30
+ requestBody: body,
31
+ });
32
+ const r = response.data;
33
+ return JSON.stringify({
34
+ revisionId: r.id,
35
+ modifiedTime: r.modifiedTime,
36
+ keepForever: r.keepForever || false,
37
+ published: r.published || false,
38
+ publishAuto: r.publishAuto || false,
39
+ }, null, 2);
40
+ }
41
+ catch (error) {
42
+ log.error(`Error updating revision: ${error.message || error}`);
43
+ if (error.code === 404)
44
+ throw new UserError(`File or revision not found (fileId: ${args.fileId}, revisionId: ${args.revisionId}).`);
45
+ if (error.code === 403)
46
+ throw new UserError('Permission denied. You need edit access to update revisions.');
47
+ throw new UserError(`Failed to update revision: ${error.message || 'Unknown error'}`);
48
+ }
49
+ },
50
+ });
51
+ }
@@ -80,24 +80,4 @@ export function register(server) {
80
80
  },
81
81
  });
82
82
 
83
- server.addTool({
84
- name: 'update_label',
85
- description: 'Update an existing label',
86
- parameters: z.object({
87
- id: z.string().describe("The ID of the label to update"),
88
- name: z.string().optional().describe("The display name of the label"),
89
- messageListVisibility: z.enum(['show', 'hide']).optional().describe("Visibility of messages with this label"),
90
- labelListVisibility: z.enum(['labelShow', 'labelShowIfUnread', 'labelHide']).optional().describe("Visibility of the label in the label list"),
91
- color: z.object({
92
- textColor: z.string().describe("The text color as hex string"),
93
- backgroundColor: z.string().describe("The background color as hex string"),
94
- }).optional().describe("The color settings for the label"),
95
- }),
96
- execute: async (params) => {
97
- const { id, ...labelData } = params;
98
- const gmail = await getGmailClient();
99
- const { data } = await gmail.users.labels.update({ userId: 'me', id, requestBody: labelData });
100
- return JSON.stringify(data);
101
- },
102
- });
103
83
  }
@@ -1,7 +1,7 @@
1
1
  // Gmail Message tools
2
2
  import { z } from 'zod';
3
3
  import { getGmailClient } from '../../clients.js';
4
- import { processMessagePart, constructRawMessage, constructRawMessageWithAttachments, findHeader, formatEmailList, getNestedHistory, getPlainTextBody, isHtmlBody, wrapTextBody } from '../../helpers.js';
4
+ import { processMessagePart, constructRawMessage, constructRawMessageWithAttachments, findHeader, formatEmailList, getNestedHistory, getPlainTextBody, isHtmlBody, wrapTextBody, formatMessageClean, formatMessageMetadata } from '../../helpers.js';
5
5
 
6
6
  export function register(server) {
7
7
  server.addTool({
@@ -279,42 +279,60 @@ export function register(server) {
279
279
 
280
280
  server.addTool({
281
281
  name: 'get_message',
282
- description: 'Get a specific message by ID with format options',
282
+ description: 'Get a specific message by ID. format="clean" (default) returns from/to/subject/date/body as a flat object. format="metadata" returns headers only, no body. format="full" returns the raw MIME tree (current legacy behavior).',
283
283
  parameters: z.object({
284
284
  id: z.string().describe("The ID of the message to retrieve"),
285
- includeBodyHtml: z.boolean().optional().describe("Whether to include the parsed HTML in the return for each body"),
285
+ format: z.enum(['full', 'clean', 'metadata']).optional().default('clean').describe("Response format: clean (default), metadata (headers only), or full (raw MIME tree)"),
286
+ maxBodyChars: z.number().optional().default(3000).describe("Max body characters in clean mode. 0 = unlimited. Truncated responses include bodyTruncated: true."),
287
+ includeBodyHtml: z.boolean().optional().describe("In full mode only: whether to include parsed HTML body parts"),
286
288
  }),
287
289
  execute: async (params) => {
288
290
  const gmail = await getGmailClient();
289
291
  const { data } = await gmail.users.messages.get({ userId: 'me', id: params.id, format: 'full' });
290
- if (data.payload) {
291
- data.payload = processMessagePart(data.payload, params.includeBodyHtml);
292
- }
292
+ if (params.format === 'clean') return JSON.stringify(formatMessageClean(data, params.maxBodyChars));
293
+ if (params.format === 'metadata') return JSON.stringify(formatMessageMetadata(data));
294
+ if (data.payload) data.payload = processMessagePart(data.payload, params.includeBodyHtml);
293
295
  return JSON.stringify(data);
294
296
  },
295
297
  });
296
298
 
297
299
  server.addTool({
298
300
  name: 'list_messages',
299
- description: 'List messages in the user\'s mailbox with optional filtering',
301
+ description: 'List messages in the user\'s mailbox with optional filtering. format="metadata" (default) auto-fetches message details for each result. format="clean" includes the message body. format="full" returns raw MIME data. Omit format to get bare IDs only.',
300
302
  parameters: z.object({
301
303
  maxResults: z.number().optional().describe("Maximum number of messages to return (1-500)"),
302
304
  pageToken: z.string().optional().describe("Page token to retrieve a specific page of results"),
303
305
  q: z.string().optional().describe("Only return messages matching the specified query (same format as Gmail search box)"),
304
306
  labelIds: z.array(z.string()).optional().describe("Only return messages with labels that match all specified label IDs"),
305
307
  includeSpamTrash: z.boolean().optional().describe("Include messages from SPAM and TRASH"),
306
- includeBodyHtml: z.boolean().optional().describe("Whether to include the parsed HTML in the return for each body"),
308
+ format: z.enum(['full', 'clean', 'metadata']).optional().describe("When set, auto-fetches full message details. metadata=headers only (default when set), clean=with body, full=raw MIME tree."),
309
+ maxBodyChars: z.number().optional().default(3000).describe("Max body characters in clean mode. 0 = unlimited."),
310
+ includeBodyHtml: z.boolean().optional().describe("In full mode only: whether to include parsed HTML body parts"),
307
311
  }),
308
312
  execute: async (params) => {
309
313
  const gmail = await getGmailClient();
310
- const { data } = await gmail.users.messages.list({ userId: 'me', ...params });
311
- if (data.messages) {
312
- data.messages = data.messages.map(message => {
313
- if (message.payload) {
314
- message.payload = processMessagePart(message.payload, params.includeBodyHtml);
315
- }
316
- return message;
317
- });
314
+ const { data } = await gmail.users.messages.list({
315
+ userId: 'me',
316
+ maxResults: params.maxResults,
317
+ pageToken: params.pageToken,
318
+ q: params.q,
319
+ labelIds: params.labelIds,
320
+ includeSpamTrash: params.includeSpamTrash,
321
+ });
322
+ if (params.format && data.messages?.length) {
323
+ data.messages = await Promise.all(
324
+ data.messages.map(async ({ id }) => {
325
+ try {
326
+ const { data: msg } = await gmail.users.messages.get({ userId: 'me', id, format: 'full' });
327
+ if (params.format === 'clean') return formatMessageClean(msg, params.maxBodyChars);
328
+ if (params.format === 'metadata') return formatMessageMetadata(msg);
329
+ if (msg.payload) msg.payload = processMessagePart(msg.payload, params.includeBodyHtml);
330
+ return msg;
331
+ } catch (e) {
332
+ return { id, error: e.message || 'Failed to retrieve message' };
333
+ }
334
+ })
335
+ );
318
336
  }
319
337
  return JSON.stringify(data);
320
338
  },
@@ -322,98 +340,71 @@ export function register(server) {
322
340
 
323
341
  server.addTool({
324
342
  name: 'modify_message',
325
- description: 'Modify the labels on a message',
343
+ description: 'Modify the labels on one or more messages. Pass a single id or an array of ids.',
326
344
  parameters: z.object({
327
- id: z.string().describe("The ID of the message to modify"),
345
+ ids: z.union([z.string(), z.array(z.string())]).describe("Message ID or array of message IDs to modify"),
328
346
  addLabelIds: z.array(z.string()).optional().describe("Label IDs to add"),
329
347
  removeLabelIds: z.array(z.string()).optional().describe("Label IDs to remove"),
330
348
  }),
331
349
  execute: async (params) => {
332
350
  const gmail = await getGmailClient();
333
- const { data } = await gmail.users.messages.modify({
334
- userId: 'me', id: params.id,
335
- requestBody: { addLabelIds: params.addLabelIds, removeLabelIds: params.removeLabelIds }
351
+ const ids = Array.isArray(params.ids) ? params.ids : [params.ids];
352
+ const { data } = await gmail.users.messages.batchModify({
353
+ userId: 'me',
354
+ requestBody: { ids, addLabelIds: params.addLabelIds, removeLabelIds: params.removeLabelIds },
336
355
  });
337
- return JSON.stringify(data);
356
+ return JSON.stringify(data || { success: true });
338
357
  },
339
358
  });
340
359
 
341
360
  server.addTool({
342
361
  name: 'delete_message',
343
- description: 'Immediately and permanently delete a message',
362
+ description: 'Immediately and permanently delete one or more messages. Pass a single id or an array of ids.',
344
363
  parameters: z.object({
345
- id: z.string().describe("The ID of the message to delete"),
364
+ ids: z.union([z.string(), z.array(z.string())]).describe("Message ID or array of message IDs to delete"),
346
365
  }),
347
366
  execute: async (params) => {
348
367
  const gmail = await getGmailClient();
349
- const { data } = await gmail.users.messages.delete({ userId: 'me', id: params.id });
368
+ const ids = Array.isArray(params.ids) ? params.ids : [params.ids];
369
+ const { data } = await gmail.users.messages.batchDelete({ userId: 'me', requestBody: { ids } });
350
370
  return JSON.stringify(data || { success: true });
351
371
  },
352
372
  });
353
373
 
354
374
  server.addTool({
355
375
  name: 'trash_message',
356
- description: 'Move a message to the trash',
357
- parameters: z.object({
358
- id: z.string().describe("The ID of the message to move to trash"),
359
- }),
360
- execute: async (params) => {
361
- const gmail = await getGmailClient();
362
- const { data } = await gmail.users.messages.trash({ userId: 'me', id: params.id });
363
- return JSON.stringify(data);
364
- },
365
- });
366
-
367
- server.addTool({
368
- name: 'untrash_message',
369
- description: 'Remove a message from the trash',
376
+ description: 'Move one or more messages to the trash or restore them. Pass a single id or an array of ids.',
370
377
  parameters: z.object({
371
- id: z.string().describe("The ID of the message to remove from trash"),
378
+ ids: z.union([z.string(), z.array(z.string())]).describe("Message ID or array of message IDs"),
379
+ action: z.enum(['trash', 'untrash']).describe("'trash' to move to trash, 'untrash' to restore"),
372
380
  }),
373
381
  execute: async (params) => {
374
382
  const gmail = await getGmailClient();
375
- const { data } = await gmail.users.messages.untrash({ userId: 'me', id: params.id });
376
- return JSON.stringify(data);
377
- },
378
- });
379
-
380
- server.addTool({
381
- name: 'batch_delete_messages',
382
- description: 'Delete multiple messages',
383
- parameters: z.object({
384
- ids: z.array(z.string()).describe("The IDs of the messages to delete"),
385
- }),
386
- execute: async (params) => {
387
- const gmail = await getGmailClient();
388
- const { data } = await gmail.users.messages.batchDelete({ userId: 'me', requestBody: { ids: params.ids } });
389
- return JSON.stringify(data || { success: true });
383
+ const ids = Array.isArray(params.ids) ? params.ids : [params.ids];
384
+ const fn = params.action === 'untrash' ? 'untrash' : 'trash';
385
+ const results = await Promise.all(
386
+ ids.map(async (id) => {
387
+ try {
388
+ const { data } = await gmail.users.messages[fn]({ userId: 'me', id });
389
+ return data;
390
+ } catch (e) {
391
+ return { id, error: e.message || `Failed to ${fn} message` };
392
+ }
393
+ })
394
+ );
395
+ return JSON.stringify(ids.length === 1 ? results[0] : results);
390
396
  },
391
397
  });
392
398
 
393
- server.addTool({
394
- name: 'batch_modify_messages',
395
- description: 'Modify the labels on multiple messages',
396
- parameters: z.object({
397
- ids: z.array(z.string()).describe("The IDs of the messages to modify"),
398
- addLabelIds: z.array(z.string()).optional().describe("Label IDs to add"),
399
- removeLabelIds: z.array(z.string()).optional().describe("Label IDs to remove"),
400
- }),
401
- execute: async (params) => {
402
- const gmail = await getGmailClient();
403
- const { data } = await gmail.users.messages.batchModify({
404
- userId: 'me',
405
- requestBody: { ids: params.ids, addLabelIds: params.addLabelIds, removeLabelIds: params.removeLabelIds }
406
- });
407
- return JSON.stringify(data || { success: true });
408
- },
409
- });
410
399
 
411
400
  server.addTool({
412
401
  name: 'batch_get_messages',
413
402
  description: 'Get multiple messages by ID in parallel. More efficient than calling get_message multiple times.',
414
403
  parameters: z.object({
415
404
  ids: z.array(z.string()).describe("The IDs of the messages to retrieve"),
416
- includeBodyHtml: z.boolean().optional().describe("Whether to include the parsed HTML in the return for each body"),
405
+ format: z.enum(['full', 'clean', 'metadata']).optional().default('clean').describe("Response format: clean (default), metadata (headers only), or full (raw MIME tree)"),
406
+ maxBodyChars: z.number().optional().default(3000).describe("Max body characters in clean mode. 0 = unlimited."),
407
+ includeBodyHtml: z.boolean().optional().describe("In full mode only: whether to include parsed HTML body parts"),
417
408
  }),
418
409
  execute: async (params) => {
419
410
  const gmail = await getGmailClient();
@@ -421,9 +412,9 @@ export function register(server) {
421
412
  params.ids.map(async (id) => {
422
413
  try {
423
414
  const { data } = await gmail.users.messages.get({ userId: 'me', id, format: 'full' });
424
- if (data.payload) {
425
- data.payload = processMessagePart(data.payload, params.includeBodyHtml);
426
- }
415
+ if (params.format === 'clean') return formatMessageClean(data, params.maxBodyChars);
416
+ if (params.format === 'metadata') return formatMessageMetadata(data);
417
+ if (data.payload) data.payload = processMessagePart(data.payload, params.includeBodyHtml);
427
418
  return data;
428
419
  } catch (error) {
429
420
  return { id, error: error.message || 'Failed to retrieve message' };
@@ -1,24 +1,26 @@
1
1
  // Gmail Thread tools
2
2
  import { z } from 'zod';
3
3
  import { getGmailClient } from '../../clients.js';
4
- import { processMessagePart } from '../../helpers.js';
4
+ import { processMessagePart, formatMessageClean, formatMessageMetadata } from '../../helpers.js';
5
5
 
6
6
  export function register(server) {
7
7
  server.addTool({
8
8
  name: 'get_thread',
9
- description: 'Get a specific thread by ID',
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
10
  parameters: z.object({
11
11
  id: z.string().describe("The ID of the thread to retrieve"),
12
- includeBodyHtml: z.boolean().optional().describe("Whether to include the parsed HTML in the return for each body"),
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"),
13
15
  }),
14
16
  execute: async (params) => {
15
17
  const gmail = await getGmailClient();
16
18
  const { data } = await gmail.users.threads.get({ userId: 'me', id: params.id, format: 'full' });
17
19
  if (data.messages) {
18
20
  data.messages = data.messages.map(message => {
19
- if (message.payload) {
20
- message.payload = processMessagePart(message.payload, params.includeBodyHtml);
21
- }
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);
22
24
  return message;
23
25
  });
24
26
  }
@@ -28,30 +30,46 @@ export function register(server) {
28
30
 
29
31
  server.addTool({
30
32
  name: 'list_threads',
31
- description: 'List threads in the user\'s mailbox',
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).',
32
34
  parameters: z.object({
33
35
  maxResults: z.number().optional().describe("Maximum number of threads to return"),
34
36
  pageToken: z.string().optional().describe("Page token to retrieve a specific page of results"),
35
37
  q: z.string().optional().describe("Only return threads matching the specified query"),
36
38
  labelIds: z.array(z.string()).optional().describe("Only return threads with labels that match all specified label IDs"),
37
39
  includeSpamTrash: z.boolean().optional().describe("Include threads from SPAM and TRASH"),
38
- includeBodyHtml: z.boolean().optional().describe("Whether to include the parsed HTML in the return for each body"),
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"),
39
43
  }),
40
44
  execute: async (params) => {
41
45
  const gmail = await getGmailClient();
42
- const { data } = await gmail.users.threads.list({ userId: 'me', ...params });
43
- if (data.threads) {
44
- data.threads = data.threads.map(thread => {
45
- if (thread.messages) {
46
- thread.messages = thread.messages.map(message => {
47
- if (message.payload) {
48
- message.payload = processMessagePart(message.payload, params.includeBodyHtml);
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
+ });
49
66
  }
50
- return message;
51
- });
52
- }
53
- return thread;
54
- });
67
+ return thread;
68
+ } catch (e) {
69
+ return { id, error: e.message || 'Failed to retrieve thread' };
70
+ }
71
+ })
72
+ );
55
73
  }
56
74
  return JSON.stringify(data);
57
75
  },
@@ -62,7 +80,9 @@ export function register(server) {
62
80
  description: 'Get multiple threads by ID in parallel. More efficient than calling get_thread multiple times.',
63
81
  parameters: z.object({
64
82
  ids: z.array(z.string()).describe("The IDs of the threads to retrieve"),
65
- includeBodyHtml: z.boolean().optional().describe("Whether to include the parsed HTML in the return for each body"),
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"),
66
86
  }),
67
87
  execute: async (params) => {
68
88
  const gmail = await getGmailClient();
@@ -72,9 +92,9 @@ export function register(server) {
72
92
  const { data } = await gmail.users.threads.get({ userId: 'me', id, format: 'full' });
73
93
  if (data.messages) {
74
94
  data.messages = data.messages.map(message => {
75
- if (message.payload) {
76
- message.payload = processMessagePart(message.payload, params.includeBodyHtml);
77
- }
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);
78
98
  return message;
79
99
  });
80
100
  }
@@ -119,27 +139,26 @@ export function register(server) {
119
139
 
120
140
  server.addTool({
121
141
  name: 'trash_thread',
122
- description: 'Move a thread to the trash',
123
- parameters: z.object({
124
- id: z.string().describe("The ID of the thread to move to trash"),
125
- }),
126
- execute: async (params) => {
127
- const gmail = await getGmailClient();
128
- const { data } = await gmail.users.threads.trash({ userId: 'me', id: params.id });
129
- return JSON.stringify(data);
130
- },
131
- });
132
-
133
- server.addTool({
134
- name: 'untrash_thread',
135
- description: 'Remove a thread from the trash',
142
+ description: 'Move one or more threads to the trash or restore them. Pass a single id or an array of ids.',
136
143
  parameters: z.object({
137
- id: z.string().describe("The ID of the thread to remove from trash"),
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"),
138
146
  }),
139
147
  execute: async (params) => {
140
148
  const gmail = await getGmailClient();
141
- const { data } = await gmail.users.threads.untrash({ userId: 'me', id: params.id });
142
- return JSON.stringify(data);
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);
143
162
  },
144
163
  });
145
164
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "google-tools-mcp",
3
- "version": "1.2.6",
3
+ "version": "1.2.8",
4
4
  "description": "The easiest MCP server for Google Workspace — Drive, Docs, Sheets, Gmail, Calendar, and Forms. 153 tools with one-click browser auth. Read Word docs, PDFs, and spreadsheets straight from Drive.",
5
5
  "type": "module",
6
6
  "bin": {