google-workspace-mcp 2.1.1 → 2.3.4

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 (43) hide show
  1. package/README.md +17 -2
  2. package/dist/accounts.d.ts.map +1 -1
  3. package/dist/accounts.js +1 -0
  4. package/dist/accounts.js.map +1 -1
  5. package/dist/excelHelpers.d.ts +108 -0
  6. package/dist/excelHelpers.d.ts.map +1 -0
  7. package/dist/excelHelpers.js +343 -0
  8. package/dist/excelHelpers.js.map +1 -0
  9. package/dist/securityHelpers.d.ts +118 -0
  10. package/dist/securityHelpers.d.ts.map +1 -0
  11. package/dist/securityHelpers.js +437 -0
  12. package/dist/securityHelpers.js.map +1 -0
  13. package/dist/server.js +22 -6
  14. package/dist/server.js.map +1 -1
  15. package/dist/serverWrapper.d.ts +9 -1
  16. package/dist/serverWrapper.d.ts.map +1 -1
  17. package/dist/serverWrapper.js +76 -7
  18. package/dist/serverWrapper.js.map +1 -1
  19. package/dist/tools/docs.tools.d.ts.map +1 -1
  20. package/dist/tools/docs.tools.js +29 -10
  21. package/dist/tools/docs.tools.js.map +1 -1
  22. package/dist/tools/drive.tools.d.ts.map +1 -1
  23. package/dist/tools/drive.tools.js +680 -6
  24. package/dist/tools/drive.tools.js.map +1 -1
  25. package/dist/tools/excel.tools.d.ts +3 -0
  26. package/dist/tools/excel.tools.d.ts.map +1 -0
  27. package/dist/tools/excel.tools.js +651 -0
  28. package/dist/tools/excel.tools.js.map +1 -0
  29. package/dist/tools/forms.tools.d.ts.map +1 -1
  30. package/dist/tools/forms.tools.js +13 -7
  31. package/dist/tools/forms.tools.js.map +1 -1
  32. package/dist/tools/gmail.tools.d.ts.map +1 -1
  33. package/dist/tools/gmail.tools.js +376 -37
  34. package/dist/tools/gmail.tools.js.map +1 -1
  35. package/dist/tools/sheets.tools.d.ts.map +1 -1
  36. package/dist/tools/sheets.tools.js +138 -4
  37. package/dist/tools/sheets.tools.js.map +1 -1
  38. package/dist/tools/slides.tools.d.ts.map +1 -1
  39. package/dist/tools/slides.tools.js +3 -1
  40. package/dist/tools/slides.tools.js.map +1 -1
  41. package/dist/types.d.ts +5 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/package.json +12 -6
@@ -1,9 +1,15 @@
1
1
  // gmail.tools.ts - Gmail tool module
2
+ import * as fs from 'fs/promises';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
2
5
  import { z } from 'zod';
3
6
  import { formatToolError } from '../errorHelpers.js';
