gmail-workspace-mcp-server 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -48,24 +48,22 @@ Use this for personal `@gmail.com` accounts or any Google account without Worksp
48
48
 
49
49
  #### Getting a Refresh Token
50
50
 
51
- Run the one-time setup script from a clone of the repository:
51
+ Run the built-in setup command:
52
52
 
53
53
  ```bash
54
- git clone https://github.com/pulsemcp/mcp-servers.git
55
- cd mcp-servers/experimental/gmail
56
- npx tsx scripts/oauth-setup.ts <client_id> <client_secret>
54
+ npx gmail-workspace-mcp-server oauth-setup <client_id> <client_secret>
57
55
  ```
58
56
 
59
57
  You can also pass credentials via environment variables:
60
58
 
61
59
  ```bash
62
- GMAIL_OAUTH_CLIENT_ID=... GMAIL_OAUTH_CLIENT_SECRET=... npx tsx scripts/oauth-setup.ts
60
+ GMAIL_OAUTH_CLIENT_ID=... GMAIL_OAUTH_CLIENT_SECRET=... npx gmail-workspace-mcp-server oauth-setup
63
61
  ```
64
62
 
65
63
  **Port conflict?** If port 3000 is already in use, specify a different port:
66
64
 
67
65
  ```bash
68
- PORT=3001 npx tsx scripts/oauth-setup.ts <client_id> <client_secret>
66
+ PORT=3001 npx gmail-workspace-mcp-server oauth-setup <client_id> <client_secret>
69
67
  ```
70
68
 
71
69
  Desktop app credentials automatically allow `http://localhost` redirects on any port, so no additional Google Cloud Console configuration is needed.
@@ -61,6 +61,44 @@ const MOCK_EMAILS = [
61
61
  },
62
62
  sizeEstimate: 2048,
63
63
  },
64
+ {
65
+ id: 'msg_003',
66
+ threadId: 'thread_003',
67
+ labelIds: ['INBOX'],
68
+ snippet: 'Please find the invoice attached...',
69
+ historyId: '12347',
70
+ internalDate: String(Date.now() - 1000 * 60 * 60), // 1 hour ago
71
+ payload: {
72
+ mimeType: 'multipart/mixed',
73
+ headers: [
74
+ { name: 'Subject', value: 'Invoice Attached' },
75
+ { name: 'From', value: 'billing@example.com' },
76
+ { name: 'To', value: 'me@example.com' },
77
+ { name: 'Date', value: new Date(Date.now() - 1000 * 60 * 60).toISOString() },
78
+ { name: 'Message-ID', value: '<msg003@example.com>' },
79
+ ],
80
+ parts: [
81
+ {
82
+ partId: '0',
83
+ mimeType: 'text/plain',
84
+ body: {
85
+ size: 40,
86
+ data: Buffer.from('Please find the invoice attached.').toString('base64url'),
87
+ },
88
+ },
89
+ {
90
+ partId: '1',
91
+ mimeType: 'application/pdf',
92
+ filename: 'invoice.pdf',
93
+ body: {
94
+ attachmentId: 'att_001',
95
+ size: 1024,
96
+ },
97
+ },
98
+ ],
99
+ },
100
+ sizeEstimate: 2048,
101
+ },
64
102
  ];
65
103
  const mockDrafts = [];
66
104
  let draftIdCounter = 1;
@@ -206,6 +244,16 @@ function createMockClient() {
206
244
  };
207
245
  return sentMessage;
208
246
  },
