gmail-workspace-mcp-server 0.0.5 → 0.1.1

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
@@ -1,6 +1,6 @@
1
1
  # Gmail Workspace MCP Server
2
2
 
3
- An MCP (Model Context Protocol) server that provides Gmail integration for AI assistants using Google Workspace service accounts with domain-wide delegation.
3
+ An MCP (Model Context Protocol) server that provides Gmail integration for AI assistants. Supports both **personal Gmail accounts** (via OAuth2) and **Google Workspace accounts** (via service account with domain-wide delegation).
4
4
 
5
5
  ## Features
6
6
 
@@ -10,7 +10,7 @@ An MCP (Model Context Protocol) server that provides Gmail integration for AI as
10
10
  - **Manage Emails**: Mark as read/unread, star/unstar, archive, apply labels
11
11
  - **Create Drafts**: Compose draft emails with reply support
12
12
  - **Send Emails**: Send new emails or replies, directly or from drafts
13
- - **Service Account Authentication**: Secure domain-wide delegation for Google Workspace organizations
13
+ - **Two Auth Methods**: OAuth2 for personal accounts, Service Account for Google Workspace
14
14
 
15
15
  ## Installation
16
16
 
@@ -24,11 +24,86 @@ Or run directly with npx:
24
24
  npx gmail-workspace-mcp-server
25
25
  ```
26
26
 
27
- ## Prerequisites
27
+ ## Authentication
28
28
 
29
- This server requires a Google Cloud service account with domain-wide delegation to access Gmail on behalf of users in your Google Workspace domain.
29
+ This server supports two authentication modes. Choose the one that matches your account type.
30
30
 
31
- ### Setup Steps
31
+ ### Option 1: OAuth2 (Personal Gmail Accounts)
32
+
33
+ Use this for personal `@gmail.com` accounts or any Google account without Workspace admin access.
34
+
35
+ #### Prerequisites
36
+
37
+ 1. Go to [Google Cloud Console](https://console.cloud.google.com/)
38
+ 2. Create or select a project
39
+ 3. Enable the Gmail API
40
+ 4. Configure the OAuth consent screen:
41
+ - Choose **External** user type
42
+ - Add yourself as a test user
43
+ - **Publish the app** (click "Publish" on the consent screen) to prevent refresh tokens from expiring every 7 days. No full Google verification is needed for personal use.
44
+ 5. Create OAuth 2.0 credentials:
45
+ - Choose **Desktop app** as the application type
46
+ - **Important**: Create new credentials _after_ publishing the app
47
+ 6. Copy the Client ID and Client Secret
48
+
49
+ #### Getting a Refresh Token
50
+
51
+ Run the one-time setup script from a clone of the repository:
52
+
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>
57
+ ```
58
+
59
+ You can also pass credentials via environment variables:
60
+
61
+ ```bash
62
+ GMAIL_OAUTH_CLIENT_ID=... GMAIL_OAUTH_CLIENT_SECRET=... npx tsx scripts/oauth-setup.ts
63
+ ```
64
+
65
+ **Port conflict?** If port 3000 is already in use, specify a different port:
66
+
67
+ ```bash
68
+ PORT=3001 npx tsx scripts/oauth-setup.ts <client_id> <client_secret>
69
+ ```
70
+
71
+ Desktop app credentials automatically allow `http://localhost` redirects on any port, so no additional Google Cloud Console configuration is needed.
72
+
73
+ This will open your browser for Google sign-in and print the refresh token. You only need to do this once — the refresh token does not expire (as long as the OAuth consent screen is published).
74
+
75
+ #### Environment Variables (OAuth2)
76
+
77
+ | Variable | Required | Description |
78
+ | --------------------------- | -------- | -------------------------------------------------- |
79
+ | `GMAIL_OAUTH_CLIENT_ID` | Yes | OAuth2 client ID from Google Cloud Console |
80
+ | `GMAIL_OAUTH_CLIENT_SECRET` | Yes | OAuth2 client secret |
81
+ | `GMAIL_OAUTH_REFRESH_TOKEN` | Yes | Refresh token from the setup script |
82
+ | `GMAIL_ENABLED_TOOLGROUPS` | No | Comma-separated list of tool groups (default: all) |
83
+
84
+ #### Claude Desktop Configuration (OAuth2)
85
+
86
+ ```json
87
+ {
88
+ "mcpServers": {
89
+ "gmail": {
90
+ "command": "npx",
91
+ "args": ["gmail-workspace-mcp-server"],
92
+ "env": {
93
+ "GMAIL_OAUTH_CLIENT_ID": "your-client-id.apps.googleusercontent.com",
94
+ "GMAIL_OAUTH_CLIENT_SECRET": "your-client-secret",
95
+ "GMAIL_OAUTH_REFRESH_TOKEN": "your-refresh-token"
96
+ }
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ ### Option 2: Service Account (Google Workspace)
103
+
104
+ Use this for Google Workspace organizations where a domain admin can grant domain-wide delegation.
105
+
106
+ #### Prerequisites
32
107
 
33
108
  1. Go to [Google Cloud Console](https://console.cloud.google.com/)
34
109
  2. Create or select a project
@@ -41,7 +116,7 @@ This server requires a Google Cloud service account with domain-wide delegation
41
116
  - `https://www.googleapis.com/auth/gmail.send` (send emails)
