google-tools-mcp 1.2.11 → 2.0.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.
Files changed (97) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +555 -276
  3. package/dist/cachedToolsList.js +52 -52
  4. package/dist/googleDocsApiHelpers.js +76 -12
  5. package/dist/helpers.js +572 -252
  6. package/dist/httpAuth.js +296 -0
  7. package/dist/index.js +162 -11
  8. package/dist/logger.js +87 -87
  9. package/dist/markdown-transformer/docsToMarkdown.js +222 -21
  10. package/dist/markdown-transformer/index.js +19 -9
  11. package/dist/markdown-transformer/markdownToDocs.js +458 -28
  12. package/dist/readTracker.js +136 -17
  13. package/dist/sessionContext.js +37 -0
  14. package/dist/setup.js +187 -9
  15. package/dist/tools/calendar/getBusy.js +64 -64
  16. package/dist/tools/calendar/getEvents.js +141 -141
  17. package/dist/tools/calendar/getFree.js +225 -225
  18. package/dist/tools/calendar/index.js +19 -19
  19. package/dist/tools/calendar/listCalendars.js +38 -38
  20. package/dist/tools/calendar/listRecurringInstances.js +83 -83
  21. package/dist/tools/calendar/manageCalendar.js +121 -121
  22. package/dist/tools/calendar/manageEvent.js +258 -258
  23. package/dist/tools/calendar/moveEvent.js +66 -66
  24. package/dist/tools/docs/addTab.js +10 -12
  25. package/dist/tools/docs/appendToGoogleDoc.js +9 -4
  26. package/dist/tools/docs/deleteRange.js +9 -4
  27. package/dist/tools/docs/findAndReplace.js +9 -4
  28. package/dist/tools/docs/formatting/applyParagraphStyle.js +4 -1
  29. package/dist/tools/docs/formatting/getFormatting.js +171 -171
  30. package/dist/tools/docs/insertImage.js +15 -2
  31. package/dist/tools/docs/insertPageBreak.js +4 -1
  32. package/dist/tools/docs/insertTable.js +4 -1
  33. package/dist/tools/docs/insertTableWithData.js +4 -1
  34. package/dist/tools/docs/modifyText.js +14 -4
  35. package/dist/tools/docs/modifyText.test.js +84 -84
  36. package/dist/tools/docs/readGoogleDoc.js +71 -11
  37. package/dist/tools/docs/renameTab.js +12 -14
  38. package/dist/tools/drafts.js +165 -165
  39. package/dist/tools/drive/createDocument.js +13 -1
  40. package/dist/tools/drive/downloadFile.js +268 -268
  41. package/dist/tools/drive/getFileInfo.js +48 -48
  42. package/dist/tools/drive/getFilePath.js +50 -50
  43. package/dist/tools/drive/listDriveFiles.js +112 -112
  44. package/dist/tools/drive/listSharedDrives.js +52 -52
  45. package/dist/tools/drive/listSharedWithMe.js +97 -97
  46. package/dist/tools/drive/uploadFile.js +111 -111
  47. package/dist/tools/extras/index.js +9 -9
  48. package/dist/tools/extras/readDriveFile.js +212 -206
  49. package/dist/tools/extras/readFile.js +84 -84
  50. package/dist/tools/extras/searchFileContents.js +81 -81
  51. package/dist/tools/forms/batchUpdateForm.js +81 -81
  52. package/dist/tools/forms/createForm.js +54 -54
  53. package/dist/tools/forms/getForm.js +118 -118
  54. package/dist/tools/forms/getFormResponse.js +45 -45
  55. package/dist/tools/forms/index.js +15 -15
  56. package/dist/tools/forms/listFormResponses.js +56 -56
  57. package/dist/tools/forms/setPublishSettings.js +59 -59
  58. package/dist/tools/gmail/drafts.js +165 -165
  59. package/dist/tools/gmail/labels.js +54 -83
  60. package/dist/tools/gmail/messages.js +444 -441
  61. package/dist/tools/gmail/settings.js +358 -528
  62. package/dist/tools/gmail/threads.js +286 -164
  63. package/dist/tools/index.js +511 -473
  64. package/dist/tools/labels.js +103 -103
  65. package/dist/tools/legacyAliases.js +426 -0
  66. package/dist/tools/maps/directions.js +72 -0
  67. package/dist/tools/maps/geocode.js +16 -0
  68. package/dist/tools/maps/index.js +15 -0
  69. package/dist/tools/maps/mapsClient.js +83 -0
  70. package/dist/tools/maps/placeDetails.js +16 -0
  71. package/dist/tools/maps/reverseGeocode.js +19 -0
  72. package/dist/tools/maps/searchNearby.js +54 -0
  73. package/dist/tools/maps/searchPlaces.js +24 -0
  74. package/dist/tools/messages.js +448 -448
  75. package/dist/tools/settings.js +528 -528
  76. package/dist/tools/slides/createPresentation.js +104 -104
  77. package/dist/tools/slides/createShape.js +92 -92
  78. package/dist/tools/slides/createTextBox.js +82 -82
  79. package/dist/tools/slides/deleteSlide.js +30 -30
  80. package/dist/tools/slides/duplicateSlide.js +37 -37
  81. package/dist/tools/slides/exportThumbnail.js +42 -42
  82. package/dist/tools/slides/formatParagraph.js +72 -72
  83. package/dist/tools/slides/formatText.js +84 -84
  84. package/dist/tools/slides/getPresentation.js +87 -87
  85. package/dist/tools/slides/index.js +33 -33
  86. package/dist/tools/slides/reorderSlides.js +41 -41
  87. package/dist/tools/slides/replaceAllText.js +46 -46
  88. package/dist/tools/slides/setBackground.js +58 -58
  89. package/dist/tools/slides/speakerNotes.js +102 -102
  90. package/dist/tools/slides/styleShape.js +111 -111
  91. package/dist/tools/slides/updatePresentation.js +128 -128
  92. package/dist/tools/threads.js +145 -145
  93. package/dist/tools/utils/appendMarkdownToGoogleDoc.js +31 -7
  94. package/dist/tools/utils/replaceDocumentWithMarkdown.js +72 -9
  95. package/dist/updateCheck.js +202 -0
  96. package/dist/workspace.js +122 -0
  97. package/package.json +82 -80
