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
@@ -7,23 +7,57 @@ import { UserError } from 'fastmcp';
7
7
  import { createPatch } from 'diff';
8
8
  import { getDriveClient } from './clients.js';
9
9
  import { logger } from './logger.js';
10
+ import { currentSessionKey } from './sessionContext.js';
10
11
 
11
- // Map of fileId → { readAt: Date, modifiedTime: string|null, content: string|null }
12
- // content is only populated for file types that opt in (currently Google Docs).
13
- // Sheets and raw Drive files still track read/modifiedTime only.
14
- const readLog = new Map();
12
+ // Per-session read logs.
13
+ //
14
+ // Each session has its own Map of fileId → { readAt, modifiedTime, content }.
15
+ // content is only populated for file types that opt in (currently Google Docs);
16
+ // Sheets and raw Drive files track read/modifiedTime only.
17
+ //
18
+ // Namespacing by session is what makes shared HTTP mode safe: two clients
19
+ // reading or mutating the same file no longer clobber each other's tracked
20
+ // content, modifiedTime, and revision guard (PR #36 review). In stdio mode the
21
+ // ambient session key is null, so there is a single namespace and behavior is
22
+ // identical to the original single-Map implementation.
23
+ const sessions = new Map(); // sessionKey (string|null) → Map<fileId, entry>
24
+
25
+ // Sentinel for the stdio / no-request namespace (Map keys can't be null-safe
26
+ // across distinct absent values, so normalize null → this string).
27
+ const DEFAULT_SESSION = '\0default';
28
+
29
+ function logForCurrentSession() {
30
+ const key = currentSessionKey() ?? DEFAULT_SESSION;
31
+ let log = sessions.get(key);
32
+ if (!log) {
33
+ log = new Map();
34
+ sessions.set(key, log);
35
+ }
36
+ return log;
37
+ }
38
+
39
+ /**
40
+ * Drop all tracked state for a session. Called when an HTTP session disconnects
41
+ * so a long-lived shared server doesn't accumulate tracker entries forever.
42
+ * @param {string|null|undefined} sessionKey
43
+ */
44
+ export function clearSession(sessionKey) {
45
+ sessions.delete(sessionKey ?? DEFAULT_SESSION);
46
+ }
15
47
 
16
48
  /**
17
49
  * Record that a file was read. Call from all read tools.
18
50
  * @param fileId
19
51
  * @param modifiedTime Drive API modifiedTime (ISO string) at read time, or null
20
52
  * @param content Optional content snapshot (e.g. markdown for docs) used for diffs
53
+ * @param revisionId Optional Google Docs revision ID used for optimistic concurrency
21
54
  */
