google-tools-mcp 1.2.7 → 1.2.9
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/dist/auth.js +343 -325
- package/dist/clients.js +293 -213
- package/dist/index.js +85 -74
- package/dist/setup.js +422 -235
- package/dist/tools/gmail/messages.js +14 -4
- package/dist/tools/gmail/threads.js +14 -4
- package/dist/tools/sheets/deleteColumns.js +66 -0
- package/dist/tools/sheets/index.js +2 -0
- package/dist/tools/sheets/ungroupAllRows.js +6 -2
- package/package.json +1 -1
package/dist/auth.js
CHANGED
|
@@ -1,325 +1,343 @@
|
|
|
1
|
-
// Combined auth for google-tools-mcp.
|
|
2
|
-
// Merges GDrive + Gmail scopes into a single OAuth flow.
|
|
3
|
-
// Config dir: ~/.config/google-tools-mcp/ (with GOOGLE_MCP_PROFILE subdirs).
|
|
4
|
-
import { google } from 'googleapis';
|
|
5
|
-
import { JWT } from 'google-auth-library';
|
|
6
|
-
import * as fs from 'fs/promises';
|
|
7
|
-
import * as path from 'path';
|
|
8
|
-
import * as os from 'os';
|
|
9
|
-
import * as http from 'http';
|
|
10
|
-
import { exec } from 'child_process';
|
|
11
|
-
import { fileURLToPath } from 'url';
|
|
12
|
-
import { logger } from './logger.js';
|
|
13
|
-
|
|
14
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
-
const __dirname = path.dirname(__filename);
|
|
16
|
-
const projectRootDir = path.resolve(__dirname, '..');
|
|
17
|
-
const CREDENTIALS_PATH = path.join(projectRootDir, 'credentials.json');
|
|
18
|
-
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
// Paths
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
function getConfigDir() {
|
|
23
|
-
const xdg = process.env.XDG_CONFIG_HOME;
|
|
24
|
-
const base = xdg || path.join(os.homedir(), '.config');
|
|
25
|
-
const baseDir = path.join(base, 'google-tools-mcp');
|
|
26
|
-
const profile = process.env.GOOGLE_MCP_PROFILE;
|
|
27
|
-
return profile ? path.join(baseDir, profile) : baseDir;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function getTokenPath() {
|
|
31
|
-
return path.join(getConfigDir(), 'token.json');
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// Scopes (GDrive + Gmail combined)
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
const SCOPES = [
|
|
38
|
-
// GDrive / Docs / Sheets
|
|
39
|
-
'https://www.googleapis.com/auth/documents',
|
|
40
|
-
'https://www.googleapis.com/auth/drive',
|
|
41
|
-
'https://www.googleapis.com/auth/spreadsheets',
|
|
42
|
-
'https://www.googleapis.com/auth/script.external_request',
|
|
43
|
-
// Gmail
|
|
44
|
-
'https://www.googleapis.com/auth/gmail.modify',
|
|
45
|
-
'https://www.googleapis.com/auth/gmail.compose',
|
|
46
|
-
'https://www.googleapis.com/auth/gmail.send',
|
|
47
|
-
'https://www.googleapis.com/auth/gmail.settings.basic',
|
|
48
|
-
'https://www.googleapis.com/auth/gmail.settings.sharing',
|
|
49
|
-
// Calendar
|
|
50
|
-
'https://www.googleapis.com/auth/calendar',
|
|
51
|
-
// Forms
|
|
52
|
-
'https://www.googleapis.com/auth/forms.body',
|
|
53
|
-
'https://www.googleapis.com/auth/forms.body.readonly',
|
|
54
|
-
'https://www.googleapis.com/auth/forms.responses.readonly',
|
|
55
|
-
// Slides
|
|
56
|
-
'https://www.googleapis.com/auth/presentations',
|
|
57
|
-
// Tasks
|
|
58
|
-
'https://www.googleapis.com/auth/tasks',
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}
|
|
1
|
+
// Combined auth for google-tools-mcp.
|
|
2
|
+
// Merges GDrive + Gmail scopes into a single OAuth flow.
|
|
3
|
+
// Config dir: ~/.config/google-tools-mcp/ (with GOOGLE_MCP_PROFILE subdirs).
|
|
4
|
+
import { google } from 'googleapis';
|
|
5
|
+
import { JWT } from 'google-auth-library';
|
|
6
|
+
import * as fs from 'fs/promises';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as os from 'os';
|
|
9
|
+
import * as http from 'http';
|
|
10
|
+
import { exec } from 'child_process';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { logger } from './logger.js';
|
|
13
|
+
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
+
const __dirname = path.dirname(__filename);
|
|
16
|
+
const projectRootDir = path.resolve(__dirname, '..');
|
|
17
|
+
const CREDENTIALS_PATH = path.join(projectRootDir, 'credentials.json');
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Paths
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
function getConfigDir() {
|
|
23
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
24
|
+
const base = xdg || path.join(os.homedir(), '.config');
|
|
25
|
+
const baseDir = path.join(base, 'google-tools-mcp');
|
|
26
|
+
const profile = process.env.GOOGLE_MCP_PROFILE;
|
|
27
|
+
return profile ? path.join(baseDir, profile) : baseDir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getTokenPath() {
|
|
31
|
+
return path.join(getConfigDir(), 'token.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Scopes (GDrive + Gmail combined)
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
const SCOPES = [
|
|
38
|
+
// GDrive / Docs / Sheets
|
|
39
|
+
'https://www.googleapis.com/auth/documents',
|
|
40
|
+
'https://www.googleapis.com/auth/drive',
|
|
41
|
+
'https://www.googleapis.com/auth/spreadsheets',
|
|
42
|
+
'https://www.googleapis.com/auth/script.external_request',
|
|
43
|
+
// Gmail
|
|
44
|
+
'https://www.googleapis.com/auth/gmail.modify',
|
|
45
|
+
'https://www.googleapis.com/auth/gmail.compose',
|
|
46
|
+
'https://www.googleapis.com/auth/gmail.send',
|
|
47
|
+
'https://www.googleapis.com/auth/gmail.settings.basic',
|
|
48
|
+
'https://www.googleapis.com/auth/gmail.settings.sharing',
|
|
49
|
+
// Calendar
|
|
50
|
+
'https://www.googleapis.com/auth/calendar',
|
|
51
|
+
// Forms
|
|
52
|
+
'https://www.googleapis.com/auth/forms.body',
|
|
53
|
+
'https://www.googleapis.com/auth/forms.body.readonly',
|
|
54
|
+
'https://www.googleapis.com/auth/forms.responses.readonly',
|
|
55
|
+
// Slides
|
|
56
|
+
'https://www.googleapis.com/auth/presentations',
|
|
57
|
+
// Tasks
|
|
58
|
+
'https://www.googleapis.com/auth/tasks',
|
|
59
|
+
// Service Usage — lets the setup wizard programmatically enable the APIs above
|
|
60
|
+
// in the user's own project (Service Usage API itself is enabled by default).
|
|
61
|
+
'https://www.googleapis.com/auth/service.management',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// .env file loader
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
async function loadEnvFile(filePath) {
|
|
68
|
+
try {
|
|
69
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
70
|
+
for (const line of content.split('\n')) {
|
|
71
|
+
const trimmed = line.trim();
|
|
72
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
73
|
+
const eqIdx = trimmed.indexOf('=');
|
|
74
|
+
if (eqIdx === -1) continue;
|
|
75
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
76
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
77
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
78
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
79
|
+
value = value.slice(1, -1);
|
|
80
|
+
}
|
|
81
|
+
if (!process.env[key]) {
|
|
82
|
+
process.env[key] = value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Client secrets resolution
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
async function loadClientSecrets() {
|
|
95
|
+
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
|
96
|
+
return { client_id: process.env.GOOGLE_CLIENT_ID, client_secret: process.env.GOOGLE_CLIENT_SECRET };
|
|
97
|
+
}
|
|
98
|
+
const configDir = getConfigDir();
|
|
99
|
+
const cwd = process.cwd();
|
|
100
|
+
await loadEnvFile(path.join(configDir, '.env'));
|
|
101
|
+
await loadEnvFile(path.join(cwd, '.env'));
|
|
102
|
+
await loadEnvFile(path.join(projectRootDir, '.env'));
|
|
103
|
+
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
|
104
|
+
logger.info('Loaded client credentials from .env file.');
|
|
105
|
+
return { client_id: process.env.GOOGLE_CLIENT_ID, client_secret: process.env.GOOGLE_CLIENT_SECRET };
|
|
106
|
+
}
|
|
107
|
+
const credentialsPaths = [
|
|
108
|
+
path.join(configDir, 'credentials.json'),
|
|
109
|
+
path.join(cwd, 'credentials.json'),
|
|
110
|
+
CREDENTIALS_PATH,
|
|
111
|
+
];
|
|
112
|
+
for (const credPath of credentialsPaths) {
|
|
113
|
+
try {
|
|
114
|
+
const content = await fs.readFile(credPath, 'utf8');
|
|
115
|
+
const keys = JSON.parse(content);
|
|
116
|
+
const key = keys.installed || keys.web;
|
|
117
|
+
if (key) {
|
|
118
|
+
logger.info('Loaded client credentials from', credPath);
|
|
119
|
+
return { client_id: key.client_id, client_secret: key.client_secret };
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (err.code !== 'ENOENT') throw err;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const configDirDisplay = configDir.replace(os.homedir(), '~');
|
|
126
|
+
throw new Error(
|
|
127
|
+
'No OAuth credentials found. Provide them in any of these ways:\n' +
|
|
128
|
+
` 1. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET env vars in your MCP config\n` +
|
|
129
|
+
` 2. Create a .env file with GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in ${configDirDisplay}/ or your project directory\n` +
|
|
130
|
+
` 3. Place your credentials.json (from Google Cloud Console) in ${configDirDisplay}/ or your project directory`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Service account auth
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
async function authorizeWithServiceAccount() {
|
|
138
|
+
const serviceAccountPath = process.env.SERVICE_ACCOUNT_PATH;
|
|
139
|
+
const impersonateUser = process.env.GOOGLE_IMPERSONATE_USER;
|
|
140
|
+
try {
|
|
141
|
+
const keyFileContent = await fs.readFile(serviceAccountPath, 'utf8');
|
|
142
|
+
const serviceAccountKey = JSON.parse(keyFileContent);
|
|
143
|
+
const auth = new JWT({
|
|
144
|
+
email: serviceAccountKey.client_email,
|
|
145
|
+
key: serviceAccountKey.private_key,
|
|
146
|
+
scopes: SCOPES,
|
|
147
|
+
subject: impersonateUser,
|
|
148
|
+
});
|
|
149
|
+
await auth.authorize();
|
|
150
|
+
if (impersonateUser) {
|
|
151
|
+
logger.info(`Service Account authentication successful, impersonating: ${impersonateUser}`);
|
|
152
|
+
} else {
|
|
153
|
+
logger.info('Service Account authentication successful!');
|
|
154
|
+
}
|
|
155
|
+
return auth;
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error.code === 'ENOENT') {
|
|
158
|
+
logger.error(`FATAL: Service account key file not found at path: ${serviceAccountPath}`);
|
|
159
|
+
throw new Error('Service account key file not found. Please check the path in SERVICE_ACCOUNT_PATH.');
|
|
160
|
+
}
|
|
161
|
+
logger.error('FATAL: Error loading or authorizing the service account key:', error.message);
|
|
162
|
+
throw new Error('Failed to authorize using the service account.');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Token persistence
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
async function loadSavedCredentialsIfExist() {
|
|
170
|
+
try {
|
|
171
|
+
const tokenPath = getTokenPath();
|
|
172
|
+
const content = await fs.readFile(tokenPath, 'utf8');
|
|
173
|
+
const credentials = JSON.parse(content);
|
|
174
|
+
|
|
175
|
+
// Check that the saved token covers all current SCOPES.
|
|
176
|
+
// Tokens without a scopes field (pre-upgrade) or with missing scopes
|
|
177
|
+
// are stale — delete and force re-auth via browser automatically.
|
|
178
|
+
if (!Array.isArray(credentials.scopes)) {
|
|
179
|
+
logger.info('Saved token has no scopes record (pre-upgrade token). Re-authentication required for new scopes.');
|
|
180
|
+
try { await fs.unlink(tokenPath); } catch {}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const saved = new Set(credentials.scopes);
|
|
184
|
+
const missing = SCOPES.filter(s => !saved.has(s));
|
|
185
|
+
if (missing.length > 0) {
|
|
186
|
+
logger.info(`Saved token is missing scope(s): ${missing.join(', ')}. Re-authentication required.`);
|
|
187
|
+
try { await fs.unlink(tokenPath); } catch {}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const { client_secret, client_id } = await loadClientSecrets();
|
|
192
|
+
const client = new google.auth.OAuth2(client_id, client_secret);
|
|
193
|
+
client.setCredentials(credentials);
|
|
194
|
+
return client;
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function saveCredentials(client) {
|
|
201
|
+
const { client_secret, client_id } = await loadClientSecrets();
|
|
202
|
+
const configDir = getConfigDir();
|
|
203
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
204
|
+
const tokenPath = getTokenPath();
|
|
205
|
+
const payload = JSON.stringify({
|
|
206
|
+
type: 'authorized_user',
|
|
207
|
+
client_id,
|
|
208
|
+
client_secret,
|
|
209
|
+
refresh_token: client.credentials.refresh_token,
|
|
210
|
+
scopes: SCOPES,
|
|
211
|
+
}, null, 2);
|
|
212
|
+
await fs.writeFile(tokenPath, payload);
|
|
213
|
+
logger.info('Token stored to', tokenPath);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Browser opener
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
function openBrowser(url) {
|
|
220
|
+
const platform = process.platform;
|
|
221
|
+
let cmd;
|
|
222
|
+
if (platform === 'win32') {
|
|
223
|
+
cmd = `start "" "${url}"`;
|
|
224
|
+
} else if (platform === 'darwin') {
|
|
225
|
+
cmd = `open "${url}"`;
|
|
226
|
+
} else {
|
|
227
|
+
cmd = `xdg-open "${url}"`;
|
|
228
|
+
}
|
|
229
|
+
exec(cmd, (err) => {
|
|
230
|
+
if (err) {
|
|
231
|
+
logger.warn('Could not auto-open browser. Please open this URL manually.');
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Interactive OAuth browser flow
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
async function authenticate() {
|
|
240
|
+
const { client_secret, client_id } = await loadClientSecrets();
|
|
241
|
+
const server = http.createServer();
|
|
242
|
+
await new Promise((resolve) => server.listen(0, 'localhost', resolve));
|
|
243
|
+
const port = server.address().port;
|
|
244
|
+
const redirectUri = `http://localhost:${port}`;
|
|
245
|
+
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
|
|
246
|
+
const authorizeUrl = oAuth2Client.generateAuthUrl({
|
|
247
|
+
access_type: 'offline',
|
|
248
|
+
scope: SCOPES.join(' '),
|
|
249
|
+
});
|
|
250
|
+
logger.info('Opening browser for Google authorization...');
|
|
251
|
+
logger.info('If the browser does not open, visit this URL:', authorizeUrl);
|
|
252
|
+
openBrowser(authorizeUrl);
|
|
253
|
+
const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
254
|
+
const code = await new Promise((resolve, reject) => {
|
|
255
|
+
const timeout = setTimeout(() => {
|
|
256
|
+
server.close();
|
|
257
|
+
reject(new Error(
|
|
258
|
+
'OAuth flow timed out after 5 minutes. ' +
|
|
259
|
+
'Please re-run `google-tools-mcp auth` or call the `troubleshoot` tool.'
|
|
260
|
+
));
|
|
261
|
+
}, OAUTH_TIMEOUT_MS);
|
|
262
|
+
server.on('request', (req, res) => {
|
|
263
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
264
|
+
const authCode = url.searchParams.get('code');
|
|
265
|
+
const error = url.searchParams.get('error');
|
|
266
|
+
if (error) {
|
|
267
|
+
clearTimeout(timeout);
|
|
268
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
269
|
+
res.end('<h1>Authorization failed</h1><p>You can close this tab.</p>');
|
|
270
|
+
server.close();
|
|
271
|
+
reject(new Error(`Authorization error: ${error}`));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (authCode) {
|
|
275
|
+
clearTimeout(timeout);
|
|
276
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
277
|
+
res.end('<h1>Google authorization successful!</h1><p>You can close this tab.</p>');
|
|
278
|
+
server.close();
|
|
279
|
+
resolve(authCode);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// Unrecognized request (favicon, prefetch, etc.) — respond so the
|
|
283
|
+
// browser does not hang, but do not settle the Promise.
|
|
284
|
+
res.writeHead(404);
|
|
285
|
+
res.end();
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
const { tokens } = await oAuth2Client.getToken(code);
|
|
289
|
+
oAuth2Client.setCredentials(tokens);
|
|
290
|
+
if (tokens.refresh_token) {
|
|
291
|
+
await saveCredentials(oAuth2Client);
|
|
292
|
+
} else {
|
|
293
|
+
logger.warn('Did not receive refresh token. Token might expire.');
|
|
294
|
+
}
|
|
295
|
+
logger.info('Authentication successful!');
|
|
296
|
+
return oAuth2Client;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Public API
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
export { getTokenPath, getConfigDir, SCOPES };
|
|
303
|
+
|
|
304
|
+
export async function authorize() {
|
|
305
|
+
if (process.env.SERVICE_ACCOUNT_PATH) {
|
|
306
|
+
logger.info('Service account path detected. Attempting service account authentication...');
|
|
307
|
+
return authorizeWithServiceAccount();
|
|
308
|
+
}
|
|
309
|
+
logger.info('Attempting OAuth 2.0 authentication...');
|
|
310
|
+
const client = await loadSavedCredentialsIfExist();
|
|
311
|
+
if (client) {
|
|
312
|
+
// Proactively refresh to verify the token is still valid
|
|
313
|
+
try {
|
|
314
|
+
logger.info('Refreshing access token...');
|
|
315
|
+
const { credentials } = await client.refreshAccessToken();
|
|
316
|
+
client.setCredentials(credentials);
|
|
317
|
+
if (credentials.refresh_token) {
|
|
318
|
+
await saveCredentials(client);
|
|
319
|
+
}
|
|
320
|
+
const expiryDate = credentials.expiry_date
|
|
321
|
+
? new Date(credentials.expiry_date).toISOString()
|
|
322
|
+
: 'unknown';
|
|
323
|
+
logger.info(`Token refreshed successfully. Expires: ${expiryDate}`);
|
|
324
|
+
return client;
|
|
325
|
+
} catch (err) {
|
|
326
|
+
const isInvalidGrant = err.message?.includes('invalid_grant') ||
|
|
327
|
+
err.response?.data?.error === 'invalid_grant';
|
|
328
|
+
if (isInvalidGrant) {
|
|
329
|
+
logger.warn('Saved refresh token is invalid/revoked. Starting re-authentication...');
|
|
330
|
+
try { await fs.unlink(getTokenPath()); } catch {}
|
|
331
|
+
return authenticate();
|
|
332
|
+
}
|
|
333
|
+
logger.error('Token refresh failed:', err.message || err);
|
|
334
|
+
throw err;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
logger.info('No saved token found. Starting interactive authentication flow...');
|
|
338
|
+
return authenticate();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function runAuthFlow() {
|
|
342
|
+
return await authenticate();
|
|
343
|
+
}
|