bluelamp 4.1.2 → 4.2.0
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/bin/bluelamp-vscode-base +1 -1
- package/lib/auth/InteractiveLogin.js +1 -1
- package/lib/auth/PortalAuthClient.js +1 -1
- package/lib/auth/TokenManager.js +1 -1
- package/lib/mcp-gmail/credentials.json +1 -0
- package/lib/mcp-gmail/index.js +547 -0
- package/lib/mcp-password-manager/index.js +248 -0
- package/lib/mcp-server/portal-api-client.cjs +1 -1
- package/package.json +5 -3
- package/scripts/playwright-launch.js +1 -1
- package/scripts/setup-mcps.js +2 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { google } from 'googleapis';
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
// --- Config ---
|
|
9
|
+
const CONFIG_DIR = path.join(process.env.HOME || '/tmp', '.config', 'gmail-mcp');
|
|
10
|
+
const TOKENS_DIR = path.join(CONFIG_DIR, 'tokens');
|
|
11
|
+
const CREDENTIALS_PATH = path.join(CONFIG_DIR, 'credentials.json');
|
|
12
|
+
const ACCOUNTS_PATH = path.join(CONFIG_DIR, 'accounts.json');
|
|
13
|
+
// Legacy single-token path (for migration)
|
|
14
|
+
const LEGACY_TOKEN_PATH = path.join(CONFIG_DIR, 'token.json');
|
|
15
|
+
const SCOPES = ['https://www.googleapis.com/auth/gmail.readonly'];
|
|
16
|
+
const DEFAULT_TEXT_BODY_LIMIT = 10000;
|
|
17
|
+
// --- Account discovery ---
|
|
18
|
+
function getAccounts() {
|
|
19
|
+
// 1. Explicit accounts.json takes priority
|
|
20
|
+
if (fs.existsSync(ACCOUNTS_PATH)) {
|
|
21
|
+
const data = JSON.parse(fs.readFileSync(ACCOUNTS_PATH, 'utf-8'));
|
|
22
|
+
if (Array.isArray(data))
|
|
23
|
+
return data;
|
|
24
|
+
if (data.accounts && Array.isArray(data.accounts))
|
|
25
|
+
return data.accounts;
|
|
26
|
+
}
|
|
27
|
+
// 2. Auto-discover from token files
|
|
28
|
+
if (fs.existsSync(TOKENS_DIR)) {
|
|
29
|
+
return fs.readdirSync(TOKENS_DIR)
|
|
30
|
+
.filter(f => f.endsWith('.json'))
|
|
31
|
+
.map(f => f.replace(/\.json$/, ''));
|
|
32
|
+
}
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
function getAuthenticatedAccounts() {
|
|
36
|
+
return getAccounts().filter(email => loadToken(email) !== null);
|
|
37
|
+
}
|
|
38
|
+
// --- Auth helpers ---
|
|
39
|
+
function ensureConfigDir() {
|
|
40
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
41
|
+
fs.mkdirSync(TOKENS_DIR, { recursive: true, mode: 0o700 });
|
|
42
|
+
}
|
|
43
|
+
function loadCredentials() {
|
|
44
|
+
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
|
45
|
+
throw new Error(`credentials.json not found at ${CREDENTIALS_PATH}\n` +
|
|
46
|
+
'Place your Google OAuth credentials file there, or set GMAIL_MCP_CREDENTIALS_PATH env var.');
|
|
47
|
+
}
|
|
48
|
+
const raw = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf-8'));
|
|
49
|
+
return raw.installed || raw.web;
|
|
50
|
+
}
|
|
51
|
+
function makeOAuth2Client() {
|
|
52
|
+
const creds = loadCredentials();
|
|
53
|
+
return new google.auth.OAuth2(creds.client_id, creds.client_secret, creds.redirect_uris[0] || 'http://localhost');
|
|
54
|
+
}
|
|
55
|
+
function tokenPathFor(email) {
|
|
56
|
+
return path.join(TOKENS_DIR, `${email}.json`);
|
|
57
|
+
}
|
|
58
|
+
function loadToken(email) {
|
|
59
|
+
const p = tokenPathFor(email);
|
|
60
|
+
if (fs.existsSync(p)) {
|
|
61
|
+
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function saveToken(email, token) {
|
|
66
|
+
ensureConfigDir();
|
|
67
|
+
fs.writeFileSync(tokenPathFor(email), JSON.stringify(token, null, 2), { mode: 0o600 });
|
|
68
|
+
}
|
|
69
|
+
function getAuthenticatedClient(email) {
|
|
70
|
+
const token = loadToken(email);
|
|
71
|
+
if (!token)
|
|
72
|
+
return null;
|
|
73
|
+
const client = makeOAuth2Client();
|
|
74
|
+
client.setCredentials(token);
|
|
75
|
+
client.on('tokens', (newTokens) => {
|
|
76
|
+
const existing = loadToken(email) || {};
|
|
77
|
+
saveToken(email, { ...existing, ...newTokens });
|
|
78
|
+
});
|
|
79
|
+
return client;
|
|
80
|
+
}
|
|
81
|
+
function getGmail(auth) {
|
|
82
|
+
return google.gmail({ version: 'v1', auth });
|
|
83
|
+
}
|
|
84
|
+
function decodeBase64(data) {
|
|
85
|
+
return Buffer.from(data.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf-8');
|
|
86
|
+
}
|
|
87
|
+
function getHeader(headers, name) {
|
|
88
|
+
const h = headers?.find(h => h.name?.toLowerCase() === name.toLowerCase());
|
|
89
|
+
return h?.value || '';
|
|
90
|
+
}
|
|
91
|
+
function extractAttachments(parts) {
|
|
92
|
+
const attachments = [];
|
|
93
|
+
for (const part of parts) {
|
|
94
|
+
if (part.filename && part.body?.attachmentId) {
|
|
95
|
+
attachments.push({
|
|
96
|
+
id: part.body.attachmentId,
|
|
97
|
+
filename: part.filename,
|
|
98
|
+
mimeType: part.mimeType || 'application/octet-stream',
|
|
99
|
+
size: part.body.size || 0,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (part.parts) {
|
|
103
|
+
attachments.push(...extractAttachments(part.parts));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return attachments;
|
|
107
|
+
}
|
|
108
|
+
function extractHtmlBody(parts) {
|
|
109
|
+
for (const part of parts) {
|
|
110
|
+
if (part.mimeType === 'text/html' && part.body?.data) {
|
|
111
|
+
return decodeBase64(part.body.data);
|
|
112
|
+
}
|
|
113
|
+
if (part.parts) {
|
|
114
|
+
const found = extractHtmlBody(part.parts);
|
|
115
|
+
if (found)
|
|
116
|
+
return found;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return '';
|
|
120
|
+
}
|
|
121
|
+
function extractTextBody(parts) {
|
|
122
|
+
for (const part of parts) {
|
|
123
|
+
if (part.mimeType === 'text/plain' && part.body?.data) {
|
|
124
|
+
return decodeBase64(part.body.data);
|
|
125
|
+
}
|
|
126
|
+
if (part.parts) {
|
|
127
|
+
const found = extractTextBody(part.parts);
|
|
128
|
+
if (found)
|
|
129
|
+
return found;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return '';
|
|
133
|
+
}
|
|
134
|
+
async function searchAccount(email, query, maxResults) {
|
|
135
|
+
const auth = getAuthenticatedClient(email);
|
|
136
|
+
if (!auth)
|
|
137
|
+
return [];
|
|
138
|
+
const gmail = getGmail(auth);
|
|
139
|
+
const listRes = await gmail.users.messages.list({
|
|
140
|
+
userId: 'me',
|
|
141
|
+
q: query,
|
|
142
|
+
maxResults,
|
|
143
|
+
});
|
|
144
|
+
const messages = listRes.data.messages || [];
|
|
145
|
+
const results = [];
|
|
146
|
+
for (const msg of messages) {
|
|
147
|
+
const detail = await gmail.users.messages.get({
|
|
148
|
+
userId: 'me',
|
|
149
|
+
id: msg.id,
|
|
150
|
+
format: 'metadata',
|
|
151
|
+
metadataHeaders: ['Subject', 'From', 'Date'],
|
|
152
|
+
});
|
|
153
|
+
const headers = detail.data.payload?.headers || [];
|
|
154
|
+
const parts = detail.data.payload?.parts || [];
|
|
155
|
+
const attachments = extractAttachments(parts);
|
|
156
|
+
results.push({
|
|
157
|
+
account: email,
|
|
158
|
+
messageId: msg.id,
|
|
159
|
+
subject: getHeader(headers, 'Subject'),
|
|
160
|
+
from: getHeader(headers, 'From'),
|
|
161
|
+
date: getHeader(headers, 'Date'),
|
|
162
|
+
snippet: detail.data.snippet || '',
|
|
163
|
+
hasAttachments: attachments.length > 0,
|
|
164
|
+
attachments,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return results;
|
|
168
|
+
}
|
|
169
|
+
function buildQuery(params) {
|
|
170
|
+
const parts = [];
|
|
171
|
+
if (params.from)
|
|
172
|
+
parts.push(`from:${params.from}`);
|
|
173
|
+
if (params.after)
|
|
174
|
+
parts.push(`after:${params.after}`);
|
|
175
|
+
if (params.before)
|
|
176
|
+
parts.push(`before:${params.before}`);
|
|
177
|
+
if (params.subject)
|
|
178
|
+
parts.push(`subject:${params.subject}`);
|
|
179
|
+
if (params.has_attachment)
|
|
180
|
+
parts.push('has:attachment');
|
|
181
|
+
if (params.query)
|
|
182
|
+
parts.push(params.query);
|
|
183
|
+
return parts.join(' ');
|
|
184
|
+
}
|
|
185
|
+
// --- MCP Server ---
|
|
186
|
+
const server = new Server({ name: 'mcp-gmail', version: '2.0.0' }, { capabilities: { tools: {} } });
|
|
187
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
188
|
+
tools: [
|
|
189
|
+
{
|
|
190
|
+
name: 'list_accounts',
|
|
191
|
+
description: 'List configured Gmail accounts and their authentication status',
|
|
192
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
name: 'authorize_account',
|
|
196
|
+
description: 'Authorize a Gmail account. Call without auth_code to get the authorization URL, then call again with the code.',
|
|
197
|
+
inputSchema: {
|
|
198
|
+
type: 'object',
|
|
199
|
+
properties: {
|
|
200
|
+
email: { type: 'string', description: 'Email address to authorize' },
|
|
201
|
+
auth_code: { type: 'string', description: 'Authorization code from OAuth flow (optional on first call)' },
|
|
202
|
+
},
|
|
203
|
+
required: ['email'],
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: 'search_emails',
|
|
208
|
+
description: 'Search emails across Gmail accounts by sender, date, keywords, etc.',
|
|
209
|
+
inputSchema: {
|
|
210
|
+
type: 'object',
|
|
211
|
+
properties: {
|
|
212
|
+
accounts: { type: 'array', items: { type: 'string' }, description: 'Email accounts to search (default: all authenticated)' },
|
|
213
|
+
from: { type: 'string', description: 'Filter by sender' },
|
|
214
|
+
after: { type: 'string', description: 'After date (YYYY/MM/DD)' },
|
|
215
|
+
before: { type: 'string', description: 'Before date (YYYY/MM/DD)' },
|
|
216
|
+
subject: { type: 'string', description: 'Subject keyword' },
|
|
217
|
+
query: { type: 'string', description: 'Raw Gmail search query' },
|
|
218
|
+
has_attachment: { type: 'boolean', description: 'Only emails with attachments' },
|
|
219
|
+
max_results: { type: 'number', description: 'Max results per account (default: 10)' },
|
|
220
|
+
},
|
|
221
|
+
required: [],
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: 'get_email',
|
|
226
|
+
description: 'Get full email content (HTML body + text body + attachment list)',
|
|
227
|
+
inputSchema: {
|
|
228
|
+
type: 'object',
|
|
229
|
+
properties: {
|
|
230
|
+
account: { type: 'string', description: 'Email account' },
|
|
231
|
+
message_id: { type: 'string', description: 'Gmail message ID' },
|
|
232
|
+
text_body_limit: { type: 'number', description: `Max text body chars (default: ${DEFAULT_TEXT_BODY_LIMIT})` },
|
|
233
|
+
},
|
|
234
|
+
required: ['account', 'message_id'],
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: 'download_attachment',
|
|
239
|
+
description: 'Download an email attachment to a local file path',
|
|
240
|
+
inputSchema: {
|
|
241
|
+
type: 'object',
|
|
242
|
+
properties: {
|
|
243
|
+
account: { type: 'string', description: 'Email account' },
|
|
244
|
+
message_id: { type: 'string', description: 'Gmail message ID' },
|
|
245
|
+
attachment_id: { type: 'string', description: 'Attachment ID' },
|
|
246
|
+
save_path: { type: 'string', description: 'Absolute path to save the file' },
|
|
247
|
+
},
|
|
248
|
+
required: ['account', 'message_id', 'attachment_id', 'save_path'],
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: 'save_email_as_html',
|
|
253
|
+
description: 'Save email body as an HTML file',
|
|
254
|
+
inputSchema: {
|
|
255
|
+
type: 'object',
|
|
256
|
+
properties: {
|
|
257
|
+
account: { type: 'string', description: 'Email account' },
|
|
258
|
+
message_id: { type: 'string', description: 'Gmail message ID' },
|
|
259
|
+
save_path: { type: 'string', description: 'Absolute path to save HTML file' },
|
|
260
|
+
},
|
|
261
|
+
required: ['account', 'message_id', 'save_path'],
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: 'batch_search_receipts',
|
|
266
|
+
description: 'Search for multiple receipts at once. Provide vendor/date/amount items and search all accounts.',
|
|
267
|
+
inputSchema: {
|
|
268
|
+
type: 'object',
|
|
269
|
+
properties: {
|
|
270
|
+
items: {
|
|
271
|
+
type: 'array',
|
|
272
|
+
items: {
|
|
273
|
+
type: 'object',
|
|
274
|
+
properties: {
|
|
275
|
+
vendor: { type: 'string', description: 'Vendor/sender name' },
|
|
276
|
+
date: { type: 'string', description: 'Transaction date (YYYY/MM/DD)' },
|
|
277
|
+
amount: { type: 'number', description: 'Amount in JPY (optional)' },
|
|
278
|
+
},
|
|
279
|
+
required: ['vendor', 'date'],
|
|
280
|
+
},
|
|
281
|
+
description: 'List of receipts to search for',
|
|
282
|
+
},
|
|
283
|
+
date_tolerance_days: { type: 'number', description: 'Days before/after to search (default: 7)' },
|
|
284
|
+
},
|
|
285
|
+
required: ['items'],
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
}));
|
|
290
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
291
|
+
const { name, arguments: args } = request.params;
|
|
292
|
+
try {
|
|
293
|
+
switch (name) {
|
|
294
|
+
case 'list_accounts': {
|
|
295
|
+
const accounts = getAccounts();
|
|
296
|
+
const statuses = accounts.map(email => {
|
|
297
|
+
const token = loadToken(email);
|
|
298
|
+
return {
|
|
299
|
+
email,
|
|
300
|
+
authenticated: !!token,
|
|
301
|
+
hasRefreshToken: !!(token && token.refresh_token),
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
// Also show any token files not in accounts list
|
|
305
|
+
if (fs.existsSync(TOKENS_DIR)) {
|
|
306
|
+
const tokenEmails = fs.readdirSync(TOKENS_DIR)
|
|
307
|
+
.filter(f => f.endsWith('.json'))
|
|
308
|
+
.map(f => f.replace(/\.json$/, ''));
|
|
309
|
+
for (const email of tokenEmails) {
|
|
310
|
+
if (!accounts.includes(email)) {
|
|
311
|
+
const token = loadToken(email);
|
|
312
|
+
statuses.push({
|
|
313
|
+
email,
|
|
314
|
+
authenticated: !!token,
|
|
315
|
+
hasRefreshToken: !!(token && token.refresh_token),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { content: [{ type: 'text', text: JSON.stringify(statuses, null, 2) }] };
|
|
321
|
+
}
|
|
322
|
+
case 'authorize_account': {
|
|
323
|
+
const email = args.email;
|
|
324
|
+
const authCode = args.auth_code;
|
|
325
|
+
if (!authCode) {
|
|
326
|
+
const client = makeOAuth2Client();
|
|
327
|
+
const url = client.generateAuthUrl({
|
|
328
|
+
access_type: 'offline',
|
|
329
|
+
scope: SCOPES,
|
|
330
|
+
login_hint: email,
|
|
331
|
+
prompt: 'consent',
|
|
332
|
+
});
|
|
333
|
+
return {
|
|
334
|
+
content: [{
|
|
335
|
+
type: 'text',
|
|
336
|
+
text: `Open this URL in a browser and authorize:\n\n${url}\n\nAfter authorizing, you'll be redirected to localhost. Copy the "code" parameter from the URL bar and call this tool again with auth_code.`,
|
|
337
|
+
}],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const client = makeOAuth2Client();
|
|
341
|
+
const { tokens } = await client.getToken(authCode);
|
|
342
|
+
saveToken(email, tokens);
|
|
343
|
+
return { content: [{ type: 'text', text: `Account ${email} authorized and token saved.` }] };
|
|
344
|
+
}
|
|
345
|
+
case 'search_emails': {
|
|
346
|
+
const a = args;
|
|
347
|
+
const targetAccounts = a.accounts?.length
|
|
348
|
+
? a.accounts
|
|
349
|
+
: getAuthenticatedAccounts();
|
|
350
|
+
const query = buildQuery({
|
|
351
|
+
from: a.from,
|
|
352
|
+
after: a.after,
|
|
353
|
+
before: a.before,
|
|
354
|
+
subject: a.subject,
|
|
355
|
+
query: a.query,
|
|
356
|
+
has_attachment: a.has_attachment,
|
|
357
|
+
});
|
|
358
|
+
const maxResults = a.max_results || 10;
|
|
359
|
+
const allResults = await Promise.all(targetAccounts.map((email) => searchAccount(email, query, maxResults).catch(err => {
|
|
360
|
+
return [{ account: email, error: err.message }];
|
|
361
|
+
})));
|
|
362
|
+
const flat = allResults.flat();
|
|
363
|
+
return { content: [{ type: 'text', text: JSON.stringify(flat, null, 2) }] };
|
|
364
|
+
}
|
|
365
|
+
case 'get_email': {
|
|
366
|
+
const { account, message_id, text_body_limit } = args;
|
|
367
|
+
const limit = text_body_limit || DEFAULT_TEXT_BODY_LIMIT;
|
|
368
|
+
const auth = getAuthenticatedClient(account);
|
|
369
|
+
if (!auth) {
|
|
370
|
+
return {
|
|
371
|
+
content: [{ type: 'text', text: `Account ${account} not authenticated. Use authorize_account first.` }],
|
|
372
|
+
isError: true,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
const gmail = getGmail(auth);
|
|
376
|
+
const detail = await gmail.users.messages.get({
|
|
377
|
+
userId: 'me',
|
|
378
|
+
id: message_id,
|
|
379
|
+
format: 'full',
|
|
380
|
+
});
|
|
381
|
+
const headers = detail.data.payload?.headers || [];
|
|
382
|
+
const parts = detail.data.payload?.parts || [detail.data.payload];
|
|
383
|
+
const htmlBody = extractHtmlBody(parts);
|
|
384
|
+
const textBody = extractTextBody(parts);
|
|
385
|
+
const attachments = extractAttachments(parts);
|
|
386
|
+
return {
|
|
387
|
+
content: [{
|
|
388
|
+
type: 'text',
|
|
389
|
+
text: JSON.stringify({
|
|
390
|
+
subject: getHeader(headers, 'Subject'),
|
|
391
|
+
from: getHeader(headers, 'From'),
|
|
392
|
+
date: getHeader(headers, 'Date'),
|
|
393
|
+
textBody: textBody.substring(0, limit),
|
|
394
|
+
textBodyTruncated: textBody.length > limit,
|
|
395
|
+
htmlBodyLength: htmlBody.length,
|
|
396
|
+
attachments,
|
|
397
|
+
}, null, 2),
|
|
398
|
+
}],
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
case 'download_attachment': {
|
|
402
|
+
const { account, message_id, attachment_id, save_path } = args;
|
|
403
|
+
const auth = getAuthenticatedClient(account);
|
|
404
|
+
if (!auth) {
|
|
405
|
+
return {
|
|
406
|
+
content: [{ type: 'text', text: `Account ${account} not authenticated.` }],
|
|
407
|
+
isError: true,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
const gmail = getGmail(auth);
|
|
411
|
+
const att = await gmail.users.messages.attachments.get({
|
|
412
|
+
userId: 'me',
|
|
413
|
+
messageId: message_id,
|
|
414
|
+
id: attachment_id,
|
|
415
|
+
});
|
|
416
|
+
const data = att.data.data;
|
|
417
|
+
const buffer = Buffer.from(data.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
418
|
+
const dir = path.dirname(save_path);
|
|
419
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
420
|
+
fs.writeFileSync(save_path, buffer);
|
|
421
|
+
return { content: [{ type: 'text', text: `Saved attachment (${buffer.length} bytes) to ${save_path}` }] };
|
|
422
|
+
}
|
|
423
|
+
case 'save_email_as_html': {
|
|
424
|
+
const { account, message_id, save_path } = args;
|
|
425
|
+
const auth = getAuthenticatedClient(account);
|
|
426
|
+
if (!auth) {
|
|
427
|
+
return {
|
|
428
|
+
content: [{ type: 'text', text: `Account ${account} not authenticated.` }],
|
|
429
|
+
isError: true,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
const gmail = getGmail(auth);
|
|
433
|
+
const detail = await gmail.users.messages.get({
|
|
434
|
+
userId: 'me',
|
|
435
|
+
id: message_id,
|
|
436
|
+
format: 'full',
|
|
437
|
+
});
|
|
438
|
+
const headers = detail.data.payload?.headers || [];
|
|
439
|
+
const parts = detail.data.payload?.parts || [detail.data.payload];
|
|
440
|
+
let htmlBody = extractHtmlBody(parts);
|
|
441
|
+
if (!htmlBody) {
|
|
442
|
+
const textBody = extractTextBody(parts);
|
|
443
|
+
htmlBody = `<pre>${textBody}</pre>`;
|
|
444
|
+
}
|
|
445
|
+
const subject = getHeader(headers, 'Subject');
|
|
446
|
+
const from = getHeader(headers, 'From');
|
|
447
|
+
const date = getHeader(headers, 'Date');
|
|
448
|
+
const fullHtml = `<!DOCTYPE html>
|
|
449
|
+
<html><head><meta charset="utf-8"><title>${subject}</title></head><body>
|
|
450
|
+
<div style="border-bottom:2px solid #333;padding:12px;margin-bottom:16px;font-family:sans-serif;font-size:14px">
|
|
451
|
+
<p><b>From:</b> ${from}</p>
|
|
452
|
+
<p><b>Date:</b> ${date}</p>
|
|
453
|
+
<p><b>Subject:</b> ${subject}</p>
|
|
454
|
+
</div>
|
|
455
|
+
${htmlBody}
|
|
456
|
+
</body></html>`;
|
|
457
|
+
const dir = path.dirname(save_path);
|
|
458
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
459
|
+
fs.writeFileSync(save_path, fullHtml, 'utf-8');
|
|
460
|
+
return { content: [{ type: 'text', text: `Saved email HTML (${fullHtml.length} chars) to ${save_path}` }] };
|
|
461
|
+
}
|
|
462
|
+
case 'batch_search_receipts': {
|
|
463
|
+
const { items, date_tolerance_days = 7 } = args;
|
|
464
|
+
const authenticatedAccounts = getAuthenticatedAccounts();
|
|
465
|
+
if (authenticatedAccounts.length === 0) {
|
|
466
|
+
return {
|
|
467
|
+
content: [{ type: 'text', text: 'No authenticated accounts. Use authorize_account first.' }],
|
|
468
|
+
isError: true,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
const results = [];
|
|
472
|
+
for (const item of items) {
|
|
473
|
+
const d = new Date(item.date.replace(/\//g, '-'));
|
|
474
|
+
const after = new Date(d);
|
|
475
|
+
after.setDate(after.getDate() - date_tolerance_days);
|
|
476
|
+
const before = new Date(d);
|
|
477
|
+
before.setDate(before.getDate() + date_tolerance_days);
|
|
478
|
+
const afterStr = `${after.getFullYear()}/${String(after.getMonth() + 1).padStart(2, '0')}/${String(after.getDate()).padStart(2, '0')}`;
|
|
479
|
+
const beforeStr = `${before.getFullYear()}/${String(before.getMonth() + 1).padStart(2, '0')}/${String(before.getDate()).padStart(2, '0')}`;
|
|
480
|
+
const query = `${item.vendor} after:${afterStr} before:${beforeStr}`;
|
|
481
|
+
const allMatches = await Promise.all(authenticatedAccounts.map(email => searchAccount(email, query, 5).catch(() => [])));
|
|
482
|
+
const matches = allMatches.flat();
|
|
483
|
+
if (item.amount) {
|
|
484
|
+
const amtStr = item.amount.toLocaleString();
|
|
485
|
+
for (const m of matches) {
|
|
486
|
+
m.amountMatch = m.snippet.includes(amtStr) ||
|
|
487
|
+
m.snippet.includes(String(item.amount));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
results.push({
|
|
491
|
+
vendor: item.vendor,
|
|
492
|
+
date: item.date,
|
|
493
|
+
amount: item.amount,
|
|
494
|
+
matchCount: matches.length,
|
|
495
|
+
matches,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
499
|
+
}
|
|
500
|
+
default:
|
|
501
|
+
return {
|
|
502
|
+
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
503
|
+
isError: true,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
return {
|
|
509
|
+
content: [{ type: 'text', text: `Error: ${error.message}` }],
|
|
510
|
+
isError: true,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
// --- Start ---
|
|
515
|
+
async function main() {
|
|
516
|
+
ensureConfigDir();
|
|
517
|
+
// Migrate legacy single token if needed
|
|
518
|
+
if (fs.existsSync(LEGACY_TOKEN_PATH)) {
|
|
519
|
+
const tokenFiles = fs.existsSync(TOKENS_DIR) ? fs.readdirSync(TOKENS_DIR) : [];
|
|
520
|
+
if (tokenFiles.length === 0) {
|
|
521
|
+
// Try to determine which account this token belongs to
|
|
522
|
+
const accounts = getAccounts();
|
|
523
|
+
if (accounts.length > 0) {
|
|
524
|
+
fs.copyFileSync(LEGACY_TOKEN_PATH, tokenPathFor(accounts[0]));
|
|
525
|
+
process.stderr.write(`Migrated legacy token to ${accounts[0]}\n`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// Fix permissions on existing token files
|
|
530
|
+
if (fs.existsSync(TOKENS_DIR)) {
|
|
531
|
+
for (const f of fs.readdirSync(TOKENS_DIR)) {
|
|
532
|
+
if (f.endsWith('.json')) {
|
|
533
|
+
try {
|
|
534
|
+
fs.chmodSync(path.join(TOKENS_DIR, f), 0o600);
|
|
535
|
+
}
|
|
536
|
+
catch { }
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const transport = new StdioServerTransport();
|
|
541
|
+
await server.connect(transport);
|
|
542
|
+
process.stderr.write('Gmail MCP server running (v2.0.0)\n');
|
|
543
|
+
}
|
|
544
|
+
main().catch((err) => {
|
|
545
|
+
process.stderr.write(`Fatal: ${err.message}\n`);
|
|
546
|
+
process.exit(1);
|
|
547
|
+
});
|