42
117
  6. Download the JSON key file
43
118
 
44
- ### Environment Variables
119
+ #### Environment Variables (Service Account)
45
120
 
46
121
  | Variable | Required | Description |
47
122
  | ------------------------------------ | -------- | ------------------------------------------------------------------- |
@@ -52,7 +127,30 @@ This server requires a Google Cloud service account with domain-wide delegation
52
127
 
53
128
  You can find the `client_email` and `private_key` values in your service account JSON key file.
54
129
 
55
- ### Tool Groups
130
+ #### Claude Desktop Configuration (Service Account)
131
+
132
+ ```json
133
+ {
134
+ "mcpServers": {
135
+ "gmail": {
136
+ "command": "npx",
137
+ "args": ["gmail-workspace-mcp-server"],
138
+ "env": {
139
+ "GMAIL_SERVICE_ACCOUNT_CLIENT_EMAIL": "my-service-account@my-project.iam.gserviceaccount.com",
140
+ "GMAIL_SERVICE_ACCOUNT_PRIVATE_KEY": "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
141
+ "GMAIL_IMPERSONATE_EMAIL": "user@yourdomain.com"
142
+ }
143
+ }
144
+ }
145
+ }
146
+ ```
147
+
148
+ **Note:** For the private key, you can either:
149
+
150
+ 1. Use the key directly with `\n` for newlines (as shown above)
151
+ 2. Set the environment variable from a shell that preserves newlines
152
+
153
+ ## Tool Groups
56
154
 
57
155
  The server supports three tool groups for permission-based access control:
58
156
 
@@ -77,33 +175,6 @@ GMAIL_ENABLED_TOOLGROUPS=readwrite_external
77
175
 
78
176
  **Security Note:** The `send_email` tool is in a separate `readwrite_external` group because it can send emails externally, which carries higher risk than internal operations like modifying labels or creating drafts.
79
177
 
80
- ## Configuration
81
-
82
- ### Claude Desktop
83
-
84
- Add to your Claude Desktop configuration (`~/Library/Application Support/Claude/claude_desktop_config.json`):
85
-
86
- ```json
87
- {
88
- "mcpServers": {
89
- "gmail": {
90
- "command": "npx",
91
- "args": ["gmail-workspace-mcp-server"],
92
- "env": {
93
- "GMAIL_SERVICE_ACCOUNT_CLIENT_EMAIL": "my-service-account@my-project.iam.gserviceaccount.com",
94
- "GMAIL_SERVICE_ACCOUNT_PRIVATE_KEY": "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
95
- "GMAIL_IMPERSONATE_EMAIL": "user@yourdomain.com"
96
- }
97
- }
98
- }
99
- }
100
- ```
101
-
102
- **Note:** For the private key, you can either:
103
-
104
- 1. Use the key directly with `\n` for newlines (as shown above)
105
- 2. Set the environment variable from a shell that preserves newlines
106
-
107
178
  ## Available Tools
108
179
 
109
180
  ### list_email_conversations
@@ -262,7 +333,7 @@ npm test
262
333
  # Run integration tests
263
334
  npm run test:integration
264
335
 
265
- # Run manual tests (requires service account credentials)
336
+ # Run manual tests (requires credentials)
266
337
  npm run test:manual
