gmail-workspace-mcp-server 0.1.0 → 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/build/index.integration-with-mock.js +48 -0
- package/package.json +1 -1
- package/shared/gmail-client/lib/get-attachment.d.ts +14 -0
- package/shared/gmail-client/lib/get-attachment.js +16 -0
- package/shared/server.d.ts +11 -0
- package/shared/server.js +5 -0
- 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
|
@@ -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/package.json
CHANGED
|
@@ -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/server.d.ts
CHANGED
|
@@ -96,6 +96,13 @@ export interface IGmailClient {
|
|
|
96
96
|
* Send a draft
|
|
97
97
|
*/
|
|
98
98
|
sendDraft(draftId: string): Promise<Email>;
|
|
99
|
+
/**
|
|
100
|
+
* Get attachment data by message ID and attachment ID
|
|
101
|
+
*/
|
|
102
|
+
getAttachment(messageId: string, attachmentId: string): Promise<{
|
|
103
|
+
data: string;
|
|
104
|
+
size: number;
|
|
105
|
+
}>;
|
|
99
106
|
}
|
|
100
107
|
/**
|
|
101
108
|
* Service account credentials structure
|
|
@@ -191,6 +198,10 @@ declare abstract class BaseGmailClient implements IGmailClient {
|
|
|
191
198
|
references?: string;
|
|
192
199
|
}): Promise<Email>;
|
|
193
200
|
sendDraft(draftId: string): Promise<Email>;
|
|
201
|
+
getAttachment(messageId: string, attachmentId: string): Promise<{
|
|
202
|
+
data: string;
|
|
203
|
+
size: number;
|
|
204
|
+
}>;
|
|
194
205
|
}
|
|
195
206
|
/**
|
|
196
207
|
* Gmail API client implementation using service account with domain-wide delegation
|
package/shared/server.js
CHANGED
|
@@ -97,6 +97,11 @@ class BaseGmailClient {
|
|
|
97
97
|
const { sendDraft } = await import('./gmail-client/lib/send-message.js');
|
|
98
98
|
return sendDraft(this.baseUrl, headers, draftId);
|
|
99
99
|
}
|
|
100
|
+
async getAttachment(messageId, attachmentId) {
|
|
101
|
+
const headers = await this.getHeaders();
|
|
102
|
+
const { getAttachment } = await import('./gmail-client/lib/get-attachment.js');
|
|
103
|
+
return getAttachment(this.baseUrl, headers, messageId, attachmentId);
|
|
104
|
+
}
|
|
100
105
|
}
|
|
101
106
|
/**
|
|
102
107
|
* Gmail API client implementation using service account with domain-wide delegation
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { ClientFactory } from '../server.js';
|
|
4
|
+
export declare const DownloadEmailAttachmentsSchema: z.ZodObject<{
|
|
5
|
+
email_id: z.ZodString;
|
|
6
|
+
filename: z.ZodOptional<z.ZodString>;
|
|
7
|
+
inline: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
8
|
+
}, "strip", z.ZodTypeAny, {
|
|
9
|
+
email_id: string;
|
|
10
|
+
inline: boolean;
|
|
11
|
+
filename?: string | undefined;
|
|
12
|
+
}, {
|
|
13
|
+
email_id: string;
|
|
14
|
+
filename?: string | undefined;
|
|
15
|
+
inline?: boolean | undefined;
|
|
16
|
+
}>;
|
|
17
|
+
export declare function downloadEmailAttachmentsTool(_server: Server, clientFactory: ClientFactory): {
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
inputSchema: {
|
|
21
|
+
type: "object";
|
|
22
|
+
properties: {
|
|
23
|
+
email_id: {
|
|
24
|
+
type: string;
|
|
25
|
+
description: string;
|
|
26
|
+
};
|
|
27
|
+
filename: {
|
|
28
|
+
type: string;
|
|
29
|
+
description: string;
|
|
30
|
+
};
|
|
31
|
+
inline: {
|
|
32
|
+
type: string;
|
|
33
|
+
description: string;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
required: string[];
|
|
37
|
+
};
|
|
38
|
+
handler: (args: unknown) => Promise<{
|
|
39
|
+
content: {
|
|
40
|
+
type: string;
|
|
41
|
+
text: string;
|
|
42
|
+
}[];
|
|
43
|
+
} | {
|
|
44
|
+
content: {
|
|
45
|
+
type: string;
|
|
46
|
+
text: string;
|
|
47
|
+
}[];
|
|
48
|
+
isError: boolean;
|
|
49
|
+
}>;
|
|
50
|
+
};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { writeFile, mkdir } from 'fs/promises';
|
|
2
|
+
import { join, basename, extname } from 'path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
/** Maximum total size (in bytes) for inline attachments (25 MB) */
|
|
5
|
+
const MAX_INLINE_SIZE = 25 * 1024 * 1024;
|
|
6
|
+
const PARAM_DESCRIPTIONS = {
|
|
7
|
+
email_id: 'The unique identifier of the email containing the attachment(s). ' +
|
|
8
|
+
'Obtain this from list_email_conversations, search_email_conversations, or get_email_conversation.',
|
|
9
|
+
filename: 'Optional filename to download a specific attachment. ' +
|
|
10
|
+
'If omitted, all attachments on the email are downloaded.',
|
|
11
|
+
inline: 'When true, return attachment content directly in the response (text-based files as text, binary as base64). ' +
|
|
12
|
+
'When false (default), save attachments to /tmp/ and return file paths.',
|
|
13
|
+
};
|
|
14
|
+
export const DownloadEmailAttachmentsSchema = z.object({
|
|
15
|
+
email_id: z.string().min(1).describe(PARAM_DESCRIPTIONS.email_id),
|
|
16
|
+
filename: z.string().optional().describe(PARAM_DESCRIPTIONS.filename),
|
|
17
|
+
inline: z.boolean().optional().default(false).describe(PARAM_DESCRIPTIONS.inline),
|
|
18
|
+
});
|
|
19
|
+
const TOOL_DESCRIPTION = `Download attachment content from a specific email.
|
|
20
|
+
|
|
21
|
+
By default, saves all attachments to /tmp/ and returns the file paths. Use the inline parameter to return content directly in the response instead.
|
|
22
|
+
|
|
23
|
+
**Parameters:**
|
|
24
|
+
- email_id: The unique identifier of the email (required)
|
|
25
|
+
- filename: Download only the attachment matching this filename (optional)
|
|
26
|
+
- inline: If true, return content in the response instead of saving to files (optional, default: false)
|
|
27
|
+
|
|
28
|
+
**Default behavior (inline=false):**
|
|
29
|
+
Saves attachments as files to /tmp/ and returns the full file paths. Best for binary files or large attachments where you need to process the file afterward.
|
|
30
|
+
|
|
31
|
+
**Inline behavior (inline=true):**
|
|
32
|
+
Returns content directly. Text-based attachments (text/*, JSON, XML) are decoded to text. Binary attachments (PDF, images, etc.) are returned as base64. Total size is capped at 25 MB.
|
|
33
|
+
|
|
34
|
+
**Use cases:**
|
|
35
|
+
- Download invoices, receipts, or documents attached to emails
|
|
36
|
+
- Extract data from CSV or text file attachments
|
|
37
|
+
- Access PDF attachments for processing
|
|
38
|
+
- Batch-download all attachments from an email in one call
|
|
39
|
+
|
|
40
|
+
**Note:** Use get_email_conversation first to see attachment metadata (filenames, sizes, types) before downloading.`;
|
|
41
|
+
/**
|
|
42
|
+
* Recursively extracts attachment info (including attachmentId) from email parts
|
|
43
|
+
*/
|
|
44
|
+
function getAttachmentInfos(parts) {
|
|
45
|
+
if (!parts)
|
|
46
|
+
return [];
|
|
47
|
+
const attachments = [];
|
|
48
|
+
for (const part of parts) {
|
|
49
|
+
if (part.filename && part.body?.attachmentId) {
|
|
50
|
+
attachments.push({
|
|
51
|
+
filename: part.filename,
|
|
52
|
+
mimeType: part.mimeType,
|
|
53
|
+
size: part.body.size,
|
|
54
|
+
attachmentId: part.body.attachmentId,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (part.parts) {
|
|
58
|
+
attachments.push(...getAttachmentInfos(part.parts));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return attachments;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Converts base64url to standard base64
|
|
65
|
+
*/
|
|
66
|
+
function base64UrlToBase64(data) {
|
|
67
|
+
return data.replace(/-/g, '+').replace(/_/g, '/');
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Returns true if the MIME type represents text-based content
|
|
71
|
+
*/
|
|
72
|
+
function isTextMimeType(mimeType) {
|
|
73
|
+
if (mimeType.startsWith('text/'))
|
|
74
|
+
return true;
|
|
75
|
+
if (mimeType === 'application/json')
|
|
76
|
+
return true;
|
|
77
|
+
if (mimeType === 'application/xml')
|
|
78
|
+
return true;
|
|
79
|
+
if (mimeType.endsWith('+json') || mimeType.endsWith('+xml'))
|
|
80
|
+
return true;
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Builds inline response with attachment content in the response text
|
|
85
|
+
*/
|
|
86
|
+
function buildInlineResponse(results) {
|
|
87
|
+
const outputParts = [];
|
|
88
|
+
const summaryLines = results.map((r, i) => {
|
|
89
|
+
const sizeKb = Math.round(r.size / 1024);
|
|
90
|
+
return `${i + 1}. ${r.filename} (${r.mimeType}, ${sizeKb} KB)`;
|
|
91
|
+
});
|
|
92
|
+
outputParts.push(`# Downloaded Attachments (${results.length})\n\n${summaryLines.join('\n')}`);
|
|
93
|
+
for (const result of results) {
|
|
94
|
+
const base64Data = base64UrlToBase64(result.data);
|
|
95
|
+
if (isTextMimeType(result.mimeType)) {
|
|
96
|
+
const textContent = Buffer.from(base64Data, 'base64').toString('utf-8');
|
|
97
|
+
outputParts.push(`---\n## ${result.filename}\n\n${textContent}`);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
outputParts.push(`---\n## ${result.filename}\n\n` +
|
|
101
|
+
`**MIME Type:** ${result.mimeType}\n` +
|
|
102
|
+
`**Encoding:** base64\n\n` +
|
|
103
|
+
`\`\`\`\n${base64Data}\n\`\`\``);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
content: [{ type: 'text', text: outputParts.join('\n\n') }],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Sanitizes a filename to prevent path traversal attacks.
|
|
112
|
+
* Strips directory components and falls back to 'attachment' if empty.
|
|
113
|
+
*/
|
|
114
|
+
function sanitizeFilename(filename) {
|
|
115
|
+
return basename(filename) || 'attachment';
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Returns a unique filename, appending (1), (2), etc. if the name already exists.
|
|
119
|
+
*/
|
|
120
|
+
function deduplicateFilename(name, usedNames) {
|
|
121
|
+
if (!usedNames.has(name)) {
|
|
122
|
+
usedNames.add(name);
|
|
123
|
+
return name;
|
|
124
|
+
}
|
|
125
|
+
const ext = extname(name);
|
|
126
|
+
const base = name.slice(0, name.length - ext.length);
|
|
127
|
+
let counter = 1;
|
|
128
|
+
let candidate;
|
|
129
|
+
do {
|
|
130
|
+
candidate = `${base} (${counter})${ext}`;
|
|
131
|
+
counter++;
|
|
132
|
+
} while (usedNames.has(candidate));
|
|
133
|
+
usedNames.add(candidate);
|
|
134
|
+
return candidate;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Saves attachments to /tmp/ and returns file paths
|
|
138
|
+
*/
|
|
139
|
+
async function buildFileResponse(results, emailId) {
|
|
140
|
+
const safeEmailId = emailId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
141
|
+
const dir = join('/tmp', `gmail-attachments-${safeEmailId}`);
|
|
142
|
+
await mkdir(dir, { recursive: true });
|
|
143
|
+
const savedFiles = [];
|
|
144
|
+
const usedNames = new Set();
|
|
145
|
+
for (const result of results) {
|
|
146
|
+
const base64Data = base64UrlToBase64(result.data);
|
|
147
|
+
const buffer = Buffer.from(base64Data, 'base64');
|
|
148
|
+
const safeName = deduplicateFilename(sanitizeFilename(result.filename), usedNames);
|
|
149
|
+
const filePath = join(dir, safeName);
|
|
150
|
+
await writeFile(filePath, buffer);
|
|
151
|
+
savedFiles.push({
|
|
152
|
+
filename: result.filename,
|
|
153
|
+
path: filePath,
|
|
154
|
+
mimeType: result.mimeType,
|
|
155
|
+
size: buffer.length,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const outputParts = [];
|
|
159
|
+
const summaryLines = savedFiles.map((f, i) => {
|
|
160
|
+
const sizeKb = Math.round(f.size / 1024);
|
|
161
|
+
return `${i + 1}. ${f.filename} (${f.mimeType}, ${sizeKb} KB)`;
|
|
162
|
+
});
|
|
163
|
+
outputParts.push(`# Downloaded Attachments (${savedFiles.length})\n\n${summaryLines.join('\n')}`);
|
|
164
|
+
outputParts.push('## Saved Files\n');
|
|
165
|
+
for (const file of savedFiles) {
|
|
166
|
+
outputParts.push(`- **${file.filename}**: \`${file.path}\``);
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
content: [{ type: 'text', text: outputParts.join('\n') }],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
export function downloadEmailAttachmentsTool(_server, clientFactory) {
|
|
173
|
+
return {
|
|
174
|
+
name: 'download_email_attachments',
|
|
175
|
+
description: TOOL_DESCRIPTION,
|
|
176
|
+
inputSchema: {
|
|
177
|
+
type: 'object',
|
|
178
|
+
properties: {
|
|
179
|
+
email_id: {
|
|
180
|
+
type: 'string',
|
|
181
|
+
description: PARAM_DESCRIPTIONS.email_id,
|
|
182
|
+
},
|
|
183
|
+
filename: {
|
|
184
|
+
type: 'string',
|
|
185
|
+
description: PARAM_DESCRIPTIONS.filename,
|
|
186
|
+
},
|
|
187
|
+
inline: {
|
|
188
|
+
type: 'boolean',
|
|
189
|
+
description: PARAM_DESCRIPTIONS.inline,
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
required: ['email_id'],
|
|
193
|
+
},
|
|
194
|
+
handler: async (args) => {
|
|
195
|
+
try {
|
|
196
|
+
const parsed = DownloadEmailAttachmentsSchema.parse(args ?? {});
|
|
197
|
+
const client = clientFactory();
|
|
198
|
+
// Fetch the email to discover attachments
|
|
199
|
+
const email = await client.getMessage(parsed.email_id, { format: 'full' });
|
|
200
|
+
const allAttachments = [];
|
|
201
|
+
// Check payload-level attachment (single-part email with attachment)
|
|
202
|
+
if (email.payload?.filename && email.payload?.body?.attachmentId) {
|
|
203
|
+
allAttachments.push({
|
|
204
|
+
filename: email.payload.filename,
|
|
205
|
+
mimeType: email.payload.mimeType,
|
|
206
|
+
size: email.payload.body.size,
|
|
207
|
+
attachmentId: email.payload.body.attachmentId,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
// Check nested parts for attachments
|
|
211
|
+
allAttachments.push(...getAttachmentInfos(email.payload?.parts));
|
|
212
|
+
if (allAttachments.length === 0) {
|
|
213
|
+
return {
|
|
214
|
+
content: [{ type: 'text', text: 'No attachments found on this email.' }],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
// Filter by filename if specified
|
|
218
|
+
let targetAttachments = allAttachments;
|
|
219
|
+
if (parsed.filename) {
|
|
220
|
+
targetAttachments = allAttachments.filter((a) => a.filename === parsed.filename);
|
|
221
|
+
if (targetAttachments.length === 0) {
|
|
222
|
+
const available = allAttachments.map((a) => a.filename).join(', ');
|
|
223
|
+
return {
|
|
224
|
+
content: [
|
|
225
|
+
{
|
|
226
|
+
type: 'text',
|
|
227
|
+
text: `Attachment "${parsed.filename}" not found. Available attachments: ${available}`,
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
isError: true,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// For inline mode, enforce size limit
|
|
235
|
+
if (parsed.inline) {
|
|
236
|
+
const totalSize = targetAttachments.reduce((sum, a) => sum + a.size, 0);
|
|
237
|
+
if (totalSize > MAX_INLINE_SIZE) {
|
|
238
|
+
const totalMb = (totalSize / (1024 * 1024)).toFixed(1);
|
|
239
|
+
return {
|
|
240
|
+
content: [
|
|
241
|
+
{
|
|
242
|
+
type: 'text',
|
|
243
|
+
text: `Total attachment size (${totalMb} MB) exceeds the 25 MB limit for inline mode. ` +
|
|
244
|
+
`Use inline=false (default) to save to files, or use the filename parameter to download individually.`,
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
isError: true,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Download all target attachments concurrently
|
|
252
|
+
const results = await Promise.all(targetAttachments.map(async (att) => {
|
|
253
|
+
const data = await client.getAttachment(parsed.email_id, att.attachmentId);
|
|
254
|
+
return { ...att, data: data.data };
|
|
255
|
+
}));
|
|
256
|
+
if (parsed.inline) {
|
|
257
|
+
return buildInlineResponse(results);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
return await buildFileResponse(results, parsed.email_id);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
return {
|
|
265
|
+
content: [
|
|
266
|
+
{
|
|
267
|
+
type: 'text',
|
|
268
|
+
text: `Error downloading attachment(s): ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
isError: true,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
package/shared/tools.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
2
2
|
import { ClientFactory } from './server.js';
|
|
3
3
|
/**
|
|
4
4
|
* Available tool groups for Gmail MCP server
|
|
5
|
-
* - readonly: Read-only operations (list, get, search
|
|
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'] },
|