@@ -1,473 +1,511 @@
1
- // Tool registration — all categories loaded eagerly at startup.
2
- // (Claude Code doesn't support notifications/tools/list_changed,
3
- // so lazy-loading doesn't work.)
4
- import { z } from 'zod';
5
- import * as fs from 'fs/promises';
6
- import * as path from 'path';
7
- import { fileURLToPath } from 'url';
8
- import * as os from 'os';
9
- import { exec } from 'child_process';
10
- import { promisify } from 'util';
11
- import { getTokenPath, getConfigDir, SCOPES } from '../auth.js';
12
- import { resetClients, withAuthRetry, getAuthClientIfReady } from '../clients.js';
13
- import { logger } from '../logger.js';
14
- import { google } from 'googleapis';
15
-
16
- const execAsync = promisify(exec);
17
-
18
- const REPO = 'karthikcsq/google-tools-mcp';
19
-
20
- async function tryGhCli(title, body, label) {
21
- // Probe for gh CLI
22
- try {
23
- await execAsync('gh --version');
24
- } catch {
25
- return { ok: false, reason: 'gh CLI not installed' };
26
- }
27
- // Probe for auth
28
- try {
29
- await execAsync('gh auth status');
30
- } catch {
31
- return { ok: false, reason: 'gh CLI not authenticated (run: gh auth login)' };
32
- }
33
- // Write body to a temp file to avoid shell-escaping issues with newlines/quotes.
34
- const tmpFile = path.join(os.tmpdir(), `gtm-feedback-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
35
- try {
36
- await fs.writeFile(tmpFile, body, 'utf8');
37
- const { stdout } = await execAsync(
38
- `gh issue create --repo ${REPO} --title ${JSON.stringify(title)} --label ${JSON.stringify(label)} --body-file ${JSON.stringify(tmpFile)}`,
39
- { maxBuffer: 10 * 1024 * 1024 }
40
- );
41
- const issueUrl = stdout.trim().split('\n').pop();
42
- return { ok: true, issueUrl };
43
- } catch (err) {
44
- return { ok: false, reason: `gh CLI failed: ${err.stderr || err.message || err}` };
45
- } finally {
46
- try { await fs.unlink(tmpFile); } catch {}
47
- }
48
- }
49
-
50
- function openBrowser(url) {
51
- const platform = process.platform;
52
- let cmd;
53
- if (platform === 'win32') {
54
- cmd = `start "" "${url}"`;
55
- } else if (platform === 'darwin') {
56
- cmd = `open "${url}"`;
57
- } else {
58
- cmd = `xdg-open "${url}"`;
59
- }
60
- return new Promise((resolve) => {
61
- exec(cmd, (err) => resolve(!err));
62
- });
63
- }
64
-
65
- // ---------------------------------------------------------------------------
66
- // Wrap server.addTool so every tool's execute() auto-retries on invalid_grant
67
- // and appends a troubleshoot/feedback hint to errors.
68
- // ---------------------------------------------------------------------------
69
- const ERROR_HINT =
70
- '\n\nIf this error is unexpected or unclear, you can:\n' +
71
- ' • Call the `troubleshoot` tool to run a health check (auth, API connectivity, recent logs).\n' +
72
- ' Call the `feedback` tool to file a bug report with diagnostics auto-attached.';
73
-
74
- // Tools that should NOT have the hint appended (would be circular/noisy).
75
- const HINT_EXCLUDED_TOOLS = new Set(['troubleshoot', 'feedback', 'help', 'logout']);
76
-
77
- function appendHintToError(error, toolName) {
78
- if (HINT_EXCLUDED_TOOLS.has(toolName)) return error;
79
- if (!error) return error;
80
- // Avoid double-appending if something else (or a retry) already added it.
81
- const existingMsg = error.message || '';
82
- if (existingMsg.includes('`troubleshoot` tool')) return error;
83
- try {
84
- error.message = existingMsg + ERROR_HINT;
85
- } catch {
86
- // Some error types have non-writable message; fall back to a new error.
87
- const wrapped = new Error(existingMsg + ERROR_HINT);
88
- wrapped.cause = error;
89
- return wrapped;
90
- }
91
- return error;
92
- }
93
-
94
- function wrapServerWithAuthRetry(server) {
95
- const originalAddTool = server.addTool.bind(server);
96
- server.addTool = function (toolDef) {
97
- const originalExecute = toolDef.execute;
98
- const toolName = toolDef.name;
99
- if (originalExecute) {
100
- toolDef.execute = async function (...args) {
101
- try {
102
- return await withAuthRetry(() => originalExecute.apply(this, args));
103
- } catch (err) {
104
- throw appendHintToError(err, toolName);
105
- }
106
- };
107
- }
108
- return originalAddTool(toolDef);
109
- };
110
- return server;
111
- }
112
-
113
- // --- Category registry ---
114
- const CATEGORIES = {
115
- files: {
116
- async loader(server) {
117
- const { registerDriveTools } = await import('./drive/index.js');
118
- const { registerExtrasTools } = await import('./extras/index.js');
119
- registerDriveTools(server);
120
- registerExtrasTools(server);
121
- },
122
- },
123
- documents: {
124
- async loader(server) {
125
- const { registerDocsTools } = await import('./docs/index.js');
126
- const { registerUtilsTools } = await import('./utils/index.js');
127
- registerDocsTools(server);
128
- registerUtilsTools(server);
129
- },
130
- },
131
- spreadsheets: {
132
- async loader(server) {
133
- const { registerSheetsTools } = await import('./sheets/index.js');
134
- registerSheetsTools(server);
135
- },
136
- },
137
- email: {
138
- async loader(server) {
139
- const { register: registerMessages } = await import('./gmail/messages.js');
140
- const { register: registerDrafts } = await import('./gmail/drafts.js');
141
- registerMessages(server);
142
- registerDrafts(server);
143
- },
144
- },
145
- email_threads: {
146
- async loader(server) {
147
- const { register } = await import('./gmail/threads.js');
148
- register(server);
149
- },
150
- },
151
- email_labels: {
152
- async loader(server) {
153
- const { register } = await import('./gmail/labels.js');
154
- register(server);
155
- },
156
- },
157
- email_settings: {
158
- async loader(server) {
159
- const { register } = await import('./gmail/settings.js');
160
- register(server);
161
- },
162
- },
163
- calendar: {
164
- async loader(server) {
165
- const { registerCalendarTools } = await import('./calendar/index.js');
166
- registerCalendarTools(server);
167
- },
168
- },
169
- forms: {
170
- async loader(server) {
171
- const { registerFormsTools } = await import('./forms/index.js');
172
- registerFormsTools(server);
173
- },
174
- },
175
- slides: {
176
- async loader(server) {
177
- const { registerSlidesTools } = await import('./slides/index.js');
178
- registerSlidesTools(server);
179
- },
180
- },
181
- tasks: {
182
- async loader(server) {
183
- const { registerTasksTools } = await import('./tasks/index.js');
184
- registerTasksTools(server);
185
- },
186
- },
187
- };
188
-
189
- // ---------------------------------------------------------------------------
190
- // Public: register all tools eagerly, plus the logout utility.
191
- // ---------------------------------------------------------------------------
192
- export async function registerAllTools(server) {
193
- // Wrap server so every tool auto-retries on invalid_grant (expired refresh token)
194
- const wrappedServer = wrapServerWithAuthRetry(server);
195
-
196
- // Load every category
197
- for (const [name, { loader }] of Object.entries(CATEGORIES)) {
198
- await loader(wrappedServer);
199
- }
200
- logger.info(`Loaded all ${Object.keys(CATEGORIES).length} categories at startup.`);
201
-
202
- // --- Help tool (always available) ---
203
- server.addTool({
204
- name: 'help',
205
- description:
206
- 'Show documentation for google-tools-mcp: setup instructions, available tool categories, environment variables, and troubleshooting. ' +
207
- 'Call this when you need guidance on how to use the Google Workspace tools. ' +
208
- 'Also available: `troubleshoot` (run health check when tools fail) and `feedback` (submit bug reports/feature requests with diagnostics auto-attached).',
209
- parameters: z.object({}),
210
- execute: async () => {
211
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
212
- const readmePath = path.resolve(__dirname, '..', '..', 'README.md');
213
- const diagnosticsSection = '\n\n## Diagnostics & Feedback\n\n' +
214
- '- **`troubleshoot`** Run a health check when tools fail (checks auth, API connectivity, config, recent logs).\n' +
215
- '- **`feedback`** Submit a bug report or feature request with diagnostics auto-attached (files via GitHub CLI or browser).\n' +
216
- '- **`help`** Show this documentation.\n';
217
- try {
218
- const readme = await fs.readFile(readmePath, 'utf-8');
219
- return readme + diagnosticsSection;
220
- } catch {
221
- return 'README not found. Visit https://www.npmjs.com/package/google-tools-mcp for documentation.' + diagnosticsSection;
222
- }
223
- },
224
- });
225
-
226
- // --- Logout tool (always available) ---
227
- server.addTool({
228
- name: 'logout',
229
- description:
230
- 'Log out of the current Google account by deleting the saved OAuth token. The next tool call will require re-authentication.',
231
- parameters: z.object({}),
232
- execute: async () => {
233
- const tokenPath = getTokenPath();
234
- try {
235
- await fs.unlink(tokenPath);
236
- } catch (err) {
237
- if (err.code !== 'ENOENT') throw err;
238
- }
239
- resetClients();
240
- return JSON.stringify({
241
- success: true,
242
- message: 'Logged out. The next tool call will require re-authentication.',
243
- });
244
- },
245
- });
246
-
247
- // --- Troubleshoot tool (always available) ---
248
- server.addTool({
249
- name: 'troubleshoot',
250
- description:
251
- 'Run a health check on google-tools-mcp: verify OAuth token, test API connectivity, show config and recent logs. Call this when tools are failing or behaving unexpectedly.',
252
- parameters: z.object({}),
253
- execute: async () => {
254
- const report = { auth: {}, services: {}, config: {}, recentLogs: null, environment: {} };
255
-
256
- // --- Auth status ---
257
- const tokenPath = getTokenPath();
258
- try {
259
- const tokenContent = await fs.readFile(tokenPath, 'utf8');
260
- const token = JSON.parse(tokenContent);
261
- report.auth.tokenFile = 'present';
262
- report.auth.hasRefreshToken = !!token.refresh_token;
263
- report.auth.type = token.type || 'unknown';
264
- } catch (err) {
265
- report.auth.tokenFile = err.code === 'ENOENT' ? 'missing' : 'unreadable';
266
- report.auth.hasRefreshToken = false;
267
- }
268
-
269
- // Try refreshing token
270
- const client = getAuthClientIfReady();
271
- if (client) {
272
- try {
273
- const { credentials } = await client.refreshAccessToken();
274
- client.setCredentials(credentials);
275
- report.auth.status = 'valid';
276
- report.auth.expiry = credentials.expiry_date
277
- ? new Date(credentials.expiry_date).toISOString()
278
- : 'unknown';
279
- } catch (err) {
280
- report.auth.status = 'expired_or_revoked';
281
- report.auth.refreshError = err.message;
282
- }
283
- } else {
284
- report.auth.status = report.auth.tokenFile === 'present' ? 'not_initialized' : 'missing';
285
- }
286
-
287
- // --- Service probes ---
288
- if (client && report.auth.status === 'valid') {
289
- // Drive
290
- try {
291
- const drive = google.drive({ version: 'v3', auth: client });
292
- const res = await drive.about.get({ fields: 'user' });
293
- report.services.drive = { status: 'ok', account: res.data.user?.emailAddress || 'unknown' };
294
- } catch (err) {
295
- report.services.drive = { status: 'error', error: err.message };
296
- }
297
-
298
- // Gmail
299
- try {
300
- const gmail = google.gmail({ version: 'v1', auth: client });
301
- const res = await gmail.users.getProfile({ userId: 'me' });
302
- report.services.gmail = { status: 'ok', email: res.data.emailAddress || 'unknown' };
303
- } catch (err) {
304
- report.services.gmail = { status: 'error', error: err.message };
305
- }
306
-
307
- // Calendar
308
- try {
309
- const calendar = google.calendar({ version: 'v3', auth: client });
310
- await calendar.calendarList.list({ maxResults: 1 });
311
- report.services.calendar = { status: 'ok' };
312
- } catch (err) {
313
- report.services.calendar = { status: 'error', error: err.message };
314
- }
315
-
316
- // Forms — just check scope presence
317
- report.services.forms = {
318
- status: SCOPES.some(s => s.includes('forms')) ? 'configured' : 'no_scope',
319
- };
320
-
321
- // Docs/Sheets — covered by Drive auth
322
- report.services.docs = { status: report.services.drive.status === 'ok' ? 'ok (via Drive auth)' : 'unknown' };
323
- report.services.sheets = { status: report.services.drive.status === 'ok' ? 'ok (via Drive auth)' : 'unknown' };
324
- } else {
325
- report.services = { note: 'Skipped — auth not available' };
326
- }
327
-
328
- // --- Config summary ---
329
- const configDir = getConfigDir();
330
- report.config = {
331
- configDir,
332
- profile: process.env.GOOGLE_MCP_PROFILE || '(default)',
333
- tokenPath,
334
- credentialSource: process.env.GOOGLE_CLIENT_ID ? 'environment' : 'file',
335
- scopes: SCOPES,
336
- logFile: process.env.GOOGLE_MCP_LOG_FILE || '(not set)',
337
- };
338
-
339
- // --- Recent logs ---
340
- const logFilePath = process.env.GOOGLE_MCP_LOG_FILE === '1'
341
- ? path.join(configDir, 'server.log')
342
- : process.env.GOOGLE_MCP_LOG_FILE;
343
- if (logFilePath) {
344
- try {
345
- const logContent = await fs.readFile(logFilePath, 'utf8');
346
- const lines = logContent.trimEnd().split('\n');
347
- report.recentLogs = lines.slice(-20);
348
- } catch (err) {
349
- report.recentLogs = err.code === 'ENOENT' ? '(log file not found)' : `(error reading log: ${err.message})`;
350
- }
351
- } else {
352
- report.recentLogs = '(file logging not enabled — set GOOGLE_MCP_LOG_FILE to enable)';
353
- }
354
-
355
- // --- Environment ---
356
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
357
- let pkgVersion = 'unknown';
358
- try {
359
- const pkgPath = path.resolve(__dirname, '..', '..', 'package.json');
360
- const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8'));
361
- pkgVersion = pkg.version;
362
- } catch {}
363
- report.environment = {
364
- serverVersion: pkgVersion,
365
- nodeVersion: process.version,
366
- platform: process.platform,
367
- osRelease: os.release(),
368
- arch: process.arch,
369
- };
370
-
371
- return JSON.stringify(report, null, 2);
372
- },
373
- });
374
-
375
- // --- Feedback tool (always available) ---
376
- server.addTool({
377
- name: 'feedback',
378
- description:
379
- 'Submit feedback or a bug report for google-tools-mcp. Automatically collects diagnostic info, then files the issue via the GitHub CLI (`gh`) if available, or falls back to opening a pre-filled GitHub issue URL in the user\'s browser.',
380
- parameters: z.object({
381
- type: z.enum(['bug', 'feature']).describe('Type of feedback'),
382
- title: z.string().describe('Short summary'),
383
- description: z.string().describe('Detailed description of the issue or feature request'),
384
- }),
385
- execute: async (args) => {
386
- // Collect diagnostics
387
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
388
- let pkgVersion = 'unknown';
389
- try {
390
- const pkgPath = path.resolve(__dirname, '..', '..', 'package.json');
391
- const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8'));
392
- pkgVersion = pkg.version;
393
- } catch {}
394
-
395
- let authStatus = 'unknown';
396
- const tokenPath = getTokenPath();
397
- try {
398
- await fs.access(tokenPath);
399
- const client = getAuthClientIfReady();
400
- if (client) {
401
- try {
402
- await client.refreshAccessToken();
403
- authStatus = 'valid';
404
- } catch {
405
- authStatus = 'expired_or_revoked';
406
- }
407
- } else {
408
- authStatus = 'not_initialized';
409
- }
410
- } catch {
411
- authStatus = 'missing';
412
- }
413
-
414
- const enabledScopes = SCOPES.map(s => s.split('/').pop());
415
-
416
- // Build markdown body
417
- const diagnostics = [
418
- `- **Server version:** ${pkgVersion}`,
419
- `- **Node version:** ${process.version}`,
420
- `- **OS:** ${process.platform} ${os.release()} (${process.arch})`,
421
- `- **Auth status:** ${authStatus}`,
422
- `- **Scopes:** ${enabledScopes.join(', ')}`,
423
- ].join('\n');
424
-
425
- const body = [
426
- `## Description`,
427
- ``,
428
- args.description,
429
- ``,
430
- `<details>`,
431
- `<summary>Diagnostic Info</summary>`,
432
- ``,
433
- diagnostics,
434
- ``,
435
- `</details>`,
436
- ].join('\n');
437
-
438
- const label = args.type === 'bug' ? 'bug' : 'enhancement';
439
-
440
- // Try gh CLI first
441
- const ghResult = await tryGhCli(args.title, body, label);
442
- if (ghResult.ok) {
443
- return JSON.stringify({
444
- method: 'gh-cli',
445
- issueUrl: ghResult.issueUrl,
446
- note: 'Issue filed successfully via GitHub CLI.',
447
- }, null, 2);
448
- }
449
-
450
- // Fallback: open pre-filled GitHub issue URL in the user's browser
451
- const params = new URLSearchParams({
452
- title: args.title,
453
- body,
454
- labels: label,
455
- });
456
- const url = `https://github.com/${REPO}/issues/new?${params.toString()}`;
457
- const opened = await openBrowser(url);
458
-
459
- return JSON.stringify({
460
- method: 'browser-fallback',
461
- ghCliUnavailableReason: ghResult.reason,
462
- url,
463
- browserOpened: opened,
464
- markdown: body,
465
- note: url.length > 8000
466
- ? 'The URL may be too long for some browsers. Use the markdown body to create the issue manually.'
467
- : opened
468
- ? 'Opened the pre-filled GitHub issue in your browser. Click "Submit new issue" to file it.'
469
- : 'Could not auto-open browser. Please open the URL manually.',
470
- }, null, 2);
471
- },
472
- });
473
- }
1
+ // Tool registration — all categories loaded eagerly at startup.
2
+ // (Claude Code doesn't support notifications/tools/list_changed,
3
+ // so lazy-loading doesn't work.)
4
+ import { z } from 'zod';
5
+ import * as fs from 'fs/promises';
6
+ import * as path from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import * as os from 'os';
9
+ import { exec } from 'child_process';
10
+ import { promisify } from 'util';
11
+ import { getTokenPath, getConfigDir, SCOPES } from '../auth.js';
12
+ import { resetClients, withAuthRetry, getAuthClientIfReady } from '../clients.js';
13
+ import { logger } from '../logger.js';
14
+ import { runWithSession } from '../sessionContext.js';
15
+ import { google } from 'googleapis';
16
+ import { registerLegacyAliases } from './legacyAliases.js';
17
+
18
+ const execAsync = promisify(exec);
19
+
20
+ const REPO = 'karthikcsq/google-tools-mcp';
21
+
22
+ async function tryGhCli(title, body, label) {
23
+ // Probe for gh CLI
24
+ try {
25
+ await execAsync('gh --version');
26
+ } catch {
27
+ return { ok: false, reason: 'gh CLI not installed' };
28
+ }
29
+ // Probe for auth
30
+ try {
31
+ await execAsync('gh auth status');
32
+ } catch {
33
+ return { ok: false, reason: 'gh CLI not authenticated (run: gh auth login)' };
34
+ }
35
+ // Write body to a temp file to avoid shell-escaping issues with newlines/quotes.
36
+ const tmpFile = path.join(os.tmpdir(), `gtm-feedback-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
37
+ try {
38
+ await fs.writeFile(tmpFile, body, 'utf8');
39
+ const { stdout } = await execAsync(
40
+ `gh issue create --repo ${REPO} --title ${JSON.stringify(title)} --label ${JSON.stringify(label)} --body-file ${JSON.stringify(tmpFile)}`,
41
+ { maxBuffer: 10 * 1024 * 1024 }
42
+ );
43
+ const issueUrl = stdout.trim().split('\n').pop();
44
+ return { ok: true, issueUrl };
45
+ } catch (err) {
46
+ return { ok: false, reason: `gh CLI failed: ${err.stderr || err.message || err}` };
47
+ } finally {
48
+ try { await fs.unlink(tmpFile); } catch {}
49
+ }
50
+ }
51
+
52
+ function openBrowser(url) {
53
+ const platform = process.platform;
54
+ let cmd;
55
+ if (platform === 'win32') {
56
+ cmd = `start "" "${url}"`;
57
+ } else if (platform === 'darwin') {
58
+ cmd = `open "${url}"`;
59
+ } else {
60
+ cmd = `xdg-open "${url}"`;
61
+ }
62
+ return new Promise((resolve) => {
63
+ exec(cmd, (err) => resolve(!err));
64
+ });
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Wrap server.addTool so every tool's execute() auto-retries on invalid_grant
69
+ // and appends a troubleshoot/feedback hint to errors.
70
+ // ---------------------------------------------------------------------------
71
+ const ERROR_HINT =
72
+ '\n\nIf this error is unexpected or unclear, you can:\n' +
73
+ ' • Call the `troubleshoot` tool to run a health check (auth, API connectivity, recent logs).\n' +
74
+ ' • Call the `feedback` tool to file a bug report with diagnostics auto-attached.';
75
+
76
+ // Tools that should NOT have the hint appended (would be circular/noisy).
77
+ const HINT_EXCLUDED_TOOLS = new Set(['troubleshoot', 'feedback', 'help', 'logout']);
78
+
79
+ function appendHintToError(error, toolName) {
80
+ if (HINT_EXCLUDED_TOOLS.has(toolName)) return error;
81
+ if (!error) return error;
82
+ // Avoid double-appending if something else (or a retry) already added it.
83
+ const existingMsg = error.message || '';
84
+ if (existingMsg.includes('`troubleshoot` tool')) return error;
85
+ try {
86
+ error.message = existingMsg + ERROR_HINT;
87
+ } catch {
88
+ // Some error types have non-writable message; fall back to a new error.
89
+ const wrapped = new Error(existingMsg + ERROR_HINT);
90
+ wrapped.cause = error;
91
+ return wrapped;
92
+ }
93
+ return error;
94
+ }
95
+
96
+ function wrapServerWithAuthRetry(server) {
97
+ const originalAddTool = server.addTool.bind(server);
98
+ server.addTool = function (toolDef) {
99
+ const originalExecute = toolDef.execute;
100
+ const toolName = toolDef.name;
101
+ if (originalExecute) {
102
+ toolDef.execute = async function (...args) {
103
+ // Bind this request's MCP session so per-session state (e.g. the
104
+ // read-before-edit tracker) is isolated across concurrent HTTP
105
+ // clients. context.sessionId is undefined for stdio (single
106
+ // client), which maps to the default namespace. (PR #36 review)
107
+ const sessionKey = args[1]?.sessionId ?? null;
108
+ return runWithSession(sessionKey, async () => {
109
+ try {
110
+ return await withAuthRetry(() => originalExecute.apply(this, args));
111
+ } catch (err) {
112
+ throw appendHintToError(err, toolName);
113
+ }
114
+ });
115
+ };
116
+ }
117
+ return originalAddTool(toolDef);
118
+ };
119
+ return server;
120
+ }
121
+
122
+ // --- Category registry ---
123
+ const CATEGORIES = {
124
+ files: {
125
+ async loader(server) {
126
+ const { registerDriveTools } = await import('./drive/index.js');
127
+ const { registerExtrasTools } = await import('./extras/index.js');
128
+ registerDriveTools(server);
129
+ registerExtrasTools(server);
130
+ },
131
+ },
132
+ documents: {
133
+ async loader(server) {
134
+ const { registerDocsTools } = await import('./docs/index.js');
135
+ const { registerUtilsTools } = await import('./utils/index.js');
136
+ registerDocsTools(server);
137
+ registerUtilsTools(server);
138
+ },
139
+ },
140
+ spreadsheets: {
141
+ async loader(server) {
142
+ const { registerSheetsTools } = await import('./sheets/index.js');
143
+ registerSheetsTools(server);
144
+ },
145
+ },
146
+ email: {
147
+ async loader(server) {
148
+ const { register: registerMessages } = await import('./gmail/messages.js');
149
+ const { register: registerDrafts } = await import('./gmail/drafts.js');
150
+ registerMessages(server);
151
+ registerDrafts(server);
152
+ },
153
+ },
154
+ email_threads: {
155
+ async loader(server) {
156
+ const { register } = await import('./gmail/threads.js');
157
+ register(server);
158
+ },
159
+ },
160
+ email_labels: {
161
+ async loader(server) {
162
+ const { register } = await import('./gmail/labels.js');
163
+ register(server);
164
+ },
165
+ },
166
+ email_settings: {
167
+ async loader(server) {
168
+ const { register } = await import('./gmail/settings.js');
169
+ register(server);
170
+ },
171
+ },
172
+ calendar: {
173
+ async loader(server) {
174
+ const { registerCalendarTools } = await import('./calendar/index.js');
175
+ registerCalendarTools(server);
176
+ },
177
+ },
178
+ forms: {
179
+ async loader(server) {
180
+ const { registerFormsTools } = await import('./forms/index.js');
181
+ registerFormsTools(server);
182
+ },
183
+ },
184
+ slides: {
185
+ async loader(server) {
186
+ const { registerSlidesTools } = await import('./slides/index.js');
187
+ registerSlidesTools(server);
188
+ },
189
+ },
190
+ tasks: {
191
+ async loader(server) {
192
+ const { registerTasksTools } = await import('./tasks/index.js');
193
+ registerTasksTools(server);
194
+ },
195
+ },
196
+ maps: {
197
+ async loader(server) {
198
+ const { registerMapsTools } = await import('./maps/index.js');
199
+ registerMapsTools(server);
200
+ },
201
+ },
202
+ };
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Public: register all tools eagerly, plus the logout utility.
206
+ // ---------------------------------------------------------------------------
207
+ export async function registerAllTools(server) {
208
+ // Wrap server so every tool auto-retries on invalid_grant (expired refresh token)
209
+ const wrappedServer = wrapServerWithAuthRetry(server);
210
+
211
+ // Capture each registered tool so the legacy alias layer can look up the
212
+ // new tools' implementations to forward to. We snapshot a shallow copy of
213
+ // toolDef *before* handing it to wrappedAddTool: wrapServerWithAuthRetry
214
+ // mutates toolDef.execute in place to add the auth-retry wrapper, and since
215
+ // registeredTools would otherwise hold a reference to that same (mutated)
216
+ // object, aliases would forward to an already-retry-wrapped execute. Then,
217
+ // because each alias is itself registered through this same addTool (which
218
+ // gets retry-wrapped again), a persistent invalid_grant would invoke the
219
+ // real handler up to 4x and reauthorize repeatedly instead of the
220
+ // documented single retry. The snapshot keeps the map pointing at the raw,
221
+ // unwrapped implementation so the outer (alias-level) wrapper is the only
222
+ // retry layer applied.
223
+ const registeredTools = new Map();
224
+ const wrappedAddTool = wrappedServer.addTool.bind(wrappedServer);
225
+ wrappedServer.addTool = function (toolDef) {
226
+ registeredTools.set(toolDef.name, { ...toolDef });
227
+ return wrappedAddTool(toolDef);
228
+ };
229
+
230
+ // Load every category
231
+ for (const [name, { loader }] of Object.entries(CATEGORIES)) {
232
+ await loader(wrappedServer);
233
+ }
234
+ logger.info(`Loaded all ${Object.keys(CATEGORIES).length} categories at startup.`);
235
+
236
+ // Register backward-compatible snake_case aliases for the renamed/consolidated
237
+ // tools. Opt-in: set GOOGLE_MCP_ENABLE_LEGACY_ALIASES=true to register them.
238
+ registerLegacyAliases(wrappedServer, registeredTools);
239
+
240
+ // --- Help tool (always available) ---
241
+ server.addTool({
242
+ name: 'help',
243
+ description:
244
+ 'Show documentation for google-tools-mcp: setup instructions, available tool categories, environment variables, and troubleshooting. ' +
245
+ 'Call this when you need guidance on how to use the Google Workspace tools. ' +
246
+ 'Also available: `troubleshoot` (run health check when tools fail) and `feedback` (submit bug reports/feature requests with diagnostics auto-attached).',
247
+ parameters: z.object({}),
248
+ execute: async () => {
249
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
250
+ const readmePath = path.resolve(__dirname, '..', '..', 'README.md');
251
+ const diagnosticsSection = '\n\n## Diagnostics & Feedback\n\n' +
252
+ '- **`troubleshoot`** — Run a health check when tools fail (checks auth, API connectivity, config, recent logs).\n' +
253
+ '- **`feedback`** — Submit a bug report or feature request with diagnostics auto-attached (files via GitHub CLI or browser).\n' +
254
+ '- **`help`** Show this documentation.\n';
255
+ try {
256
+ const readme = await fs.readFile(readmePath, 'utf-8');
257
+ return readme + diagnosticsSection;
258
+ } catch {
259
+ return 'README not found. Visit https://www.npmjs.com/package/google-tools-mcp for documentation.' + diagnosticsSection;
260
+ }
261
+ },
262
+ });
263
+
264
+ // --- Logout tool (always available) ---
265
+ server.addTool({
266
+ name: 'logout',
267
+ description:
268
+ 'Log out of the current Google account by deleting the saved OAuth token. The next tool call will require re-authentication.',
269
+ parameters: z.object({}),
270
+ execute: async () => {
271
+ const tokenPath = getTokenPath();
272
+ try {
273
+ await fs.unlink(tokenPath);
274
+ } catch (err) {
275
+ if (err.code !== 'ENOENT') throw err;
276
+ }
277
+ resetClients();
278
+ return JSON.stringify({
279
+ success: true,
280
+ message: 'Logged out. The next tool call will require re-authentication.',
281
+ });
282
+ },
283
+ });
284
+
285
+ // --- Troubleshoot tool (always available) ---
286
+ server.addTool({
287
+ name: 'troubleshoot',
288
+ description:
289
+ 'Run a health check on google-tools-mcp: verify OAuth token, test API connectivity, show config and recent logs. Call this when tools are failing or behaving unexpectedly.',
290
+ parameters: z.object({}),
291
+ execute: async () => {
292
+ const report = { auth: {}, services: {}, config: {}, recentLogs: null, environment: {} };
293
+
294
+ // --- Auth status ---
295
+ const tokenPath = getTokenPath();
296
+ try {
297
+ const tokenContent = await fs.readFile(tokenPath, 'utf8');
298
+ const token = JSON.parse(tokenContent);
299
+ report.auth.tokenFile = 'present';
300
+ report.auth.hasRefreshToken = !!token.refresh_token;
301
+ report.auth.type = token.type || 'unknown';
302
+ } catch (err) {
303
+ report.auth.tokenFile = err.code === 'ENOENT' ? 'missing' : 'unreadable';
304
+ report.auth.hasRefreshToken = false;
305
+ }
306
+
307
+ // Try refreshing token
308
+ const client = getAuthClientIfReady();
309
+ if (client) {
310
+ try {
311
+ const { credentials } = await client.refreshAccessToken();
312
+ client.setCredentials(credentials);
313
+ report.auth.status = 'valid';
314
+ report.auth.expiry = credentials.expiry_date
315
+ ? new Date(credentials.expiry_date).toISOString()
316
+ : 'unknown';
317
+ } catch (err) {
318
+ report.auth.status = 'expired_or_revoked';
319
+ report.auth.refreshError = err.message;
320
+ }
321
+ } else {
322
+ report.auth.status = report.auth.tokenFile === 'present' ? 'not_initialized' : 'missing';
323
+ }
324
+
325
+ // --- Service probes ---
326
+ if (client && report.auth.status === 'valid') {
327
+ // Drive
328
+ try {
329
+ const drive = google.drive({ version: 'v3', auth: client });
330
+ const res = await drive.about.get({ fields: 'user' });
331
+ report.services.drive = { status: 'ok', account: res.data.user?.emailAddress || 'unknown' };
332
+ } catch (err) {
333
+ report.services.drive = { status: 'error', error: err.message };
334
+ }
335
+
336
+ // Gmail
337
+ try {
338
+ const gmail = google.gmail({ version: 'v1', auth: client });
339
+ const res = await gmail.users.getProfile({ userId: 'me' });
340
+ report.services.gmail = { status: 'ok', email: res.data.emailAddress || 'unknown' };
341
+ } catch (err) {
342
+ report.services.gmail = { status: 'error', error: err.message };
343
+ }
344
+
345
+ // Calendar
346
+ try {
347
+ const calendar = google.calendar({ version: 'v3', auth: client });
348
+ await calendar.calendarList.list({ maxResults: 1 });
349
+ report.services.calendar = { status: 'ok' };
350
+ } catch (err) {
351
+ report.services.calendar = { status: 'error', error: err.message };
352
+ }
353
+
354
+ // Forms — just check scope presence
355
+ report.services.forms = {
356
+ status: SCOPES.some(s => s.includes('forms')) ? 'configured' : 'no_scope',
357
+ };
358
+
359
+ // Docs/Sheets covered by Drive auth
360
+ report.services.docs = { status: report.services.drive.status === 'ok' ? 'ok (via Drive auth)' : 'unknown' };
361
+ report.services.sheets = { status: report.services.drive.status === 'ok' ? 'ok (via Drive auth)' : 'unknown' };
362
+ } else {
363
+ report.services = { note: 'Skipped — auth not available' };
364
+ }
365
+
366
+ // --- Config summary ---
367
+ const configDir = getConfigDir();
368
+ report.config = {
369
+ configDir,
370
+ profile: process.env.GOOGLE_MCP_PROFILE || '(default)',
371
+ tokenPath,
372
+ credentialSource: process.env.GOOGLE_CLIENT_ID ? 'environment' : 'file',
373
+ scopes: SCOPES,
374
+ logFile: process.env.GOOGLE_MCP_LOG_FILE || '(not set)',
375
+ };
376
+
377
+ // --- Recent logs ---
378
+ const logFilePath = process.env.GOOGLE_MCP_LOG_FILE === '1'
379
+ ? path.join(configDir, 'server.log')
380
+ : process.env.GOOGLE_MCP_LOG_FILE;
381
+ if (logFilePath) {
382
+ try {
383
+ const logContent = await fs.readFile(logFilePath, 'utf8');
384
+ const lines = logContent.trimEnd().split('\n');
385
+ report.recentLogs = lines.slice(-20);
386
+ } catch (err) {
387
+ report.recentLogs = err.code === 'ENOENT' ? '(log file not found)' : `(error reading log: ${err.message})`;
388
+ }
389
+ } else {
390
+ report.recentLogs = '(file logging not enabled — set GOOGLE_MCP_LOG_FILE to enable)';
391
+ }
392
+
393
+ // --- Environment ---
394
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
395
+ let pkgVersion = 'unknown';
396
+ try {
397
+ const pkgPath = path.resolve(__dirname, '..', '..', 'package.json');
398
+ const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8'));
399
+ pkgVersion = pkg.version;
400
+ } catch {}
401
+ report.environment = {
402
+ serverVersion: pkgVersion,
403
+ nodeVersion: process.version,
404
+ platform: process.platform,
405
+ osRelease: os.release(),
406
+ arch: process.arch,
407
+ };
408
+
409
+ return JSON.stringify(report, null, 2);
410
+ },
411
+ });
412
+
413
+ // --- Feedback tool (always available) ---
414
+ server.addTool({
415
+ name: 'feedback',
416
+ description:
417
+ 'Submit feedback or a bug report for google-tools-mcp. Automatically collects diagnostic info, then files the issue via the GitHub CLI (`gh`) if available, or falls back to opening a pre-filled GitHub issue URL in the user\'s browser.',
418
+ parameters: z.object({
419
+ type: z.enum(['bug', 'feature']).describe('Type of feedback'),
420
+ title: z.string().describe('Short summary'),
421
+ description: z.string().describe('Detailed description of the issue or feature request'),
422
+ }),
423
+ execute: async (args) => {
424
+ // Collect diagnostics
425
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
426
+ let pkgVersion = 'unknown';
427
+ try {
428
+ const pkgPath = path.resolve(__dirname, '..', '..', 'package.json');
429
+ const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8'));
430
+ pkgVersion = pkg.version;
431
+ } catch {}
432
+
433
+ let authStatus = 'unknown';
434
+ const tokenPath = getTokenPath();
435
+ try {
436
+ await fs.access(tokenPath);
437
+ const client = getAuthClientIfReady();
438
+ if (client) {
439
+ try {
440
+ await client.refreshAccessToken();
441
+ authStatus = 'valid';
442
+ } catch {
443
+ authStatus = 'expired_or_revoked';
444
+ }
445
+ } else {
446
+ authStatus = 'not_initialized';
447
+ }
448
+ } catch {
449
+ authStatus = 'missing';
450
+ }
451
+
452
+ const enabledScopes = SCOPES.map(s => s.split('/').pop());
453
+
454
+ // Build markdown body
455
+ const diagnostics = [
456
+ `- **Server version:** ${pkgVersion}`,
457
+ `- **Node version:** ${process.version}`,
458
+ `- **OS:** ${process.platform} ${os.release()} (${process.arch})`,
459
+ `- **Auth status:** ${authStatus}`,
460
+ `- **Scopes:** ${enabledScopes.join(', ')}`,
461
+ ].join('\n');
462
+
463
+ const body = [
464
+ `## Description`,
465
+ ``,
466
+ args.description,
467
+ ``,
468
+ `<details>`,
469
+ `<summary>Diagnostic Info</summary>`,
470
+ ``,
471
+ diagnostics,
472
+ ``,
473
+ `</details>`,
474
+ ].join('\n');
475
+
476
+ const label = args.type === 'bug' ? 'bug' : 'enhancement';
477
+
478
+ // Try gh CLI first
479
+ const ghResult = await tryGhCli(args.title, body, label);
480
+ if (ghResult.ok) {
481
+ return JSON.stringify({
482
+ method: 'gh-cli',
483
+ issueUrl: ghResult.issueUrl,
484
+ note: 'Issue filed successfully via GitHub CLI.',
485
+ }, null, 2);
486
+ }
487
+
488
+ // Fallback: open pre-filled GitHub issue URL in the user's browser
489
+ const params = new URLSearchParams({
490
+ title: args.title,
491
+ body,
492
+ labels: label,
493
+ });
494
+ const url = `https://github.com/${REPO}/issues/new?${params.toString()}`;
495
+ const opened = await openBrowser(url);
496
+
497
+ return JSON.stringify({
498
+ method: 'browser-fallback',
499
+ ghCliUnavailableReason: ghResult.reason,
500
+ url,
501
+ browserOpened: opened,
502
+ markdown: body,
503
+ note: url.length > 8000
504
+ ? 'The URL may be too long for some browsers. Use the markdown body to create the issue manually.'
505
+ : opened
506
+ ? 'Opened the pre-filled GitHub issue in your browser. Click "Submit new issue" to file it.'
507
+ : 'Could not auto-open browser. Please open the URL manually.',
508
+ }, null, 2);
509
+ },
510
+ });
511
+ }