267
338
  ```
268
339
 
@@ -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
@@ -14,40 +14,95 @@ const VERSION = packageJson.version;
14
14
  // ENVIRONMENT VALIDATION
15
15
  // =============================================================================
16
16
  function validateEnvironment() {
17
- const missing = [];
18
- if (!process.env.GMAIL_SERVICE_ACCOUNT_CLIENT_EMAIL) {
19
- missing.push('GMAIL_SERVICE_ACCOUNT_CLIENT_EMAIL');
17
+ // Check for OAuth2 credentials
18
+ const hasOAuth2 = process.env.GMAIL_OAUTH_CLIENT_ID &&
19
+ process.env.GMAIL_OAUTH_CLIENT_SECRET &&
20
+ process.env.GMAIL_OAUTH_REFRESH_TOKEN;
21
+ // Check for service account credentials
22
+ const hasServiceAccount = process.env.GMAIL_SERVICE_ACCOUNT_CLIENT_EMAIL &&
23
+ process.env.GMAIL_SERVICE_ACCOUNT_PRIVATE_KEY &&
24
+ process.env.GMAIL_IMPERSONATE_EMAIL;
25
+ if (hasOAuth2 || hasServiceAccount) {
26
+ return;
20
27
  }
21
- if (!process.env.GMAIL_SERVICE_ACCOUNT_PRIVATE_KEY) {
22
- missing.push('GMAIL_SERVICE_ACCOUNT_PRIVATE_KEY');
23
- }
24
- if (!process.env.GMAIL_IMPERSONATE_EMAIL) {
25
- missing.push('GMAIL_IMPERSONATE_EMAIL');
28
+ // Check for partial OAuth2 configuration
29
+ const oauthVars = {
30
+ GMAIL_OAUTH_CLIENT_ID: process.env.GMAIL_OAUTH_CLIENT_ID,
31
+ GMAIL_OAUTH_CLIENT_SECRET: process.env.GMAIL_OAUTH_CLIENT_SECRET,
32
+ GMAIL_OAUTH_REFRESH_TOKEN: process.env.GMAIL_OAUTH_REFRESH_TOKEN,
33
+ };
34
+ const hasPartialOAuth2 = Object.values(oauthVars).some(Boolean);
35
+ if (hasPartialOAuth2) {
36
+ const missingOAuth = Object.entries(oauthVars)
37
+ .filter(([, v]) => !v)
38
+ .map(([k]) => k);
39
+ logError('validateEnvironment', 'Incomplete OAuth2 configuration. Missing:');
40
+ for (const varName of missingOAuth) {
41
+ console.error(` - ${varName}`);
42
+ }
43
+ console.error('\nOAuth2 mode requires all three variables:');
44
+ console.error(' GMAIL_OAUTH_CLIENT_ID: OAuth2 client ID from Google Cloud Console');
45
+ console.error(' GMAIL_OAUTH_CLIENT_SECRET: OAuth2 client secret');
46
+ console.error(' GMAIL_OAUTH_REFRESH_TOKEN: Refresh token from one-time consent flow');
47
+ console.error('\nRun the setup script to obtain a refresh token:');
48
+ console.error(' npx tsx scripts/oauth-setup.ts <client_id> <client_secret>');
49
+ console.error('\n======================================================\n');
50
+ process.exit(1);
26
51
  }
27
- if (missing.length > 0) {
28
- logError('validateEnvironment', 'Missing required environment variables:');
29
- console.error('\nThis MCP server requires a Google Cloud service account with');
30
- console.error('domain-wide delegation to access Gmail on behalf of users.');
31
- console.error('\nRequired environment variables:');
52
+ // Check for partial service account configuration
53
+ const serviceAccountVars = {
54
+ GMAIL_SERVICE_ACCOUNT_CLIENT_EMAIL: process.env.GMAIL_SERVICE_ACCOUNT_CLIENT_EMAIL,
55
+ GMAIL_SERVICE_ACCOUNT_PRIVATE_KEY: process.env.GMAIL_SERVICE_ACCOUNT_PRIVATE_KEY,
56
+ GMAIL_IMPERSONATE_EMAIL: process.env.GMAIL_IMPERSONATE_EMAIL,
57
+ };
58
+ const hasPartialServiceAccount = Object.values(serviceAccountVars).some(Boolean);
59
+ if (hasPartialServiceAccount) {
60
+ const missingServiceAccount = Object.entries(serviceAccountVars)
61
+ .filter(([, v]) => !v)
62
+ .map(([k]) => k);
63
+ logError('validateEnvironment', 'Incomplete service account configuration. Missing:');
64
+ for (const varName of missingServiceAccount) {
65
+ console.error(` - ${varName}`);
66
+ }
67
+ console.error('\nService account mode requires all three variables:');
32
68
  console.error(' GMAIL_SERVICE_ACCOUNT_CLIENT_EMAIL: Service account email address');
33
- console.error(' Example: my-service-account@my-project.iam.gserviceaccount.com');
34
69
  console.error(' GMAIL_SERVICE_ACCOUNT_PRIVATE_KEY: Service account private key (PEM format)');
35
- console.error(' Example: -----BEGIN PRIVATE KEY-----\\nMIIE...\\n-----END PRIVATE KEY-----');
36
70
  console.error(' GMAIL_IMPERSONATE_EMAIL: Email address to impersonate');
37
- console.error(' Example: user@yourdomain.com');
38
71
  console.error('\nSetup steps:');
39
72
  console.error(' 1. Go to https://console.cloud.google.com/');
40
73
  console.error(' 2. Create a service account with domain-wide delegation');
41
74
  console.error(' 3. In Google Workspace Admin, grant required Gmail API scopes');
42
75
  console.error(' 4. Download the JSON key file and extract client_email and private_key');
43
- console.error('\nOptional environment variables:');
44
- console.error(' GMAIL_ENABLED_TOOLGROUPS: Comma-separated list of tool groups to enable');
45
- console.error(' Valid groups: readonly, readwrite, readwrite_external');
46
- console.error(' Default: all groups enabled');
47
- console.error(' Example: GMAIL_ENABLED_TOOLGROUPS=readwrite');
48
76
  console.error('\n======================================================\n');
49
77
  process.exit(1);
50
78
  }
79
+ // No credentials found at all
80
+ logError('validateEnvironment', 'Missing required environment variables:');
81
+ console.error('\nThis MCP server supports two authentication modes:\n');
82
+ console.error('--- Option 1: OAuth2 (for personal Gmail accounts) ---');
83
+ console.error(' GMAIL_OAUTH_CLIENT_ID: OAuth2 client ID from Google Cloud Console');
84
+ console.error(' GMAIL_OAUTH_CLIENT_SECRET: OAuth2 client secret');
85
+ 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>`');
87
+ console.error('\n--- Option 2: Service Account (for Google Workspace) ---');
88
+ console.error(' GMAIL_SERVICE_ACCOUNT_CLIENT_EMAIL: Service account email address');
89
+ console.error(' Example: my-service-account@my-project.iam.gserviceaccount.com');
90
+ console.error(' GMAIL_SERVICE_ACCOUNT_PRIVATE_KEY: Service account private key (PEM format)');
91
+ console.error(' Example: -----BEGIN PRIVATE KEY-----\\nMIIE...\\n-----END PRIVATE KEY-----');
92
+ console.error(' GMAIL_IMPERSONATE_EMAIL: Email address to impersonate');
93
+ console.error(' Example: user@yourdomain.com');
94
+ console.error('\n Setup steps:');
95
+ console.error(' 1. Go to https://console.cloud.google.com/');
96
+ console.error(' 2. Create a service account with domain-wide delegation');
97
+ console.error(' 3. In Google Workspace Admin, grant required Gmail API scopes');
98
+ console.error(' 4. Download the JSON key file and extract client_email and private_key');
99
+ console.error('\nOptional environment variables:');
100
+ console.error(' GMAIL_ENABLED_TOOLGROUPS: Comma-separated list of tool groups to enable');
101
+ console.error(' Valid groups: readonly, readwrite, readwrite_external');
102
+ console.error(' Default: all groups enabled');
103
+ console.error(' Example: GMAIL_ENABLED_TOOLGROUPS=readwrite');
104
+ console.error('\n======================================================\n');
105
+ process.exit(1);
51
106
  }
