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 +106 -35
- package/build/index.integration-with-mock.js +48 -0
- package/build/index.js +76 -21
- package/package.json +2 -2
- package/shared/gmail-client/lib/get-attachment.d.ts +14 -0
- package/shared/gmail-client/lib/get-attachment.js +16 -0
- package/shared/index.d.ts +1 -1
- package/shared/index.js +1 -1
- package/shared/server.d.ts +81 -8
- package/shared/server.js +136 -28
- package/shared/tools/download-email-attachments.d.ts +50 -0
- package/shared/tools/download-email-attachments.js +276 -0
- package/shared/tools.d.ts +2 -1
- package/shared/tools.js +6 -1
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
|
|
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
|
-
- **
|
|
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
|
-
##
|
|
27
|
+
## Authentication
|
|
28
28
|
|
|
29
|
-
This server
|
|
29
|
+
This server supports two authentication modes. Choose the one that matches your account type.
|
|
30
30
|
|
|
31
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
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
|
package/shared/server.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
5
|
+
* Gmail API scopes required by this server.
|
|
6
|
+
* Shared between service account JWT, OAuth2 consent flow, and documentation.
|
|
6
7
|
*/
|
|
7
|
-
export
|
|
8
|
-
|
|
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
|
-
|
|
15
|
-
this.
|
|
16
|
-
this.
|
|
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
|
|
30
|
-
|
|
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,
|
|
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,
|
|
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
|
-
*
|
|
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
|
|
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'] },
|