247
+ async getAttachment(_messageId, attachmentId) {
248
+ const mockData = {
249
+ att_001: Buffer.from('Mock PDF content').toString('base64url'),
250
+ };
251
+ const data = mockData[attachmentId];
252
+ if (!data) {
253
+ throw new Error(`Attachment not found: ${attachmentId}`);
254
+ }
255
+ return { data, size: Buffer.from(data, 'base64url').length };
256
+ },
209
257
  async sendDraft(draftId) {
210
258
  const draft = mockDrafts.find((d) => d.id === draftId);
211
259
  if (!draft) {
package/build/index.js CHANGED
@@ -5,12 +5,31 @@ import { dirname, join } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { createMCPServer } from '../shared/index.js';
7
7
  import { logServerStart, logError, logWarning } from '../shared/logging.js';
8
+ import { runOAuthSetup } from './oauth-setup.js';
8
9
  // Read version from package.json
9
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
11
  const packageJsonPath = join(__dirname, '..', 'package.json');
11
12
  const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
12
13
  const VERSION = packageJson.version;
13
14
  // =============================================================================
15
+ // CLI SUBCOMMAND HANDLING
16
+ // =============================================================================
17
+ // Check for subcommands before env validation (e.g., "oauth-setup")
18
+ const subcommand = process.argv[2];
19
+ if (subcommand === 'oauth-setup') {
20
+ runOAuthSetup(process.argv.slice(3)).catch((error) => {
21
+ console.error('Error:', error.message);
22
+ process.exit(1);
23
+ });
24
+ }
25
+ else {
26
+ // Run the MCP server (default behavior)
27
+ main().catch((error) => {
28
+ logError('main', error);
29
+ process.exit(1);
30
+ });
31
+ }
32
+ // =============================================================================
14
33
  // ENVIRONMENT VALIDATION
15
34
  // =============================================================================
16
35
  function validateEnvironment() {
@@ -45,7 +64,7 @@ function validateEnvironment() {
45
64
  console.error(' GMAIL_OAUTH_CLIENT_SECRET: OAuth2 client secret');
46
65
  console.error(' GMAIL_OAUTH_REFRESH_TOKEN: Refresh token from one-time consent flow');
47
66
  console.error('\nRun the setup script to obtain a refresh token:');
48
- console.error(' npx tsx scripts/oauth-setup.ts <client_id> <client_secret>');
67
+ console.error(' npx gmail-workspace-mcp-server oauth-setup <client_id> <client_secret>');
49
68
  console.error('\n======================================================\n');
50
69
  process.exit(1);
51
70
  }
@@ -83,7 +102,7 @@ function validateEnvironment() {
83
102
  console.error(' GMAIL_OAUTH_CLIENT_ID: OAuth2 client ID from Google Cloud Console');
84
103
  console.error(' GMAIL_OAUTH_CLIENT_SECRET: OAuth2 client secret');
85
104
  console.error(' GMAIL_OAUTH_REFRESH_TOKEN: Refresh token from one-time consent flow');
86
- console.error('\n Setup: Run `npx tsx scripts/oauth-setup.ts <client_id> <client_secret>`');
105
+ console.error('\n Setup: Run `npx gmail-workspace-mcp-server oauth-setup <client_id> <client_secret>`');
87
106
  console.error('\n--- Option 2: Service Account (for Google Workspace) ---');
88
107
  console.error(' GMAIL_SERVICE_ACCOUNT_CLIENT_EMAIL: Service account email address');
89
108
  console.error(' Example: my-service-account@my-project.iam.gserviceaccount.com');
@@ -123,8 +142,3 @@ async function main() {
123
142
  await server.connect(transport);
124
143
  logServerStart('Gmail');
125
144
  }
126
- // Run the server
127
- main().catch((error) => {
128
- logError('main', error);
129
- process.exit(1);
130
- });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * OAuth2 setup flow for obtaining a Gmail refresh token.
3
+ *
4
+ * This module is invoked as a CLI subcommand:
5
+ * npx gmail-workspace-mcp-server oauth-setup <client_id> <client_secret>
6
+ *
7
+ * It starts a local HTTP server, opens the Google OAuth consent flow,
8
+ * and prints the resulting refresh token for use in MCP server configuration.
9
+ */
10
+ /**
11
+ * Run the OAuth2 setup flow.
12
+ * @param args - CLI arguments after "oauth-setup" (i.e., [client_id, client_secret])
13
+ */
14
+ export declare function runOAuthSetup(args: string[]): Promise<void>;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * OAuth2 setup flow for obtaining a Gmail refresh token.
3
+ *
4
+ * This module is invoked as a CLI subcommand:
5
+ * npx gmail-workspace-mcp-server oauth-setup <client_id> <client_secret>
6
+ *
7
+ * It starts a local HTTP server, opens the Google OAuth consent flow,
8
+ * and prints the resulting refresh token for use in MCP server configuration.
9
+ */
10
+ import http from 'node:http';
11
+ import { OAuth2Client } from 'google-auth-library';
12
+ const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
13
+ const SCOPES = [
14
+ 'https://www.googleapis.com/auth/gmail.readonly',
15
+ 'https://www.googleapis.com/auth/gmail.modify',
16
+ 'https://www.googleapis.com/auth/gmail.compose',
17
+ 'https://www.googleapis.com/auth/gmail.send',
18
+ ];
19
+ function waitForCallback(port) {
20
+ return new Promise((resolve, reject) => {
21
+ const server = http.createServer((req, res) => {
22
+ if (!req.url?.startsWith('/callback')) {
23
+ res.writeHead(404);
24
+ res.end('Not found');
25
+ return;
26
+ }
27
+ const url = new URL(req.url, `http://localhost:${port}`);
28
+ const code = url.searchParams.get('code');
29
+ const error = url.searchParams.get('error');
30
+ if (error) {
31
+ res.writeHead(200, { 'Content-Type': 'text/html' });
32
+ res.end('<html><body><h1>Authorization Failed</h1><p>You can close this window.</p></body></html>');
33
+ server.close();
34
+ reject(new Error(`OAuth error: ${error}`));
35
+ return;
36
+ }
37
+ if (!code) {
38
+ res.writeHead(400, { 'Content-Type': 'text/html' });
39
+ res.end('<html><body><h1>Missing Code</h1><p>No authorization code received.</p></body></html>');
40
+ return;
41
+ }
42
+ res.writeHead(200, { 'Content-Type': 'text/html' });
43
+ res.end('<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>');
44
+ clearTimeout(timeout);
45
+ server.close();
46
+ resolve(code);
47
+ });
48
+ const timeout = setTimeout(() => {
49
+ console.error('\nTimeout: No callback received within 5 minutes. Exiting.');
50
+ server.close();
51
+ reject(new Error('OAuth callback timeout - no response within 5 minutes'));
52
+ }, CALLBACK_TIMEOUT_MS);
53
+ server.listen(port, () => {
54
+ // Server is listening
55
+ });
56
+ server.on('error', (err) => {
57
+ clearTimeout(timeout);
58
+ if (err.code === 'EADDRINUSE') {
59
+ reject(new Error(`Port ${port} is already in use. Please free it or set PORT env var.`));
60
+ }
61
+ else {
62
+ reject(err);
63
+ }
64
+ });
65
+ });
66
+ }
67
+ /**
68
+ * Run the OAuth2 setup flow.
69
+ * @param args - CLI arguments after "oauth-setup" (i.e., [client_id, client_secret])
70
+ */
71
+ export async function runOAuthSetup(args) {
72
+ const clientId = args[0] || process.env.GMAIL_OAUTH_CLIENT_ID;
73
+ const clientSecret = args[1] || process.env.GMAIL_OAUTH_CLIENT_SECRET;
74
+ if (!clientId || !clientSecret) {
75
+ console.error('Usage: npx gmail-workspace-mcp-server oauth-setup <client_id> <client_secret>');
76
+ console.error('');
77
+ console.error('Or set environment variables:');
78
+ console.error(' GMAIL_OAUTH_CLIENT_ID=... GMAIL_OAUTH_CLIENT_SECRET=... npx gmail-workspace-mcp-server oauth-setup');
79
+ console.error('');
80
+ console.error('Get your OAuth2 credentials from:');
81
+ console.error(' https://console.cloud.google.com/apis/credentials');
82
+ process.exit(1);
83
+ }
84
+ const port = parseInt(process.env.PORT || '3000', 10);
85
+ const redirectUri = `http://localhost:${port}/callback`;
86
+ const oauth2Client = new OAuth2Client(clientId, clientSecret, redirectUri);
87
+ const authorizeUrl = oauth2Client.generateAuthUrl({
88
+ access_type: 'offline',
89
+ scope: SCOPES,
90
+ prompt: 'consent', // Force consent to ensure refresh token is returned
91
+ });
92
+ console.log('\n=== Gmail OAuth2 Setup ===\n');
93
+ console.log('1. Open this URL in your browser:\n');
94
+ console.log(` ${authorizeUrl}\n`);
95
+ console.log('2. Sign in and authorize the application');
96
+ console.log(`3. You will be redirected to localhost:${port}/callback\n`);
97
+ console.log('Waiting for callback (5 minute timeout)...\n');
98
+ const code = await waitForCallback(port);
99
+ console.log('Authorization code received! Exchanging for tokens...\n');
100
+ const { tokens } = await oauth2Client.getToken(code);
101
+ if (!tokens.refresh_token) {
102
+ console.error('ERROR: No refresh token received.');
103
+ console.error('');
104
+ console.error('This can happen if:');
105
+ console.error(' - You previously authorized this app (revoke access at https://myaccount.google.com/permissions)');
106
+ console.error(' - The OAuth consent screen is still in "Testing" mode');
107
+ console.error('');
108
+ console.error('Try revoking access and running this script again.');
109
+ process.exit(1);
110
+ }
111
+ console.log('=== Setup Complete ===\n');
112
+ console.log('Add these environment variables to your MCP server configuration:\n');
113
+ console.log(` GMAIL_OAUTH_CLIENT_ID=${clientId}`);
114
+ console.log(` GMAIL_OAUTH_CLIENT_SECRET=${clientSecret}`);
115
+ console.log(` GMAIL_OAUTH_REFRESH_TOKEN=${tokens.refresh_token}`);
116
+ console.log('');
117
+ console.log('SECURITY NOTE: Keep your refresh token secure. Anyone with this token and your');
118
+ console.log('client credentials can access your Gmail account.\n');
119
+ console.log('Example Claude Desktop config:\n');
120
+ console.log(JSON.stringify({
121
+ mcpServers: {
122
+ gmail: {
123
+ command: 'npx',
124
+ args: ['gmail-workspace-mcp-server'],
125
+ env: {
126
+ GMAIL_OAUTH_CLIENT_ID: clientId,
127
+ GMAIL_OAUTH_CLIENT_SECRET: clientSecret,
128
+ GMAIL_OAUTH_REFRESH_TOKEN: tokens.refresh_token,
129
+ },
130
+ },
131
+ },
132
+ }, null, 2));
133
+ console.log('');
134
+ process.exit(0);
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gmail-workspace-mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "MCP server for Gmail integration with OAuth2 and service account support",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Attachment data returned by the Gmail API
3
+ */
4
+ export interface AttachmentData {
5
+ /** Base64url-encoded attachment data */
6
+ data: string;
7
+ /** Size of the attachment in bytes */
8
+ size: number;
9
+ }
10
+ /**
11
+ * Gets attachment data for a specific attachment on a message
12
+ * Uses the Gmail API: GET /messages/{messageId}/attachments/{attachmentId}
13
+ */
14
+ export declare function getAttachment(baseUrl: string, headers: Record<string, string>, messageId: string, attachmentId: string): Promise<AttachmentData>;
@@ -0,0 +1,16 @@
1
+ import { handleApiError } from './api-errors.js';
2
+ /**
3
+ * Gets attachment data for a specific attachment on a message
4
+ * Uses the Gmail API: GET /messages/{messageId}/attachments/{attachmentId}
5
+ */
6
+ export async function getAttachment(baseUrl, headers, messageId, attachmentId) {
7
+ const url = `${baseUrl}/messages/${messageId}/attachments/${attachmentId}`;
8
+ const response = await fetch(url, {
9
+ method: 'GET',
10
+ headers,
11
+ });
12
+ if (!response.ok) {
13
+ handleApiError(response.status, 'getting attachment', attachmentId);
14
+ }
15
+ return (await response.json());
16
+ }
@@ -96,6 +96,13 @@ export interface IGmailClient {
96
96
  * Send a draft
97
97
  */
98
98
  sendDraft(draftId: string): Promise<Email>;
99
+ /**
100
+ * Get attachment data by message ID and attachment ID
101
+ */
102
+ getAttachment(messageId: string, attachmentId: string): Promise<{
103
+ data: string;
104
+ size: number;
105
+ }>;
99
106
  }
100
107
  /**
101
108
  * Service account credentials structure
@@ -191,6 +198,10 @@ declare abstract class BaseGmailClient implements IGmailClient {
191
198
  references?: string;
192
199
  }): Promise<Email>;
193
200
  sendDraft(draftId: string): Promise<Email>;
201
+ getAttachment(messageId: string, attachmentId: string): Promise<{
202
+ data: string;
203
+ size: number;
204
+ }>;
194
205
  }
195
206
  /**
196
207
  * Gmail API client implementation using service account with domain-wide delegation
package/shared/server.js CHANGED
@@ -97,6 +97,11 @@ class BaseGmailClient {
97
97
  const { sendDraft } = await import('./gmail-client/lib/send-message.js');
98
98
  return sendDraft(this.baseUrl, headers, draftId);
99
99
  }
100
+ async getAttachment(messageId, attachmentId) {
101
+ const headers = await this.getHeaders();
102
+ const { getAttachment } = await import('./gmail-client/lib/get-attachment.js');
103
+ return getAttachment(this.baseUrl, headers, messageId, attachmentId);
104
+ }
100
105
  }
101
106
  /**
102
107
  * Gmail API client implementation using service account with domain-wide delegation
@@ -0,0 +1,50 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { z } from 'zod';
3
+ import type { ClientFactory } from '../server.js';
4
+ export declare const DownloadEmailAttachmentsSchema: z.ZodObject<{
5
+ email_id: z.ZodString;
6
+ filename: z.ZodOptional<z.ZodString>;
7
+ inline: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
8
+ }, "strip", z.ZodTypeAny, {
9
+ email_id: string;
10
+ inline: boolean;
11
+ filename?: string | undefined;
12
+ }, {
13
+ email_id: string;
14
+ filename?: string | undefined;
15
+ inline?: boolean | undefined;
16
+ }>;
17
+ export declare function downloadEmailAttachmentsTool(_server: Server, clientFactory: ClientFactory): {
18
+ name: string;
19
+ description: string;
20
+ inputSchema: {
21
+ type: "object";
22
+ properties: {
23
+ email_id: {
24
+ type: string;
25
+ description: string;
26
+ };
27
+ filename: {
28
+ type: string;
29
+ description: string;
30
+ };
31
+ inline: {
32
+ type: string;
33
+ description: string;
34
+ };
35
+ };
36
+ required: string[];
37
+ };
38
+ handler: (args: unknown) => Promise<{
39
+ content: {
40
+ type: string;
41
+ text: string;
42
+ }[];
43
+ } | {
44
+ content: {
45
+ type: string;
46
+ text: string;
47
+ }[];
48
+ isError: boolean;
49
+ }>;
50
+ };
@@ -0,0 +1,276 @@
1
+ import { writeFile, mkdir } from 'fs/promises';
2
+ import { join, basename, extname } from 'path';
3
+ import { z } from 'zod';
4
+ /** Maximum total size (in bytes) for inline attachments (25 MB) */
5
+ const MAX_INLINE_SIZE = 25 * 1024 * 1024;
6
+ const PARAM_DESCRIPTIONS = {
7
+ email_id: 'The unique identifier of the email containing the attachment(s). ' +
8
+ 'Obtain this from list_email_conversations, search_email_conversations, or get_email_conversation.',
9
+ filename: 'Optional filename to download a specific attachment. ' +
10
+ 'If omitted, all attachments on the email are downloaded.',
11
+ inline: 'When true, return attachment content directly in the response (text-based files as text, binary as base64). ' +
12
+ 'When false (default), save attachments to /tmp/ and return file paths.',
13
+ };
14
+ export const DownloadEmailAttachmentsSchema = z.object({
15
+ email_id: z.string().min(1).describe(PARAM_DESCRIPTIONS.email_id),
16
+ filename: z.string().optional().describe(PARAM_DESCRIPTIONS.filename),
17
+ inline: z.boolean().optional().default(false).describe(PARAM_DESCRIPTIONS.inline),
18
+ });
19
+ const TOOL_DESCRIPTION = `Download attachment content from a specific email.
20
+
21
+ By default, saves all attachments to /tmp/ and returns the file paths. Use the inline parameter to return content directly in the response instead.
22
+
23
+ **Parameters:**
24
+ - email_id: The unique identifier of the email (required)
25
+ - filename: Download only the attachment matching this filename (optional)
26
+ - inline: If true, return content in the response instead of saving to files (optional, default: false)
27
+
28
+ **Default behavior (inline=false):**
29
+ Saves attachments as files to /tmp/ and returns the full file paths. Best for binary files or large attachments where you need to process the file afterward.
30
+
31
+ **Inline behavior (inline=true):**
32
+ Returns content directly. Text-based attachments (text/*, JSON, XML) are decoded to text. Binary attachments (PDF, images, etc.) are returned as base64. Total size is capped at 25 MB.
33
+
34
+ **Use cases:**
35
+ - Download invoices, receipts, or documents attached to emails
36
+ - Extract data from CSV or text file attachments
37
+ - Access PDF attachments for processing
38
+ - Batch-download all attachments from an email in one call
39
+
40
+ **Note:** Use get_email_conversation first to see attachment metadata (filenames, sizes, types) before downloading.`;
41
+ /**
42
+ * Recursively extracts attachment info (including attachmentId) from email parts
43
+ */
44
+ function getAttachmentInfos(parts) {
45
+ if (!parts)
46
+ return [];
47
+ const attachments = [];
48
+ for (const part of parts) {
49
+ if (part.filename && part.body?.attachmentId) {
50
+ attachments.push({
51
+ filename: part.filename,
52
+ mimeType: part.mimeType,
53
+ size: part.body.size,
54
+ attachmentId: part.body.attachmentId,
55
+ });
56
+ }
57
+ if (part.parts) {
58
+ attachments.push(...getAttachmentInfos(part.parts));
59
+ }
60
+ }
61
+ return attachments;
62
+ }
63
+ /**
64
+ * Converts base64url to standard base64
65
+ */
66
+ function base64UrlToBase64(data) {
67
+ return data.replace(/-/g, '+').replace(/_/g, '/');
68
+ }
69
+ /**
70
+ * Returns true if the MIME type represents text-based content
71
+ */
72
+ function isTextMimeType(mimeType) {
73
+ if (mimeType.startsWith('text/'))
74
+ return true;
75
+ if (mimeType === 'application/json')
76
+ return true;
77
+ if (mimeType === 'application/xml')
78
+ return true;
79
+ if (mimeType.endsWith('+json') || mimeType.endsWith('+xml'))
80
+ return true;
81
+ return false;
82
+ }
83
+ /**
84
+ * Builds inline response with attachment content in the response text
85
+ */
86
+ function buildInlineResponse(results) {
87
+ const outputParts = [];
88
+ const summaryLines = results.map((r, i) => {
89
+ const sizeKb = Math.round(r.size / 1024);
90
+ return `${i + 1}. ${r.filename} (${r.mimeType}, ${sizeKb} KB)`;
91
+ });
92
+ outputParts.push(`# Downloaded Attachments (${results.length})\n\n${summaryLines.join('\n')}`);
93
+ for (const result of results) {
94
+ const base64Data = base64UrlToBase64(result.data);
95
+ if (isTextMimeType(result.mimeType)) {
96
+ const textContent = Buffer.from(base64Data, 'base64').toString('utf-8');
97
+ outputParts.push(`---\n## ${result.filename}\n\n${textContent}`);
98
+ }
99
+ else {
100
+ outputParts.push(`---\n## ${result.filename}\n\n` +
101
+ `**MIME Type:** ${result.mimeType}\n` +
102
+ `**Encoding:** base64\n\n` +
103
+ `\`\`\`\n${base64Data}\n\`\`\``);
104
+ }
105
+ }
106
+ return {
107
+ content: [{ type: 'text', text: outputParts.join('\n\n') }],
108
+ };
109
+ }
110
+ /**
111
+ * Sanitizes a filename to prevent path traversal attacks.
112
+ * Strips directory components and falls back to 'attachment' if empty.
113
+ */
114
+ function sanitizeFilename(filename) {
115
+ return basename(filename) || 'attachment';
116
+ }
117
+ /**
118
+ * Returns a unique filename, appending (1), (2), etc. if the name already exists.
119
+ */
120
+ function deduplicateFilename(name, usedNames) {
121
+ if (!usedNames.has(name)) {
122
+ usedNames.add(name);
123
+ return name;
124
+ }
125
+ const ext = extname(name);
126
+ const base = name.slice(0, name.length - ext.length);
127
+ let counter = 1;
128
+ let candidate;
129
+ do {
130
+ candidate = `${base} (${counter})${ext}`;
131
+ counter++;
132
+ } while (usedNames.has(candidate));
133
+ usedNames.add(candidate);
134
+ return candidate;
135
+ }
136
+ /**
137
+ * Saves attachments to /tmp/ and returns file paths
138
+ */
139
+ async function buildFileResponse(results, emailId) {
140
+ const safeEmailId = emailId.replace(/[^a-zA-Z0-9_-]/g, '_');
141
+ const dir = join('/tmp', `gmail-attachments-${safeEmailId}`);
142
+ await mkdir(dir, { recursive: true });
143
+ const savedFiles = [];
144
+ const usedNames = new Set();
145
+ for (const result of results) {
146
+ const base64Data = base64UrlToBase64(result.data);
147
+ const buffer = Buffer.from(base64Data, 'base64');
148
+ const safeName = deduplicateFilename(sanitizeFilename(result.filename), usedNames);
149
+ const filePath = join(dir, safeName);
150
+ await writeFile(filePath, buffer);
151
+ savedFiles.push({
152
+ filename: result.filename,
153
+ path: filePath,
154
+ mimeType: result.mimeType,
155
+ size: buffer.length,
156
+ });
157
+ }
158
+ const outputParts = [];
159
+ const summaryLines = savedFiles.map((f, i) => {
160
+ const sizeKb = Math.round(f.size / 1024);
161
+ return `${i + 1}. ${f.filename} (${f.mimeType}, ${sizeKb} KB)`;
162
+ });
163
+ outputParts.push(`# Downloaded Attachments (${savedFiles.length})\n\n${summaryLines.join('\n')}`);
164
+ outputParts.push('## Saved Files\n');
165
+ for (const file of savedFiles) {
166
+ outputParts.push(`- **${file.filename}**: \`${file.path}\``);
167
+ }
168
+ return {
169
+ content: [{ type: 'text', text: outputParts.join('\n') }],
170
+ };
171
+ }
172
+ export function downloadEmailAttachmentsTool(_server, clientFactory) {
173
+ return {
174
+ name: 'download_email_attachments',
175
+ description: TOOL_DESCRIPTION,
176
+ inputSchema: {
177
+ type: 'object',
178
+ properties: {
179
+ email_id: {
180
+ type: 'string',
181
+ description: PARAM_DESCRIPTIONS.email_id,
182
+ },
183
+ filename: {
184
+ type: 'string',
185
+ description: PARAM_DESCRIPTIONS.filename,
186
+ },
187
+ inline: {
188
+ type: 'boolean',
189
+ description: PARAM_DESCRIPTIONS.inline,
190
+ },
191
+ },
192
+ required: ['email_id'],
193
+ },
194
+ handler: async (args) => {
195
+ try {
196
+ const parsed = DownloadEmailAttachmentsSchema.parse(args ?? {});
197
+ const client = clientFactory();
198
+ // Fetch the email to discover attachments
199
+ const email = await client.getMessage(parsed.email_id, { format: 'full' });
200
+ const allAttachments = [];
201
+ // Check payload-level attachment (single-part email with attachment)
202
+ if (email.payload?.filename && email.payload?.body?.attachmentId) {
203
+ allAttachments.push({
204
+ filename: email.payload.filename,
205
+ mimeType: email.payload.mimeType,
206
+ size: email.payload.body.size,
207
+ attachmentId: email.payload.body.attachmentId,
208
+ });
209
+ }
210
+ // Check nested parts for attachments
211
+ allAttachments.push(...getAttachmentInfos(email.payload?.parts));
212
+ if (allAttachments.length === 0) {
213
+ return {
214
+ content: [{ type: 'text', text: 'No attachments found on this email.' }],
215
+ };
216
+ }
217
+ // Filter by filename if specified
218
+ let targetAttachments = allAttachments;
219
+ if (parsed.filename) {
220
+ targetAttachments = allAttachments.filter((a) => a.filename === parsed.filename);
221
+ if (targetAttachments.length === 0) {
222
+ const available = allAttachments.map((a) => a.filename).join(', ');
223
+ return {
224
+ content: [
225
+ {
226
+ type: 'text',
227
+ text: `Attachment "${parsed.filename}" not found. Available attachments: ${available}`,
228
+ },
229
+ ],
230
+ isError: true,
231
+ };
232
+ }
233
+ }
234
+ // For inline mode, enforce size limit
235
+ if (parsed.inline) {
236
+ const totalSize = targetAttachments.reduce((sum, a) => sum + a.size, 0);
237
+ if (totalSize > MAX_INLINE_SIZE) {
238
+ const totalMb = (totalSize / (1024 * 1024)).toFixed(1);
239
+ return {
240
+ content: [
241
+ {
242
+ type: 'text',
243
+ text: `Total attachment size (${totalMb} MB) exceeds the 25 MB limit for inline mode. ` +
244
+ `Use inline=false (default) to save to files, or use the filename parameter to download individually.`,
245
+ },
246
+ ],
247
+ isError: true,
248
+ };
249
+ }
250
+ }
251
+ // Download all target attachments concurrently
252
+ const results = await Promise.all(targetAttachments.map(async (att) => {
253
+ const data = await client.getAttachment(parsed.email_id, att.attachmentId);
254
+ return { ...att, data: data.data };
255
+ }));
256
+ if (parsed.inline) {
257
+ return buildInlineResponse(results);
258
+ }
259
+ else {
260
+ return await buildFileResponse(results, parsed.email_id);
261
+ }
262
+ }
263
+ catch (error) {
264
+ return {
265
+ content: [
266
+ {
267
+ type: 'text',
268
+ text: `Error downloading attachment(s): ${error instanceof Error ? error.message : 'Unknown error'}`,
269
+ },
270
+ ],
271
+ isError: true,
272
+ };
273
+ }
274
+ },
275
+ };
276
+ }
package/shared/tools.d.ts CHANGED
@@ -2,7 +2,8 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { ClientFactory } from './server.js';
3
3
  /**
4
4
  * Available tool groups for Gmail MCP server
5
- * - readonly: Read-only operations (list, get, search emails)
5
+ * - readonly: Read-only Gmail operations (list, get, search, download_attachments)
6
+ * Note: download_email_attachments writes to local /tmp/ but does not modify mailbox state
6
7
  * - readwrite: Read and write operations (includes readonly + modify, draft)
7
8
  * - readwrite_external: External communication operations (includes readwrite + send_email)
8
9
  */
package/shared/tools.js CHANGED
@@ -5,11 +5,12 @@ import { changeEmailConversationTool } from './tools/change-email-conversation.j
5
5
  import { draftEmailTool } from './tools/draft-email.js';
6
6
  import { sendEmailTool } from './tools/send-email.js';
7
7
  import { searchEmailConversationsTool } from './tools/search-email-conversations.js';
8
+ import { downloadEmailAttachmentsTool } from './tools/download-email-attachments.js';
8
9
  const ALL_TOOL_GROUPS = ['readonly', 'readwrite', 'readwrite_external'];
9
10
  /**
10
11
  * All available tools with their group assignments
11
12
  *
12
- * readonly: list_email_conversations, get_email_conversation, search_email_conversations
13
+ * readonly: list_email_conversations, get_email_conversation, search_email_conversations, download_email_attachments
13
14
  * readwrite: all readonly tools + change_email_conversation, draft_email
14
15
  * readwrite_external: all readwrite tools + send_email (external communication)
15
16
  */
@@ -21,6 +22,10 @@ const ALL_TOOLS = [
21
22
  factory: searchEmailConversationsTool,
22
23
  groups: ['readonly', 'readwrite', 'readwrite_external'],
23
24
  },
25
+ {
26
+ factory: downloadEmailAttachmentsTool,
27
+ groups: ['readonly', 'readwrite', 'readwrite_external'],
28
+ },
24
29
  // Write tools (available in readwrite and readwrite_external)
25
30
  { factory: changeEmailConversationTool, groups: ['readwrite', 'readwrite_external'] },
26
31
  { factory: draftEmailTool, groups: ['readwrite', 'readwrite_external'] },