4
- import { getGmailMessageUrl, getGmailDraftsUrl } from '../urlHelpers.js';
7
+ import { getGmailMessageUrl, getGmailDraftsUrl, getDriveFileUrl } from '../urlHelpers.js';
8
+ import { Readable } from 'stream';
9
+ import { validateWritePath, wrapEmailContent } from '../securityHelpers.js';
10
+ import { getServerConfig } from '../serverWrapper.js';
5
11
  export function registerGmailTools(options) {
6
- const { server, getGmailClient, getAccountEmail } = options;
12
+ const { server, getGmailClient, getDriveClient, getAccountEmail } = options;
7
13
  server.addTool({
8
14
  name: 'listGmailMessages',
9
15
  description: 'List email messages from Gmail inbox or other labels. Returns message IDs and snippets.',
@@ -79,6 +85,10 @@ export function registerGmailTools(options) {
79
85
  .optional()
80
86
  .default('full')
81
87
  .describe('Response format'),
88
+ maxLength: z
89
+ .number()
90
+ .optional()
91
+ .describe('Maximum character length of the response. If the result exceeds this, it will be truncated with a notice.'),
82
92
  }),
83
93
  async execute(args, { log: _log }) {
84
94
  try {
@@ -154,7 +164,13 @@ export function registerGmailTools(options) {
154
164
  if (message.snippet) {
155
165
  result += `**Snippet:** ${message.snippet}\n\n`;
156
166
  }
157
- result += `**Body**\n${body || '(empty)'}\n\n`;
167
+ // Wrap email body with security warnings to defend against prompt injection
168
+ const from = getHeader('From');
169
+ const subject = getHeader('Subject');
170
+ const wrappedBody = body
171
+ ? wrapEmailContent(body, from || undefined, subject || undefined)
172
+ : '(empty)';
173
+ result += `**Body**\n${wrappedBody}\n\n`;
158
174
  if (attachments.length > 0) {
159
175
  result += `**Attachments (${attachments.length})**\n`;
160
176
  attachments.forEach((att, i) => {
@@ -167,6 +183,11 @@ export function registerGmailTools(options) {
167
183
  if (link) {
168
184
  result += `View in Gmail: ${link}`;
169
185
  }
186
+ // Apply maxLength truncation if specified
187
+ if (args.maxLength && result.length > args.maxLength) {
188
+ result = result.substring(0, args.maxLength);
189
+ result += `\n\n[...TRUNCATED at ${args.maxLength} chars. Full message has more content. Use a larger maxLength or omit it to see everything.]`;
190
+ }
170
191
  return result;
171
192
  }
172
193
  catch (error) {
@@ -298,6 +319,78 @@ export function registerGmailTools(options) {
298
319
  }
299
320
  },
300
321
  });
322
+ // --- Create Gmail Label ---
323
+ server.addTool({
324
+ name: 'createGmailLabel',
325
+ description: 'Create a new Gmail label (folder). Labels can be used to organize emails. After creating a label, use addGmailLabel to apply it to messages.',
326
+ annotations: {
327
+ title: 'Create Gmail Label',
328
+ readOnlyHint: false,
329
+ destructiveHint: false,
330
+ idempotentHint: false,
331
+ openWorldHint: true,
332
+ },
333
+ parameters: z.object({
334
+ account: z.string().describe('Account name to use'),
335
+ name: z
336
+ .string()
337
+ .describe('Name of the label to create. Use "/" for nested labels (e.g., "Work/Projects")'),
338
+ labelListVisibility: z
339
+ .enum(['labelShow', 'labelShowIfUnread', 'labelHide'])
340
+ .optional()
341
+ .default('labelShow')
342
+ .describe('Whether to show the label in the label list: labelShow (always), labelShowIfUnread (only when unread), labelHide (hidden)'),
343
+ messageListVisibility: z
344
+ .enum(['show', 'hide'])
345
+ .optional()
346
+ .default('show')
347
+ .describe('Whether to show the label in the message list'),
348
+ backgroundColor: z
349
+ .string()
350
+ .optional()
351
+ .describe('Background color in hex format (e.g., "#16a765")'),
352
+ textColor: z.string().optional().describe('Text color in hex format (e.g., "#ffffff")'),
353
+ }),
354
+ async execute(args, { log: _log }) {
355
+ try {
356
+ const gmail = await getGmailClient(args.account);
357
+ const labelColor = args.backgroundColor || args.textColor
358
+ ? {
359
+ backgroundColor: args.backgroundColor,
360
+ textColor: args.textColor,
361
+ }
362
+ : undefined;
363
+ const response = await gmail.users.labels.create({
364
+ userId: 'me',
365
+ requestBody: {
366
+ name: args.name,
367
+ labelListVisibility: args.labelListVisibility,
368
+ messageListVisibility: args.messageListVisibility,
369
+ color: labelColor,
370
+ },
371
+ });
372
+ const label = response.data;
373
+ let result = `Successfully created label "${args.name}".\n\n`;
374
+ result += `Label ID: ${label.id}\n`;
375
+ result += `Name: ${label.name}\n`;
376
+ result += `Type: ${label.type}\n`;
377
+ if (label.labelListVisibility) {
378
+ result += `Label List Visibility: ${label.labelListVisibility}\n`;
379
+ }
380
+ if (label.messageListVisibility) {
381
+ result += `Message List Visibility: ${label.messageListVisibility}\n`;
382
+ }
383
+ if (label.color) {
384
+ result += `Color: ${label.color.backgroundColor || 'default'} / ${label.color.textColor || 'default'}\n`;
385
+ }
386
+ result += '\nUse this Label ID with addGmailLabel to apply it to messages.';
387
+ return result;
388
+ }
389
+ catch (error) {
390
+ throw new Error(formatToolError('createGmailLabel', error));
391
+ }
392
+ },
393
+ });
301
394
  // --- Add Gmail Label ---
302
395
  server.addTool({
303
396
  name: 'addGmailLabel',
@@ -463,26 +556,26 @@ export function registerGmailTools(options) {
463
556
  emailContent += `In-Reply-To: ${inReplyTo}\r\n`;
464
557
  if (references)
465
558
  emailContent += `References: ${references}\r\n`;
466
- emailContent += `MIME-Version: 1.0\r\n`;
559
+ emailContent += 'MIME-Version: 1.0\r\n';
467
560
  emailContent += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
468
- emailContent += `\r\n`;
561
+ emailContent += '\r\n';
469
562
  // Body part
470
563
  emailContent += `--${boundary}\r\n`;
471
564
  emailContent += `Content-Type: ${args.isHtml ? 'text/html' : 'text/plain'}; charset=utf-8\r\n`;
472
- emailContent += `Content-Transfer-Encoding: 7bit\r\n`;
473
- emailContent += `\r\n`;
565
+ emailContent += 'Content-Transfer-Encoding: 7bit\r\n';
566
+ emailContent += '\r\n';
474
567
  emailContent += `${args.body}\r\n`;
475
568
  // Attachment parts
476
569
  for (const attachment of args.attachments) {
477
570
  emailContent += `--${boundary}\r\n`;
478
571
  emailContent += `Content-Type: ${attachment.mimeType}; name="${attachment.filename}"\r\n`;
479
572
  emailContent += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
480
- emailContent += `Content-Transfer-Encoding: base64\r\n`;
481
- emailContent += `\r\n`;
573
+ emailContent += 'Content-Transfer-Encoding: base64\r\n';
574
+ emailContent += '\r\n';
482
575
  // Split base64 data into 76-character lines per RFC 2045
483
576
  const base64Lines = attachment.base64Data.match(/.{1,76}/g) || [];
484
577
  emailContent += base64Lines.join('\r\n');
485
- emailContent += `\r\n`;
578
+ emailContent += '\r\n';
486
579
  }
487
580
  emailContent += `--${boundary}--\r\n`;
488
581
  }
@@ -768,26 +861,26 @@ export function registerGmailTools(options) {
768
861
  if (newBcc)
769
862
  emailContent += `Bcc: ${newBcc}\r\n`;
770
863
  emailContent += `Subject: ${newSubject}\r\n`;
771
- emailContent += `MIME-Version: 1.0\r\n`;
864
+ emailContent += 'MIME-Version: 1.0\r\n';
772
865
  emailContent += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
773
- emailContent += `\r\n`;
866
+ emailContent += '\r\n';
774
867
  // Body part
775
868
  emailContent += `--${boundary}\r\n`;
776
869
  emailContent += `Content-Type: ${isHtml ? 'text/html' : 'text/plain'}; charset=utf-8\r\n`;
777
- emailContent += `Content-Transfer-Encoding: 7bit\r\n`;
778
- emailContent += `\r\n`;
870
+ emailContent += 'Content-Transfer-Encoding: 7bit\r\n';
871
+ emailContent += '\r\n';
779
872
  emailContent += `${newBody}\r\n`;
780
873
  // Attachment parts
781
874
  for (const attachment of args.attachments) {
782
875
  emailContent += `--${boundary}\r\n`;
783
876
  emailContent += `Content-Type: ${attachment.mimeType}; name="${attachment.filename}"\r\n`;
784
877
  emailContent += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
785
- emailContent += `Content-Transfer-Encoding: base64\r\n`;
786
- emailContent += `\r\n`;
878
+ emailContent += 'Content-Transfer-Encoding: base64\r\n';
879
+ emailContent += '\r\n';
787
880
  // Split base64 data into 76-character lines per RFC 2045
788
881
  const base64Lines = attachment.base64Data.match(/.{1,76}/g) || [];
789
882
  emailContent += base64Lines.join('\r\n');
790
- emailContent += `\r\n`;
883
+ emailContent += '\r\n';
791
884
  }
792
885
  emailContent += `--${boundary}--\r\n`;
793
886
  }
@@ -943,25 +1036,25 @@ export function registerGmailTools(options) {
943
1036
  if (getCurrentHeader('Bcc'))
944
1037
  emailContent += `Bcc: ${getCurrentHeader('Bcc')}\r\n`;
945
1038
  emailContent += `Subject: ${getCurrentHeader('Subject')}\r\n`;
946
- emailContent += `MIME-Version: 1.0\r\n`;
1039
+ emailContent += 'MIME-Version: 1.0\r\n';
947
1040
  emailContent += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
948
- emailContent += `\r\n`;
1041
+ emailContent += '\r\n';
949
1042
  // Body part
950
1043
  emailContent += `--${boundary}\r\n`;
951
1044
  emailContent += `Content-Type: ${bodyMimeType}; charset=utf-8\r\n`;
952
- emailContent += `Content-Transfer-Encoding: 7bit\r\n`;
953
- emailContent += `\r\n`;
1045
+ emailContent += 'Content-Transfer-Encoding: 7bit\r\n';
1046
+ emailContent += '\r\n';
954
1047
  emailContent += `${bodyContent}\r\n`;
955
1048
  // Attachment parts
956
1049
  for (const attachment of existingAttachments) {
957
1050
  emailContent += `--${boundary}\r\n`;
958
1051
  emailContent += `Content-Type: ${attachment.mimeType}; name="${attachment.filename}"\r\n`;
959
1052
  emailContent += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
960
- emailContent += `Content-Transfer-Encoding: base64\r\n`;
961
- emailContent += `\r\n`;
1053
+ emailContent += 'Content-Transfer-Encoding: base64\r\n';
1054
+ emailContent += '\r\n';
962
1055
  const base64Lines = attachment.data.match(/.{1,76}/g) || [];
963
1056
  emailContent += base64Lines.join('\r\n');
964
- emailContent += `\r\n`;
1057
+ emailContent += '\r\n';
965
1058
  }
966
1059
  emailContent += `--${boundary}--\r\n`;
967
1060
  const encodedEmail = Buffer.from(emailContent)
@@ -1088,25 +1181,25 @@ export function registerGmailTools(options) {
1088
1181
  if (getCurrentHeader('Bcc'))
1089
1182
  emailContent += `Bcc: ${getCurrentHeader('Bcc')}\r\n`;
1090
1183
  emailContent += `Subject: ${getCurrentHeader('Subject')}\r\n`;
1091
- emailContent += `MIME-Version: 1.0\r\n`;
1184
+ emailContent += 'MIME-Version: 1.0\r\n';
1092
1185
  emailContent += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
1093
- emailContent += `\r\n`;
1186
+ emailContent += '\r\n';
1094
1187
  // Body part
1095
1188
  emailContent += `--${boundary}\r\n`;
1096
1189
  emailContent += `Content-Type: ${bodyMimeType}; charset=utf-8\r\n`;
1097
- emailContent += `Content-Transfer-Encoding: 7bit\r\n`;
1098
- emailContent += `\r\n`;
1190
+ emailContent += 'Content-Transfer-Encoding: 7bit\r\n';
1191
+ emailContent += '\r\n';
1099
1192
  emailContent += `${bodyContent}\r\n`;
1100
1193
  // Remaining attachment parts
1101
1194
  for (const attachment of existingAttachments) {
1102
1195
  emailContent += `--${boundary}\r\n`;
1103
1196
  emailContent += `Content-Type: ${attachment.mimeType}; name="${attachment.filename}"\r\n`;
1104
1197
  emailContent += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
1105
- emailContent += `Content-Transfer-Encoding: base64\r\n`;
1106
- emailContent += `\r\n`;
1198
+ emailContent += 'Content-Transfer-Encoding: base64\r\n';
1199
+ emailContent += '\r\n';
1107
1200
  const base64Lines = attachment.data.match(/.{1,76}/g) || [];
1108
1201
  emailContent += base64Lines.join('\r\n');
1109
- emailContent += `\r\n`;
1202
+ emailContent += '\r\n';
1110
1203
  }
1111
1204
  emailContent += `--${boundary}--\r\n`;
1112
1205
  }
@@ -1256,7 +1349,7 @@ export function registerGmailTools(options) {
1256
1349
  // --- Get Gmail Attachment ---
1257
1350
  server.addTool({
1258
1351
  name: 'getGmailAttachment',
1259
- description: 'Download an attachment from a Gmail message. Returns the attachment as base64-encoded data along with metadata.',
1352
+ description: 'Get metadata and a preview of a Gmail attachment. Returns truncated base64 data (first 500 chars). For full attachment data or saving to file, use downloadGmailAttachment instead.',
1260
1353
  annotations: {
1261
1354
  title: 'Get Gmail Attachment',
1262
1355
  readOnlyHint: true,
@@ -1287,7 +1380,10 @@ export function registerGmailTools(options) {
1287
1380
  // The data is already base64url encoded from Gmail API
1288
1381
  result += `**Base64 Data (first 500 chars):**\n${attachment.data.substring(0, 500)}${attachment.data.length > 500 ? '...' : ''}\n\n`;
1289
1382
  result += `**Full data length:** ${attachment.data.length} characters\n`;
1290
- result += `\nNote: Data is base64url encoded. To decode, replace - with + and _ with /, then base64 decode.`;
1383
+ result +=
1384
+ '\nNote: Data is base64url encoded. To decode, replace - with + and _ with /, then base64 decode.';
1385
+ result +=
1386
+ '\n\nTip: Use downloadGmailAttachment to get full data or save directly to a file.';
1291
1387
  }
1292
1388
  else {
1293
1389
  result += 'No attachment data available.';
@@ -1299,6 +1395,234 @@ export function registerGmailTools(options) {
1299
1395
  }
1300
1396
  },
1301
1397
  });
1398
+ // --- Download Gmail Attachment ---
1399
+ server.addTool({
1400
+ name: 'downloadGmailAttachment',
1401
+ description: 'Download a Gmail attachment with full data. Returns complete base64-encoded data, or saves directly to a file if savePath is provided. Use this instead of getGmailAttachment when you need the full attachment content.',
1402
+ annotations: {
1403
+ title: 'Download Gmail Attachment',
1404
+ readOnlyHint: true,
1405
+ openWorldHint: true,
1406
+ },
1407
+ parameters: z.object({
1408
+ account: z.string().describe('Account name to use'),
1409
+ messageId: z.string().describe('The ID of the message containing the attachment'),
1410
+ attachmentId: z
1411
+ .string()
1412
+ .describe('The attachment ID (from readGmailMessage attachment info)'),
1413
+ savePath: z
1414
+ .string()
1415
+ .optional()
1416
+ .describe('Optional file path to save the attachment to. If provided, the decoded attachment is written to this path. The path must be absolute.'),
1417
+ }),
1418
+ async execute(args, { log: _log }) {
1419
+ try {
1420
+ const gmail = await getGmailClient(args.account);
1421
+ // First, get attachment metadata from the message to find the filename
1422
+ const messageResponse = await gmail.users.messages.get({
1423
+ userId: 'me',
1424
+ id: args.messageId,
1425
+ format: 'full',
1426
+ });
1427
+ // Find attachment info in message parts
1428
+ let attachmentFilename = 'attachment';
1429
+ let attachmentMimeType = 'application/octet-stream';
1430
+ const findAttachmentInfo = (part) => {
1431
+ if (part.body?.attachmentId === args.attachmentId && part.filename) {
1432
+ attachmentFilename = part.filename;
1433
+ attachmentMimeType = part.mimeType || 'application/octet-stream';
1434
+ return true;
1435
+ }
1436
+ if (part.parts) {
1437
+ for (const subpart of part.parts) {
1438
+ if (findAttachmentInfo(subpart))
1439
+ return true;
1440
+ }
1441
+ }
1442
+ return false;
1443
+ };
1444
+ if (messageResponse.data.payload) {
1445
+ findAttachmentInfo(messageResponse.data.payload);
1446
+ }
1447
+ // Get the attachment data
1448
+ const response = await gmail.users.messages.attachments.get({
1449
+ userId: 'me',
1450
+ messageId: args.messageId,
1451
+ id: args.attachmentId,
1452
+ });
1453
+ const attachment = response.data;
1454
+ const size = attachment.size || 0;
1455
+ if (!attachment.data) {
1456
+ throw new Error('No attachment data available');
1457
+ }
1458
+ // Convert base64url to standard base64
1459
+ const base64Data = attachment.data.replace(/-/g, '+').replace(/_/g, '/');
1460
+ let result = '**Attachment Downloaded**\n\n';
1461
+ result += `Filename: ${attachmentFilename}\n`;
1462
+ result += `MIME Type: ${attachmentMimeType}\n`;
1463
+ result += `Size: ${size} bytes (${(size / 1024).toFixed(2)} KB)\n`;
1464
+ result += `Message ID: ${args.messageId}\n`;
1465
+ result += `Attachment ID: ${args.attachmentId}\n\n`;
1466
+ if (args.savePath) {
1467
+ // Validate that savePath is absolute
1468
+ if (!path.isAbsolute(args.savePath)) {
1469
+ throw new Error(`savePath must be an absolute path. Received: ${args.savePath}`);
1470
+ }
1471
+ // Validate path for security
1472
+ const pathValidation = validateWritePath(args.savePath, getServerConfig().pathSecurity);
1473
+ if (!pathValidation.valid) {
1474
+ throw new Error(`Cannot save to this path: ${pathValidation.error}`);
1475
+ }
1476
+ // Decode and save to file
1477
+ const buffer = Buffer.from(base64Data, 'base64');
1478
+ // Ensure parent directory exists
1479
+ const parentDir = path.dirname(pathValidation.resolvedPath);
1480
+ await fs.mkdir(parentDir, { recursive: true });
1481
+ await fs.writeFile(pathValidation.resolvedPath, buffer);
1482
+ result += `**Saved to:** ${pathValidation.resolvedPath}\n`;
1483
+ result += `File size on disk: ${buffer.length} bytes`;
1484
+ }
1485
+ else {
1486
+ // Auto-save large attachments to temp file to avoid blowing up LLM context
1487
+ const MAX_INLINE_BYTES = 1024; // 1KB threshold
1488
+ const buffer = Buffer.from(base64Data, 'base64');
1489
+ if (buffer.length > MAX_INLINE_BYTES) {
1490
+ const tempDir = os.tmpdir();
1491
+ const safeName = attachmentFilename.replace(/[^a-zA-Z0-9._-]/g, '_');
1492
+ const tempPath = path.join(tempDir, `gmail-attachment-${Date.now()}-${safeName}`);
1493
+ await fs.writeFile(tempPath, buffer);
1494
+ result += `**Saved to:** ${tempPath}\n`;
1495
+ result += `File size on disk: ${buffer.length} bytes\n\n`;
1496
+ result += `⚠️ File was auto-saved to a temp path because it exceeds the ${MAX_INLINE_BYTES}-byte inline limit.\n`;
1497
+ result += `Use the savePath parameter to save to a specific location.`;
1498
+ }
1499
+ else {
1500
+ // Small enough to return inline
1501
+ result += `**Base64 Data (standard encoding):**\n${base64Data}`;
1502
+ }
1503
+ }
1504
+ return result;
1505
+ }
1506
+ catch (error) {
1507
+ throw new Error(formatToolError('downloadGmailAttachment', error));
1508
+ }
1509
+ },
1510
+ });
1511
+ // --- Save Gmail Attachment to Drive ---
1512
+ server.addTool({
1513
+ name: 'saveAttachmentToDrive',
1514
+ description: 'Save a Gmail attachment directly to Google Drive. Uploads the attachment as a file to Drive without downloading locally first.',
1515
+ annotations: {
1516
+ title: 'Save Attachment to Drive',
1517
+ readOnlyHint: false,
1518
+ destructiveHint: false,
1519
+ idempotentHint: false,
1520
+ openWorldHint: true,
1521
+ },
1522
+ parameters: z.object({
1523
+ account: z.string().describe('Account name to use'),
1524
+ messageId: z.string().describe('The ID of the message containing the attachment'),
1525
+ attachmentId: z
1526
+ .string()
1527
+ .describe('The attachment ID (from readGmailMessage attachment info)'),
1528
+ fileName: z
1529
+ .string()
1530
+ .optional()
1531
+ .describe('Optional custom file name for the saved file. If not provided, uses the original attachment name.'),
1532
+ folderId: z
1533
+ .string()
1534
+ .optional()
1535
+ .describe('Optional Google Drive folder ID to save the file to. If not provided, saves to root of My Drive.'),
1536
+ }),
1537
+ async execute(args, { log: _log }) {
1538
+ try {
1539
+ const gmail = await getGmailClient(args.account);
1540
+ const drive = await getDriveClient(args.account);
1541
+ const accountEmail = await getAccountEmail(args.account);
1542
+ // First, get attachment metadata from the message to find the filename
1543
+ const messageResponse = await gmail.users.messages.get({
1544
+ userId: 'me',
1545
+ id: args.messageId,
1546
+ format: 'full',
1547
+ });
1548
+ // Find attachment info in message parts
1549
+ let attachmentFilename = 'attachment';
1550
+ let attachmentMimeType = 'application/octet-stream';
1551
+ const findAttachmentInfo = (part) => {
1552
+ if (part.body?.attachmentId === args.attachmentId && part.filename) {
1553
+ attachmentFilename = part.filename;
1554
+ attachmentMimeType = part.mimeType || 'application/octet-stream';
1555
+ return true;
1556
+ }
1557
+ if (part.parts) {
1558
+ for (const subpart of part.parts) {
1559
+ if (findAttachmentInfo(subpart))
1560
+ return true;
1561
+ }
1562
+ }
1563
+ return false;
1564
+ };
1565
+ if (messageResponse.data.payload) {
1566
+ findAttachmentInfo(messageResponse.data.payload);
1567
+ }
1568
+ // Use custom filename if provided
1569
+ const finalFileName = args.fileName || attachmentFilename;
1570
+ // Get the attachment data
1571
+ const attachmentResponse = await gmail.users.messages.attachments.get({
1572
+ userId: 'me',
1573
+ messageId: args.messageId,
1574
+ id: args.attachmentId,
1575
+ });
1576
+ const attachment = attachmentResponse.data;
1577
+ const size = attachment.size || 0;
1578
+ if (!attachment.data) {
1579
+ throw new Error('No attachment data available');
1580
+ }
1581
+ // Convert base64url to standard base64, then to buffer
1582
+ const base64Data = attachment.data.replace(/-/g, '+').replace(/_/g, '/');
1583
+ const buffer = Buffer.from(base64Data, 'base64');
1584
+ // Create a readable stream from the buffer for Drive upload
1585
+ const bufferStream = new Readable();
1586
+ bufferStream.push(buffer);
1587
+ bufferStream.push(null);
1588
+ // Upload to Google Drive
1589
+ const fileMetadata = {
1590
+ name: finalFileName,
1591
+ };
1592
+ if (args.folderId) {
1593
+ fileMetadata.parents = [args.folderId];
1594
+ }
1595
+ const driveResponse = await drive.files.create({
1596
+ requestBody: fileMetadata,
1597
+ media: {
1598
+ mimeType: attachmentMimeType,
1599
+ body: bufferStream,
1600
+ },
1601
+ fields: 'id,name,webViewLink,mimeType,size',
1602
+ });
1603
+ const driveFile = driveResponse.data;
1604
+ const fileId = driveFile.id;
1605
+ if (!fileId) {
1606
+ throw new Error('Failed to upload file to Drive - no file ID returned');
1607
+ }
1608
+ const driveLink = getDriveFileUrl(fileId, accountEmail);
1609
+ let result = '**Attachment Saved to Drive**\n\n';
1610
+ result += `File Name: ${driveFile.name}\n`;
1611
+ result += `File ID: ${fileId}\n`;
1612
+ result += `MIME Type: ${driveFile.mimeType || attachmentMimeType}\n`;
1613
+ result += `Size: ${size} bytes (${(size / 1024).toFixed(2)} KB)\n`;
1614
+ if (args.folderId) {
1615
+ result += `Folder ID: ${args.folderId}\n`;
1616
+ }
1617
+ result += `\nView in Drive: ${driveLink}\n`;
1618
+ result += `\nSource Message ID: ${args.messageId}`;
1619
+ return result;
1620
+ }
1621
+ catch (error) {
1622
+ throw new Error(formatToolError('saveAttachmentToDrive', error));
1623
+ }
1624
+ },
1625
+ });
1302
1626
  // --- Mark as Read ---
1303
1627
  server.addTool({
1304
1628
  name: 'markAsRead',
@@ -1458,6 +1782,10 @@ export function registerGmailTools(options) {
1458
1782
  .optional()
1459
1783
  .default('full')
1460
1784
  .describe('Response format for messages in the thread'),
1785
+ maxLength: z
1786
+ .number()
1787
+ .optional()
1788
+ .describe('Maximum character length of the response. If the result exceeds this, it will be truncated with a notice.'),
1461
1789
  }),
1462
1790
  async execute(args, { log: _log }) {
1463
1791
  try {
@@ -1494,7 +1822,7 @@ export function registerGmailTools(options) {
1494
1822
  }
1495
1823
  return '';
1496
1824
  };
1497
- let result = `**Email Thread**\n\n`;
1825
+ let result = '**Email Thread**\n\n';
1498
1826
  result += `Thread ID: ${thread.id}\n`;
1499
1827
  result += `Messages in thread: ${messages.length}\n`;
1500
1828
  result += `History ID: ${thread.historyId}\n\n`;
@@ -1513,7 +1841,13 @@ export function registerGmailTools(options) {
1513
1841
  result += `Labels: ${(message.labelIds ?? []).join(', ') || 'None'}\n\n`;
1514
1842
  if (args.format === 'full' && message.payload) {
1515
1843
  const body = extractBody(message.payload);
1516
- result += `**Body:**\n${body || '(empty)'}\n`;
1844
+ // Wrap email body with security warnings to defend against prompt injection
1845
+ const from = getHeader(headers, 'From');
1846
+ const subject = getHeader(headers, 'Subject');
1847
+ const wrappedBody = body
1848
+ ? wrapEmailContent(body, from || undefined, subject || undefined)
1849
+ : '(empty)';
1850
+ result += `**Body:**\n${wrappedBody}\n`;
1517
1851
  }
1518
1852
  else if (message.snippet) {
1519
1853
  result += `**Snippet:** ${message.snippet}\n`;
@@ -1526,6 +1860,11 @@ export function registerGmailTools(options) {
1526
1860
  if (link) {
1527
1861
  result += `\n\nView thread in Gmail: ${link}`;
1528
1862
  }
1863
+ // Apply maxLength truncation if specified
1864
+ if (args.maxLength && result.length > args.maxLength) {
1865
+ result = result.substring(0, args.maxLength);
1866
+ result += `\n\n[...TRUNCATED at ${args.maxLength} chars. Thread has ${messages.length} messages. Use a larger maxLength or omit it to see everything.]`;
1867
+ }
1529
1868
  return result;
1530
1869
  }
1531
1870
  catch (error) {
@@ -1664,7 +2003,7 @@ export function registerGmailTools(options) {
1664
2003
  if (criteria.query)
1665
2004
  result += ` - Query: ${criteria.query}\n`;
1666
2005
  if (criteria.hasAttachment)
1667
- result += ` - Has attachment: yes\n`;
2006
+ result += ' - Has attachment: yes\n';
1668
2007
  if (criteria.size)
1669
2008
  result += ` - Size: ${criteria.sizeComparison} ${criteria.size} bytes\n`;
1670
2009
  result += ' Actions:\n';
@@ -1758,7 +2097,7 @@ export function registerGmailTools(options) {
1758
2097
  if (args.query)
1759
2098
  result += `- Query: ${args.query}\n`;
1760
2099
  if (args.hasAttachment)
1761
- result += `- Has attachment: yes\n`;
2100
+ result += '- Has attachment: yes\n';
1762
2101
  result += '\n**Actions:**\n';
1763
2102
  if (args.addLabelIds?.length)
1764
2103
  result += `- Add labels: ${args.addLabelIds.join(', ')}\n`;