22
- export function trackRead(fileId, modifiedTime, content) {
23
- readLog.set(fileId, {
55
+ export function trackRead(fileId, modifiedTime, content, revisionId) {
56
+ logForCurrentSession().set(fileId, {
24
57
  readAt: new Date(),
25
58
  modifiedTime: modifiedTime || null,
26
59
  content: typeof content === 'string' ? content : null,
60
+ revisionId: typeof revisionId === 'string' ? revisionId : null,
27
61
  });
28
62
  }
29
63
 
@@ -33,12 +67,19 @@ export function trackRead(fileId, modifiedTime, content) {
33
67
  *
34
68
  * @param fileId - The file/document/spreadsheet ID
35
69
  * @param opts.skipExternalCheck - If true, skip the Drive API modifiedTime check (for performance)
36
- * @param opts.contentFetcher - Optional async () => string. If provided and an
37
- * external-change conflict is detected, the fetcher is used to grab current
38
- * content and the UserError message will include a unified diff plus rebase
39
- * instructions rather than a plain error.
70
+ * @param opts.contentFetcher - Optional async () => (string | { content: string, revisionId?: string|null }).
71
+ * If provided and an external-change conflict is detected, the fetcher is used to grab current
72
+ * content (and, for Google Docs, the revision that content came from) and the UserError message
73
+ * will include a unified diff plus rebase instructions rather than a plain error. When the fetcher
74
+ * returns an object with `revisionId`, that revision is stored as the new baseline atomically with
75
+ * the content/modifiedTime refresh below, so a subsequent rebased write is guarded against the
76
+ * version the diff was actually taken from rather than the pre-external-edit revision. When the
77
+ * fetcher returns a bare string (or omits revisionId), the revision can't be known to be current,
78
+ * so it is cleared instead of left stale — the next write then goes out unguarded rather than with
79
+ * a guaranteed-stale requiredRevisionId.
40
80
  */
41
81
  export async function guardMutation(fileId, opts) {
82
+ const readLog = logForCurrentSession();
42
83
  const entry = readLog.get(fileId);
43
84
  if (!entry) {
44
85
  throw new UserError(
@@ -48,6 +89,19 @@ export async function guardMutation(fileId, opts) {
48
89
  );
49
90
  }
50
91
 
92
+ // A previous write left the document in a state we can't describe: no
93
+ // revision to guard against and no content snapshot to diff. Refuse rather
94
+ // than let this write go out against a stale baseline. The modifiedTime
95
+ // check below cannot cover this, because it is skipped when modifiedTime
96
+ // is null and would just re-baseline on the post-write value.
97
+ if (entry.requiresReread) {
98
+ throw new UserError(
99
+ `This file (${fileId}) must be read again before it can be edited: ${entry.requiresReread} ` +
100
+ 'Read it again (readDocument, readSpreadsheet, readFile, or readDriveFile) and rebase your edit ' +
101
+ 'on the current content.'
102
+ );
103
+ }
104
+
51
105
  // Optionally check if file was modified externally since last read
52
106
  if (!opts?.skipExternalCheck) {
53
107
  try {
@@ -65,8 +119,22 @@ export async function guardMutation(fileId, opts) {
65
119
  // model can rebase its edit on top of the new version.
66
120
  if (entry.content && typeof opts?.contentFetcher === 'function') {
67
121
  let currentContent;
122
+ let currentRevisionId;
68
123
  try {
69
- currentContent = await opts.contentFetcher();
124
+ const fetched = await opts.contentFetcher();
125
+ if (fetched && typeof fetched === 'object') {
126
+ currentContent = fetched.content;
127
+ currentRevisionId = typeof fetched.revisionId === 'string' ? fetched.revisionId : null;
128
+ } else {
129
+ currentContent = fetched;
130
+ // A bare-string fetcher can't tell us the revision the
131
+ // content came from. Clear rather than keep the
132
+ // pre-external-edit revisionId around: a cleared
133
+ // revision sends the next write out unguarded, which
134
+ // is safe; a stale one sends it out with a
135
+ // requiredRevisionId that is guaranteed to conflict.
136
+ currentRevisionId = null;
137
+ }
70
138
  } catch (fetchError) {
71
139
  logger.warn(`contentFetcher failed for ${fileId}: ${fetchError.message}`);
72
140
  }
@@ -79,10 +147,16 @@ export async function guardMutation(fileId, opts) {
79
147
  'current',
80
148
  { context: 3 }
81
149
  );
82
- // Refresh the snapshot so a subsequent read/mutation
83
- // works against the new baseline.
150
+ // Refresh content, modifiedTime, AND revisionId together
151
+ // (atomically, in the same tick) so a subsequent rebased
152
+ // write is guarded against the version this diff was
153
+ // actually taken from — not the pre-external-edit
154
+ // revision, which would otherwise cause a second,
155
+ // confusing conflict even when the caller correctly
156
+ // rebuilt its edit from the diff above.
84
157
  entry.content = currentContent;
85
158
  entry.modifiedTime = currentModifiedTime;
159
+ entry.revisionId = currentRevisionId;
86
160
  throw new UserError(
87
161
  `This file was modified externally since you last read it ` +
88
162
  `(last read: ${readAt}, last modified: ${currentModifiedTime}).\n\n` +
@@ -115,9 +189,22 @@ export async function guardMutation(fileId, opts) {
115
189
  /**
116
190
  * Update the read tracker after a successful mutation (so subsequent mutations
117
191
  * don't fail the external-change check against our own changes).
192
+ *
193
+ * @param fileId
194
+ * @param newRevisionId Optional Google Docs revision ID produced by the write
195
+ * that just succeeded (the API's batchUpdate response echoes this back as
196
+ * `writeControl.requiredRevisionId` — "the updated write control after
197
+ * applying the request"). When provided, the WriteControl guard is
198
+ * re-armed against this fresh revision instead of being disabled, so a
199
+ * second write to the same document later in the same session (with no
200
+ * re-read in between) is still guarded against a genuine concurrent edit —
201
+ * rather than either (a) silently going out unguarded, or (b) reusing the
202
+ * now-stale pre-mutation revision and spuriously conflicting with our own
203
+ * prior write. When omitted, the revision is cleared (legacy/unknown
204
+ * behavior, same as before).
118
205
  */
119
- export function trackMutation(fileId) {
120
- const entry = readLog.get(fileId);
206
+ export function trackMutation(fileId, newRevisionId) {
207
+ const entry = logForCurrentSession().get(fileId);
121
208
  if (entry) {
122
209
  entry.readAt = new Date();
123
210
  // Clear modifiedTime — it will be stale after our mutation.
@@ -126,6 +213,33 @@ export function trackMutation(fileId) {
126
213
  // Content is also stale after our mutation; clear so a future diff
127
214
  // doesn't show our own edits as "external" changes.
128
215
  entry.content = null;
216
+ entry.revisionId = typeof newRevisionId === 'string' ? newRevisionId : null;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Mark a file as needing a fresh read before any further mutation.
222
+ *
223
+ * Use this instead of trackMutation after a write whose resulting revision we
224
+ * cannot learn (an Apps Script call that edits the document outside our
225
+ * batchUpdate visibility, for example). trackMutation on its own clears the
226
+ * revision and the modifiedTime, which leaves the next write with nothing to
227
+ * guard against: it sends no writeControl and the external-change check is
228
+ * skipped because modifiedTime is null. That write would then be based on a
229
+ * pre-mutation read without anyone noticing. Blocking it and asking for a
230
+ * re-read is the only safe option when the post-write revision is unknown.
231
+ *
232
+ * @param fileId
233
+ * @param reason Sentence fragment explaining what happened, shown to the caller.
234
+ */
235
+ export function requireRereadBeforeMutation(fileId, reason) {
236
+ const entry = logForCurrentSession().get(fileId);
237
+ if (entry) {
238
+ entry.readAt = new Date();
239
+ entry.modifiedTime = null;
240
+ entry.content = null;
241
+ entry.revisionId = null;
242
+ entry.requiresReread = reason || 'a previous write changed it in a way we could not track.';
129
243
  }
130
244
  }
131
245
 
@@ -133,13 +247,18 @@ export function trackMutation(fileId) {
133
247
  * Check if a file has been read (without throwing).
134
248
  */
135
249
  export function hasBeenRead(fileId) {
136
- return readLog.has(fileId);
250
+ return logForCurrentSession().has(fileId);
137
251
  }
138
252
 
139
253
  /**
140
254
  * Return the content snapshot from the last read, or null if none stored.
141
255
  */
142
256
  export function getLastReadContent(fileId) {
143
- const entry = readLog.get(fileId);
257
+ const entry = logForCurrentSession().get(fileId);
144
258
  return entry?.content ?? null;
145
259
  }
260
+
261
+ /** Return the Google Docs revision ID from the last read, or null. */
262
+ export function getLastReadRevisionId(fileId) {
263
+ return logForCurrentSession().get(fileId)?.revisionId ?? null;
264
+ }
@@ -0,0 +1,37 @@
1
+ // Per-request session scoping.
2
+ //
3
+ // In stdio mode there is exactly one client per process, so module-global state
4
+ // (e.g. the read-before-edit tracker in readTracker.js) is implicitly
5
+ // per-client. In shared HTTP mode a single process serves many clients, so that
6
+ // same global state would leak across clients — one client's "I read this doc"
7
+ // would satisfy another client's mutation guard, and their content snapshots
8
+ // would clobber each other (see PR #36 review).
9
+ //
10
+ // This module carries the current request's session key ambiently via
11
+ // AsyncLocalStorage so stateful helpers can namespace their data per session
12
+ // without every tool having to thread a session id through its call sites.
13
+ // Outside a request (stdio, startup) the key is null, which maps to a single
14
+ // default namespace — preserving the original single-client behavior exactly.
15
+ import { AsyncLocalStorage } from 'node:async_hooks';
16
+
17
+ const sessionStore = new AsyncLocalStorage();
18
+
19
+ /**
20
+ * Run `fn` with `sessionKey` bound as the ambient session for the duration of
21
+ * the (possibly async) call. Returns whatever `fn` returns.
22
+ * @param {string|null|undefined} sessionKey
23
+ * @param {() => any} fn
24
+ */
25
+ export function runWithSession(sessionKey, fn) {
26
+ return sessionStore.run(sessionKey ?? null, fn);
27
+ }
28
+
29
+ /**
30
+ * The current ambient session key, or null when running outside a request
31
+ * (stdio transport, server startup). null is a valid namespace key.
32
+ * @returns {string|null}
33
+ */
34
+ export function currentSessionKey() {
35
+ const key = sessionStore.getStore();
36
+ return key === undefined ? null : key;
37
+ }
package/dist/setup.js CHANGED
@@ -6,6 +6,7 @@ import * as fs from 'fs/promises';
6
6
  import * as path from 'path';
7
7
  import * as os from 'os';
8
8
  import { exec, execSync } from 'child_process';
9
+ import { fileURLToPath } from 'url';
9
10
  import { google } from 'googleapis';
10
11
 
11
12
  // ---------------------------------------------------------------------------
@@ -35,6 +36,15 @@ const CONSENT_SCREEN_URL =
35
36
  const AUDIENCE_URL =
36
37
  'https://console.cloud.google.com/auth/audience';
37
38
 
39
+ // The command a global-install user needs to run to pick up a new release.
40
+ // Unlike `npx -y google-tools-mcp`, a direct `node <global-path>` launch
41
+ // command (see the fast-launch block below) never re-resolves to the latest
42
+ // version on its own, so we surface this explicitly wherever we point a
43
+ // client at a fixed path. Exported so the regression test can assert the
44
+ // wizard actually tells the user about it instead of just asserting a
45
+ // hardcoded string.
46
+ export const UPDATE_COMMAND = 'npm install -g google-tools-mcp@latest';
47
+
38
48
  // ---------------------------------------------------------------------------
39
49
  // Helpers
40
50
  // ---------------------------------------------------------------------------
@@ -82,6 +92,115 @@ function cancelled() {
82
92
  process.exit(0);
83
93
  }
84
94
 
95
+ // ---------------------------------------------------------------------------
96
+ // Fast-launch install
97
+ // ---------------------------------------------------------------------------
98
+ // `npx -y google-tools-mcp` re-resolves and verifies the whole dependency
99
+ // tree on every single launch. On some machines (observed on Windows, likely
100
+ // antivirus scanning of npm's file I/O during install/verify — this package
101
+ // pulls in fastmcp + the full googleapis client library tree) that takes
102
+ // 30+ seconds, even when the exact version is already cached. Claude Code's
103
+ // stdio MCP connection timeout is a fixed 30s, so that's enough to lose the
104
+ // race and surface as "the server won't connect" with no visible cause.
105
+ // See https://github.com/karthikcsq/google-tools-mcp/issues/46
106
+ //
107
+ // To avoid paying npx's per-launch cost on every server start, we install
108
+ // the package globally once here and point the MCP client directly at the
109
+ // resolved dist/index.js via `node`, skipping npx entirely afterward.
110
+
111
+ // Pure helper: where npm puts an installed package's files under a given
112
+ // global node_modules root. Exported for testing.
113
+ export function resolveGlobalIndexPath(globalRoot) {
114
+ return path.join(globalRoot, 'google-tools-mcp', 'dist', 'index.js');
115
+ }
116
+
117
+ // Installs google-tools-mcp globally and resolves the absolute path to its
118
+ // dist/index.js so callers can launch it directly with `node`, bypassing
119
+ // npx. `run`/`access`/`hasNpm` are injectable for testing; they default to
120
+ // the real shell/filesystem/CLI-detection implementations.
121
+ export async function installGlobalFastLaunch({
122
+ run = runCommand,
123
+ access = fs.access,
124
+ hasNpm = () => hasCli('npm'),
125
+ } = {}) {
126
+ if (!hasNpm()) {
127
+ return { ok: false, npmMissing: true, reason: 'npm was not found on PATH, so nothing can be installed globally.' };
128
+ }
129
+
130
+ try {
131
+ await run('npm install -g google-tools-mcp@latest');
132
+ } catch (err) {
133
+ return { ok: false, reason: `npm install -g google-tools-mcp failed: ${err.message}` };
134
+ }
135
+
136
+ let globalRoot;
137
+ try {
138
+ globalRoot = (await run('npm root -g')).trim();
139
+ } catch (err) {
140
+ return { ok: false, reason: `could not resolve the npm global root: ${err.message}` };
141
+ }
142
+
143
+ const indexPath = resolveGlobalIndexPath(globalRoot);
144
+ try {
145
+ await access(indexPath);
146
+ } catch {
147
+ return { ok: false, reason: `installed globally, but dist/index.js was not found at ${indexPath}` };
148
+ }
149
+
150
+ return { ok: true, indexPath };
151
+ }
152
+
153
+ // Absolute path to the dist/index.js sitting next to this file, i.e. the copy
154
+ // of the package that is running setup right now. Used as the last fallback:
155
+ // wherever this wizard was launched from, that entry point demonstrably exists.
156
+ // Returns null if the URL can't be turned into a path, so a surprise here
157
+ // degrades to "no last-resort fallback" instead of crashing the wizard.
158
+ export function resolveRunningIndexPath(moduleUrl = import.meta.url) {
159
+ try {
160
+ return path.join(path.dirname(fileURLToPath(moduleUrl)), 'index.js');
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ // Builds the { command, args, shellDisplay, source } to launch the server with,
167
+ // given the result of installGlobalFastLaunch().
168
+ //
169
+ // Falling back to `npx` unconditionally would be wrong: on a standard Node
170
+ // installation npm and npx ship together, so "npm is not on PATH" almost always
171
+ // means npx is not either, and the wizard would finish happily after writing a
172
+ // launch command that cannot run. So the order is: the global install if it
173
+ // worked, then npx if npx actually exists, then the copy of the package running
174
+ // this wizard. Returns null when none of those is available, which the caller
175
+ // must report rather than write a broken config.
176
+ export function buildLaunchCommand(fastLaunch, {
177
+ execPath = process.execPath,
178
+ runningIndexPath = null,
179
+ hasNpx = () => hasCli('npx'),
180
+ } = {}) {
181
+ const direct = (indexPath, source) => ({
182
+ command: execPath,
183
+ args: [indexPath],
184
+ shellDisplay: `"${execPath}" "${indexPath}"`,
185
+ source,
186
+ });
187
+
188
+ if (fastLaunch?.ok) return direct(fastLaunch.indexPath, 'global');
189
+
190
+ if (hasNpx()) {
191
+ return {
192
+ command: 'npx',
193
+ args: ['-y', 'google-tools-mcp'],
194
+ shellDisplay: 'npx -y google-tools-mcp',
195
+ source: 'npx',
196
+ };
197
+ }
198
+
199
+ if (runningIndexPath) return direct(runningIndexPath, 'running-copy');
200
+
201
+ return null;
202
+ }
203
+
85
204
  // Pull the GCP project number out of an OAuth client ID.
86
205
  // Format: <project_number>-<hash>.apps.googleusercontent.com
87
206
  function extractProjectNumberFromClientId(clientId) {
@@ -381,6 +500,60 @@ export async function runSetup() {
381
500
 
382
501
  // ── Step 5: Install ──────────────────────────────────────────────────
383
502
  p.log.step(chalk.cyan.bold('Step 5') + chalk.dim(' · ') + 'Install MCP server');
503
+ p.log.message(chalk.dim(
504
+ 'Installing globally so your MCP client launches google-tools-mcp directly ' +
505
+ 'instead of through npx — npx re-resolves the whole dependency tree on every ' +
506
+ 'launch, which can take 30+ seconds on some machines and lose the race against ' +
507
+ "Claude Code's 30s connection timeout (github.com/karthikcsq/google-tools-mcp/issues/46)."
508
+ ));
509
+
510
+ const installSpinner = p.spinner();
511
+ installSpinner.start('Installing google-tools-mcp globally...');
512
+ const fastLaunch = await installGlobalFastLaunch();
513
+
514
+ // Last-resort launch target: the copy of the package running this wizard.
515
+ // Only offered if it is really there, so we never write a path we haven't
516
+ // confirmed.
517
+ let runningIndexPath = resolveRunningIndexPath();
518
+ try {
519
+ await fs.access(runningIndexPath);
520
+ } catch {
521
+ runningIndexPath = null;
522
+ }
523
+
524
+ const launch = buildLaunchCommand(fastLaunch, { runningIndexPath });
525
+
526
+ if (fastLaunch.ok) {
527
+ installSpinner.stop(chalk.green('Installed globally — MCP clients will launch it directly via node.'));
528
+ p.log.message(chalk.dim(
529
+ 'Note: unlike npx, this direct launch path does not auto-update. The server checks for new ' +
530
+ 'releases at startup and will log a line if one is available, without slowing down connection. ' +
531
+ `To update whenever you like, run: `
532
+ ) + chalk.cyan(UPDATE_COMMAND));
533
+ } else if (!launch) {
534
+ installSpinner.stop(chalk.red('Could not work out any way to launch the server on this machine.'));
535
+ p.log.warn(fastLaunch.reason);
536
+ p.log.error(
537
+ 'No global install, no npx on PATH, and no readable entry point next to this wizard. ' +
538
+ 'Install Node.js with npm from nodejs.org, or install this package yourself with ' +
539
+ '`npm install -g google-tools-mcp`, then run setup again. Nothing was written to your MCP client config.'
540
+ );
541
+ process.exit(1);
542
+ } else if (launch.source === 'npx') {
543
+ installSpinner.stop(chalk.yellow('Global install failed — falling back to npx (slower; can hit connection timeouts).'));
544
+ p.log.warn(fastLaunch.reason);
545
+ p.log.message(chalk.dim('See the README troubleshooting section if the MCP server fails to connect.'));
546
+ } else {
547
+ installSpinner.stop(chalk.yellow('Global install failed and npx is not on PATH — pointing at this copy of the package instead.'));
548
+ p.log.warn(fastLaunch.reason);
549
+ p.log.message(chalk.dim(
550
+ `Your MCP client will launch ${runningIndexPath} directly. That works as long as this copy stays ` +
551
+ 'where it is; if it was run from a temporary npx cache, install the package properly and run setup again.'
552
+ ));
553
+ p.log.message(chalk.dim(
554
+ 'This path does not auto-update either. Once npm is available, run: '
555
+ ) + chalk.cyan(UPDATE_COMMAND) + chalk.dim(' to update, or re-run this wizard.'));
556
+ }
384
557
 
385
558
  const hasCodex = hasCli('codex');
386
559
  const hasClaude = hasCli('claude');
@@ -393,19 +566,20 @@ export async function runSetup() {
393
566
  });
394
567
  if (p.isCancel(install)) cancelled();
395
568
 
569
+ const codexAddCmd = `codex mcp add google -- ${launch.shellDisplay}`;
396
570
  if (install) {
397
571
  const s = p.spinner();
398
572
  s.start('Adding to Codex...');
399
573
  try {
400
- await runCommand('codex mcp add google -- npx -y google-tools-mcp');
574
+ await runCommand(codexAddCmd);
401
575
  s.stop('Added to Codex!');
402
576
  } catch (err) {
403
577
  s.stop('Failed to add automatically');
404
578
  p.log.warn(`Error: ${err.message}`);
405
- p.log.message(`Run manually:\n${chalk.cyan('codex mcp add google -- npx -y google-tools-mcp')}`);
579
+ p.log.message(`Run manually:\n${chalk.cyan(codexAddCmd)}`);
406
580
  }
407
581
  } else {
408
- p.log.message(`To add later:\n${chalk.cyan('codex mcp add google -- npx -y google-tools-mcp')}`);
582
+ p.log.message(`To add later:\n${chalk.cyan(codexAddCmd)}`);
409
583
  }
410
584
  }
411
585
 
@@ -418,34 +592,38 @@ export async function runSetup() {
418
592
  });
419
593
  if (p.isCancel(install)) cancelled();
420
594
 
595
+ const claudeAddCmd = `claude mcp add -s user google -- ${launch.shellDisplay}`;
421
596
  if (install) {
422
597
  const s = p.spinner();
423
598
  s.start('Adding to Claude Code...');
424
599
  try {
425
- await runCommand('claude mcp add -s user google -- npx -y google-tools-mcp');
600
+ await runCommand(claudeAddCmd);
426
601
  s.stop('Added to Claude Code!');
427
602
  } catch (err) {
428
603
  s.stop('Failed to add automatically');
429
604
  p.log.warn(`Error: ${err.message}`);
430
- p.log.message(`Run manually:\n${chalk.cyan('claude mcp add -s user google -- npx -y google-tools-mcp')}`);
605
+ p.log.message(`Run manually:\n${chalk.cyan(claudeAddCmd)}`);
431
606
  }
432
607
  } else {
433
- p.log.message(`To add later:\n${chalk.cyan('claude mcp add -s user google -- npx -y google-tools-mcp')}`);
608
+ p.log.message(`To add later:\n${chalk.cyan(claudeAddCmd)}`);
434
609
  }
435
610
  }
436
611
 
437
612
  if (!hasCodex && !hasClaude) {
613
+ const jsonSnippet = JSON.stringify({
614
+ mcpServers: { google: { command: launch.command, args: launch.args } },
615
+ });
438
616
  p.log.message([
439
617
  'Add to your MCP client:',
440
618
  '',
441
619
  chalk.dim('Codex:'),
442
- chalk.cyan(' codex mcp add google -- npx -y google-tools-mcp'),
620
+ chalk.cyan(` codex mcp add google -- ${launch.shellDisplay}`),
443
621
  '',
444
622
  chalk.dim('Claude Code:'),
445
- chalk.cyan(' claude mcp add -s user google -- npx -y google-tools-mcp'),
623
+ chalk.cyan(` claude mcp add -s user google -- ${launch.shellDisplay}`),
446
624
  '',
447
625
  chalk.dim('Other clients') + chalk.dim(' (.mcp.json):'),
448
- chalk.cyan(' { "mcpServers": { "google": { "command": "npx", "args": ["-y", "google-tools-mcp"] } } }'),
626
+ chalk.cyan(` ${jsonSnippet}`),
449
627
  ].join('\n'));
450
628
  }
451
629
 
@@ -1,64 +1,64 @@
1
- import { UserError } from 'fastmcp';
2
- import { z } from 'zod';
3
- import { getCalendarClient } from '../../clients.js';
4
-
5
- export function register(server) {
6
- server.addTool({
7
- name: 'get_busy',
8
- description:
9
- 'Returns busy time blocks for one or more people in a given time range. ' +
10
- 'Works across your Google Workspace org without needing calendar sharing — you see when people are busy but not event details. ' +
11
- 'Pass email addresses as calendar_ids to check other people.',
12
- parameters: z.object({
13
- time_min: z
14
- .string()
15
- .describe('Start of query range in RFC3339 format (e.g. "2025-03-15T00:00:00Z").'),
16
- time_max: z
17
- .string()
18
- .describe('End of query range in RFC3339 format (e.g. "2025-03-16T23:59:59Z").'),
19
- calendar_ids: z
20
- .array(z.string())
21
- .optional()
22
- .default(['primary'])
23
- .describe('Calendar IDs or email addresses to check. Defaults to ["primary"].'),
24
- }),
25
- execute: async (args, { log }) => {
26
- const calendar = await getCalendarClient();
27
- log.info(`Checking busy times for ${args.calendar_ids.join(', ')}`);
28
-
29
- try {
30
- const response = await calendar.freebusy.query({
31
- requestBody: {
32
- timeMin: args.time_min,
33
- timeMax: args.time_max,
34
- items: args.calendar_ids.map((id) => ({ id })),
35
- },
36
- });
37
-
38
- const calendars = response.data.calendars || {};
39
- const results = {};
40
-
41
- for (const [calId, data] of Object.entries(calendars)) {
42
- if (data.errors?.length) {
43
- results[calId] = {
44
- error: data.errors[0].reason,
45
- busy: [],
46
- };
47
- } else {
48
- results[calId] = {
49
- busy: (data.busy || []).map((block) => ({
50
- start: block.start,
51
- end: block.end,
52
- })),
53
- };
54
- }
55
- }
56
-
57
- return JSON.stringify(results, null, 2);
58
- } catch (error) {
59
- log.error(`Error querying freebusy: ${error.message || error}`);
60
- throw new UserError(`Failed to query busy times: ${error.message || 'Unknown error'}`);
61
- }
62
- },
63
- });
64
- }
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getCalendarClient } from '../../clients.js';
4
+
5
+ export function register(server) {
6
+ server.addTool({
7
+ name: 'getBusy',
8
+ description:
9
+ 'Returns busy time blocks for one or more people in a given time range. ' +
10
+ 'Works across your Google Workspace org without needing calendar sharing — you see when people are busy but not event details. ' +
11
+ 'Pass email addresses as calendar_ids to check other people.',
12
+ parameters: z.object({
13
+ time_min: z
14
+ .string()
15
+ .describe('Start of query range in RFC3339 format (e.g. "2025-03-15T00:00:00Z").'),
16
+ time_max: z
17
+ .string()
18
+ .describe('End of query range in RFC3339 format (e.g. "2025-03-16T23:59:59Z").'),
19
+ calendar_ids: z
20
+ .array(z.string())
21
+ .optional()
22
+ .default(['primary'])
23
+ .describe('Calendar IDs or email addresses to check. Defaults to ["primary"].'),
24
+ }),
25
+ execute: async (args, { log }) => {
26
+ const calendar = await getCalendarClient();
27
+ log.info(`Checking busy times for ${args.calendar_ids.join(', ')}`);
28
+
29
+ try {
30
+ const response = await calendar.freebusy.query({
31
+ requestBody: {
32
+ timeMin: args.time_min,
33
+ timeMax: args.time_max,
34
+ items: args.calendar_ids.map((id) => ({ id })),
35
+ },
36
+ });
37
+
38
+ const calendars = response.data.calendars || {};
39
+ const results = {};
40
+
41
+ for (const [calId, data] of Object.entries(calendars)) {
42
+ if (data.errors?.length) {
43
+ results[calId] = {
44
+ error: data.errors[0].reason,
45
+ busy: [],
46
+ };
47
+ } else {
48
+ results[calId] = {
49
+ busy: (data.busy || []).map((block) => ({
50
+ start: block.start,
51
+ end: block.end,
52
+ })),
53
+ };
54
+ }
55
+ }
56
+
57
+ return JSON.stringify(results, null, 2);
58
+ } catch (error) {
59
+ log.error(`Error querying freebusy: ${error.message || error}`);
60
+ throw new UserError(`Failed to query busy times: ${error.message || 'Unknown error'}`);
61
+ }
62
+ },
63
+ });
64
+ }