52
107
  // =============================================================================
53
108
  // MAIN ENTRY POINT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gmail-workspace-mcp-server",
3
- "version": "0.0.5",
4
- "description": "MCP server for Gmail integration with service account support",
3
+ "version": "0.1.1",
4
+ "description": "MCP server for Gmail integration with OAuth2 and service account support",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
7
7
  "bin": {
@@ -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
+ }
package/shared/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { createMCPServer, createDefaultClient, ServiceAccountGmailClient, type IGmailClient, type ClientFactory, type ServiceAccountCredentials, type CreateMCPServerOptions, type Draft, } from './server.js';
1
+ export { createMCPServer, createDefaultClient, ServiceAccountGmailClient, OAuth2GmailClient, GMAIL_SCOPES, type IGmailClient, type ClientFactory, type ServiceAccountCredentials, type CreateMCPServerOptions, type Draft, } from './server.js';
2
2
  export { createRegisterTools, registerTools, parseEnabledToolGroups, getAvailableToolGroups, type ToolGroup, } from './tools.js';
3
3
  export { logServerStart, logError, logWarning, logDebug } from './logging.js';
4
4
  export type { Email, EmailListItem, EmailHeader, EmailPart, Label, Thread, PaginatedResponse, } from './types.js';
package/shared/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Main exports for Gmail MCP Server
2
2
  // Server and client
3
- export { createMCPServer, createDefaultClient, ServiceAccountGmailClient, } from './server.js';
3
+ export { createMCPServer, createDefaultClient, ServiceAccountGmailClient, OAuth2GmailClient, GMAIL_SCOPES, } from './server.js';
4
4
  // Tools and tool groups
5
5
  export { createRegisterTools, registerTools, parseEnabledToolGroups, getAvailableToolGroups, } from './tools.js';
6
6
  // Logging utilities
@@ -1,5 +1,10 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import type { Email, EmailListItem } from './types.js';
3
+ /**
4
+ * Gmail API scopes required by this server.
5
+ * Shared between service account JWT, OAuth2 consent flow, and documentation.
6
+ */
7
+ export declare const GMAIL_SCOPES: readonly ["https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.compose", "https://www.googleapis.com/auth/gmail.send"];
3
8
  /**
4
9
  * Draft message structure
5
10
  */
@@ -91,6 +96,13 @@ export interface IGmailClient {
91
96
  * Send a draft
92
97
  */
93
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
+ }>;
94
106
  }
95
107
  /**
96
108
  * Service account credentials structure
@@ -108,18 +120,32 @@ export interface ServiceAccountCredentials {
108
120
  client_x509_cert_url: string;
109
121
  }
110
122
  /**
111
- * Gmail API client implementation using service account with domain-wide delegation
123
+ * Abstract base class for Gmail API clients.
124
+ * Provides shared token management (caching, mutex refresh) and all
125
+ * IGmailClient method implementations. Subclasses only need to implement
126
+ * the authentication-specific methods.
112
127
  */
113
- export declare class ServiceAccountGmailClient implements IGmailClient {
114
- private impersonateEmail;
115
- private baseUrl;
116
- private jwtClient;
128
+ declare abstract class BaseGmailClient implements IGmailClient {
129
+ protected baseUrl: string;
117
130
  private cachedToken;
118
131
  private tokenExpiry;
119
132
  private refreshPromise;
120
- constructor(credentials: ServiceAccountCredentials, impersonateEmail: string);
133
+ /**
134
+ * Perform the authentication-specific token refresh.
135
+ * Must set this.cachedToken and this.tokenExpiry via updateToken().
136
+ */
137
+ protected abstract refreshTokenImpl(): Promise<{
138
+ token: string;
139
+ expiryDate: number;
140
+ }>;
141
+ /**
142
+ * Get the sender email address for composing/sending emails.
143
+ * Service account uses the impersonation email; OAuth2 fetches from profile API.
144
+ */
145
+ protected abstract getSenderEmail(): Promise<string>;
146
+ protected updateToken(token: string, expiryDate: number): void;
121
147
  private refreshToken;
122
- private getHeaders;
148
+ protected getHeaders(): Promise<Record<string, string>>;
123
149
  listMessages(options?: {
124
150
  q?: string;
125
151
  maxResults?: number;
@@ -172,6 +198,42 @@ export declare class ServiceAccountGmailClient implements IGmailClient {
172
198
  references?: string;
173
199
  }): Promise<Email>;
174
200
  sendDraft(draftId: string): Promise<Email>;
201
+ getAttachment(messageId: string, attachmentId: string): Promise<{
202
+ data: string;
203
+ size: number;
204
+ }>;
205
+ }
206
+ /**
207
+ * Gmail API client implementation using service account with domain-wide delegation
208
+ */
209
+ export declare class ServiceAccountGmailClient extends BaseGmailClient {
210
+ private jwtClient;
211
+ private impersonateEmail;
212
+ constructor(credentials: ServiceAccountCredentials, impersonateEmail: string);
213
+ protected refreshTokenImpl(): Promise<{
214
+ token: string;
215
+ expiryDate: number;
216
+ }>;
217
+ protected getSenderEmail(): Promise<string>;
218
+ }
219
+ /**
220
+ * Gmail API client implementation using OAuth2 user authentication.
221
+ * Enables access to personal Gmail accounts (e.g., @gmail.com) that cannot
222
+ * use domain-wide delegation.
223
+ */
224
+ export declare class OAuth2GmailClient extends BaseGmailClient {
225
+ private oauth2Client;
226
+ private userEmail;
227
+ constructor(clientId: string, clientSecret: string, refreshToken: string);
228
+ protected refreshTokenImpl(): Promise<{
229
+ token: string;
230
+ expiryDate: number;
231
+ }>;
232
+ /**
233
+ * Fetches the authenticated user's email address from the Gmail profile API.
234
+ * Caches the result for subsequent calls.
235
+ */
236
+ protected getSenderEmail(): Promise<string>;
175
237
  }
176
238
  export type ClientFactory = () => IGmailClient;
177
239
  export interface CreateMCPServerOptions {
@@ -179,10 +241,20 @@ export interface CreateMCPServerOptions {
179
241
  }
180
242
  /**
181
243
  * Creates the default Gmail client based on environment variables.
182
- * Uses service account with domain-wide delegation:
244
+ *
245
+ * Supports two authentication modes:
246
+ *
247
+ * 1. OAuth2 (for personal Gmail accounts):
248
+ * - GMAIL_OAUTH_CLIENT_ID: OAuth2 client ID from Google Cloud Console
249
+ * - GMAIL_OAUTH_CLIENT_SECRET: OAuth2 client secret
250
+ * - GMAIL_OAUTH_REFRESH_TOKEN: Refresh token from one-time consent flow
251
+ *
252
+ * 2. Service Account (for Google Workspace accounts):
183
253
  * - GMAIL_SERVICE_ACCOUNT_CLIENT_EMAIL: Service account email address
184
254
  * - GMAIL_SERVICE_ACCOUNT_PRIVATE_KEY: Service account private key (PEM format)
185
255
  * - GMAIL_IMPERSONATE_EMAIL: Email address to impersonate
256
+ *
257
+ * If OAuth2 credentials are present, OAuth2 mode is used. Otherwise, service account mode is used.
186
258
  */
187
259
  export declare function createDefaultClient(): IGmailClient;
188
260
  export declare function createMCPServer(options: CreateMCPServerOptions): {
@@ -222,3 +294,4 @@ export declare function createMCPServer(options: CreateMCPServerOptions): {
222
294
  }>;
223
295
  registerHandlers: (server: Server, clientFactory?: ClientFactory) => Promise<void>;
224
296
  };
297
+ export {};
package/shared/server.js CHANGED
@@ -1,38 +1,34 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
- import { JWT } from 'google-auth-library';
2
+ import { JWT, OAuth2Client } from 'google-auth-library';
3
3
  import { createRegisterTools } from './tools.js';
4
4
  /**
5
- * Gmail API client implementation using service account with domain-wide delegation
5
+ * Gmail API scopes required by this server.
6
+ * Shared between service account JWT, OAuth2 consent flow, and documentation.
6
7
  */
7
- export class ServiceAccountGmailClient {
8
- impersonateEmail;
8
+ export const GMAIL_SCOPES = [
9
+ 'https://www.googleapis.com/auth/gmail.readonly',
10
+ 'https://www.googleapis.com/auth/gmail.modify',
11
+ 'https://www.googleapis.com/auth/gmail.compose',
12
+ 'https://www.googleapis.com/auth/gmail.send',
13
+ ];
14
+ /**
15
+ * Abstract base class for Gmail API clients.
16
+ * Provides shared token management (caching, mutex refresh) and all
17
+ * IGmailClient method implementations. Subclasses only need to implement
18
+ * the authentication-specific methods.
19
+ */
20
+ class BaseGmailClient {
9
21
  baseUrl = 'https://gmail.googleapis.com/gmail/v1/users/me';
10
- jwtClient;
11
22
  cachedToken = null;
12
23
  tokenExpiry = 0;
13
24
  refreshPromise = null;
14
- constructor(credentials, impersonateEmail) {
15
- this.impersonateEmail = impersonateEmail;
16
- this.jwtClient = new JWT({
17
- email: credentials.client_email,
18
- key: credentials.private_key,
19
- scopes: [
20
- 'https://www.googleapis.com/auth/gmail.readonly',
21
- 'https://www.googleapis.com/auth/gmail.modify',
22
- 'https://www.googleapis.com/auth/gmail.compose',
23
- 'https://www.googleapis.com/auth/gmail.send',
24
- ],
25
- subject: impersonateEmail,
26
- });
25
+ updateToken(token, expiryDate) {
26
+ this.cachedToken = token;
27
+ this.tokenExpiry = expiryDate;
27
28
  }
28
29
  async refreshToken() {
29
- const tokenResponse = await this.jwtClient.authorize();
30
- if (!tokenResponse.access_token) {
31
- throw new Error('Failed to obtain access token from service account');
32
- }
33
- this.cachedToken = tokenResponse.access_token;
34
- // Token typically expires in 1 hour, but use the actual expiry if provided
35
- this.tokenExpiry = tokenResponse.expiry_date || Date.now() + 3600000;
30
+ const { token, expiryDate } = await this.refreshTokenImpl();
31
+ this.updateToken(token, expiryDate);
36
32
  }
37
33
  async getHeaders() {
38
34
  // Check if we have a valid cached token (with 60 second buffer)
@@ -71,8 +67,9 @@ export class ServiceAccountGmailClient {
71
67
  }
72
68
  async createDraft(options) {
73
69
  const headers = await this.getHeaders();
70
+ const senderEmail = await this.getSenderEmail();
74
71
  const { createDraft } = await import('./gmail-client/lib/drafts.js');
75
- return createDraft(this.baseUrl, headers, this.impersonateEmail, options);
72
+ return createDraft(this.baseUrl, headers, senderEmail, options);
76
73
  }
77
74
  async getDraft(draftId) {
78
75
  const headers = await this.getHeaders();
@@ -91,23 +88,134 @@ export class ServiceAccountGmailClient {
91
88
  }
92
89
  async sendMessage(options) {
93
90
  const headers = await this.getHeaders();
91
+ const senderEmail = await this.getSenderEmail();
94
92
  const { sendMessage } = await import('./gmail-client/lib/send-message.js');
95
- return sendMessage(this.baseUrl, headers, this.impersonateEmail, options);
93
+ return sendMessage(this.baseUrl, headers, senderEmail, options);
96
94
  }
97
95
  async sendDraft(draftId) {
98
96
  const headers = await this.getHeaders();
99
97
  const { sendDraft } = await import('./gmail-client/lib/send-message.js');
100
98
  return sendDraft(this.baseUrl, headers, draftId);
101
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
+ }
105
+ }
106
+ /**
107
+ * Gmail API client implementation using service account with domain-wide delegation
108
+ */
109
+ export class ServiceAccountGmailClient extends BaseGmailClient {
110
+ jwtClient;
111
+ impersonateEmail;
112
+ constructor(credentials, impersonateEmail) {
113
+ super();
114
+ this.impersonateEmail = impersonateEmail;
115
+ this.jwtClient = new JWT({
116
+ email: credentials.client_email,
117
+ key: credentials.private_key,
118
+ scopes: [...GMAIL_SCOPES],
119
+ subject: impersonateEmail,
120
+ });
121
+ }
122
+ async refreshTokenImpl() {
123
+ const tokenResponse = await this.jwtClient.authorize();
124
+ if (!tokenResponse.access_token) {
125
+ throw new Error('Failed to obtain access token from service account');
126
+ }
127
+ return {
128
+ token: tokenResponse.access_token,
129
+ // Token typically expires in 1 hour, but use the actual expiry if provided
130
+ expiryDate: tokenResponse.expiry_date || Date.now() + 3600000,
131
+ };
132
+ }
133
+ async getSenderEmail() {
134
+ return this.impersonateEmail;
135
+ }
136
+ }
137
+ /**
138
+ * Gmail API client implementation using OAuth2 user authentication.
139
+ * Enables access to personal Gmail accounts (e.g., @gmail.com) that cannot
140
+ * use domain-wide delegation.
141
+ */
142
+ export class OAuth2GmailClient extends BaseGmailClient {
143
+ oauth2Client;
144
+ userEmail = null;
145
+ constructor(clientId, clientSecret, refreshToken) {
146
+ super();
147
+ this.oauth2Client = new OAuth2Client(clientId, clientSecret);
148
+ this.oauth2Client.setCredentials({ refresh_token: refreshToken });
149
+ }
150
+ async refreshTokenImpl() {
151
+ const { token, res } = await this.oauth2Client.getAccessToken();
152
+ if (!token) {
153
+ throw new Error('Failed to obtain access token from OAuth2 refresh token');
154
+ }
155
+ // Use expiry from response if available, otherwise default to 1 hour
156
+ const expiryDate = res?.data?.expiry_date;
157
+ return {
158
+ token,
159
+ expiryDate: typeof expiryDate === 'number' ? expiryDate : Date.now() + 3600000,
160
+ };
161
+ }
162
+ /**
163
+ * Fetches the authenticated user's email address from the Gmail profile API.
164
+ * Caches the result for subsequent calls.
165
+ */
166
+ async getSenderEmail() {
167
+ if (this.userEmail) {
168
+ return this.userEmail;
169
+ }
170
+ const headers = await this.getHeaders();
171
+ const response = await fetch(`${this.baseUrl}/profile`, {
172
+ method: 'GET',
173
+ headers,
174
+ });
175
+ if (!response.ok) {
176
+ const body = await response.text().catch(() => '');
177
+ throw new Error(`Failed to fetch Gmail profile: ${response.status} ${response.statusText}${body ? ` - ${body}` : ''}`);
178
+ }
179
+ const profile = (await response.json());
180
+ this.userEmail = profile.emailAddress;
181
+ return this.userEmail;
182
+ }
102
183
  }
103
184
  /**
104
185
  * Creates the default Gmail client based on environment variables.
105
- * Uses service account with domain-wide delegation:
186
+ *
187
+ * Supports two authentication modes:
188
+ *
189
+ * 1. OAuth2 (for personal Gmail accounts):
190
+ * - GMAIL_OAUTH_CLIENT_ID: OAuth2 client ID from Google Cloud Console
191
+ * - GMAIL_OAUTH_CLIENT_SECRET: OAuth2 client secret
192
+ * - GMAIL_OAUTH_REFRESH_TOKEN: Refresh token from one-time consent flow
193
+ *
194
+ * 2. Service Account (for Google Workspace accounts):
106
195
  * - GMAIL_SERVICE_ACCOUNT_CLIENT_EMAIL: Service account email address
107
196
  * - GMAIL_SERVICE_ACCOUNT_PRIVATE_KEY: Service account private key (PEM format)
108
197
  * - GMAIL_IMPERSONATE_EMAIL: Email address to impersonate
198
+ *
199
+ * If OAuth2 credentials are present, OAuth2 mode is used. Otherwise, service account mode is used.
109
200
  */
110
201
  export function createDefaultClient() {
202
+ // Check for OAuth2 credentials first
203
+ const oauthClientId = process.env.GMAIL_OAUTH_CLIENT_ID;
204
+ const oauthClientSecret = process.env.GMAIL_OAUTH_CLIENT_SECRET;
205
+ const oauthRefreshToken = process.env.GMAIL_OAUTH_REFRESH_TOKEN;
206
+ if (oauthClientId && oauthClientSecret && oauthRefreshToken) {
207
+ return new OAuth2GmailClient(oauthClientId, oauthClientSecret, oauthRefreshToken);
208
+ }
209
+ // Warn if some OAuth2 vars are set but not all (likely misconfiguration)
210
+ if (oauthClientId || oauthClientSecret || oauthRefreshToken) {
211
+ const missing = [
212
+ !oauthClientId && 'GMAIL_OAUTH_CLIENT_ID',
213
+ !oauthClientSecret && 'GMAIL_OAUTH_CLIENT_SECRET',
214
+ !oauthRefreshToken && 'GMAIL_OAUTH_REFRESH_TOKEN',
215
+ ].filter(Boolean);
216
+ console.warn(`Warning: Partial OAuth2 configuration detected. Missing: ${missing.join(', ')}. Falling back to service account mode.`);
217
+ }
218
+ // Fall back to service account mode
111
219
  const clientEmail = process.env.GMAIL_SERVICE_ACCOUNT_CLIENT_EMAIL;
112
220
  // Handle both literal \n in JSON configs and actual newlines
113
221
  const privateKey = process.env.GMAIL_SERVICE_ACCOUNT_PRIVATE_KEY?.replace(/\\n/g, '\n');
@@ -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'] },