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.
- package/LICENSE +21 -21
- package/README.md +555 -276
- package/dist/cachedToolsList.js +52 -52
- package/dist/googleDocsApiHelpers.js +76 -12
- package/dist/helpers.js +572 -252
- package/dist/httpAuth.js +296 -0
- package/dist/index.js +162 -11
- package/dist/logger.js +87 -87
- package/dist/markdown-transformer/docsToMarkdown.js +222 -21
- package/dist/markdown-transformer/index.js +19 -9
- package/dist/markdown-transformer/markdownToDocs.js +458 -28
- package/dist/readTracker.js +136 -17
- package/dist/sessionContext.js +37 -0
- package/dist/setup.js +187 -9
- package/dist/tools/calendar/getBusy.js +64 -64
- package/dist/tools/calendar/getEvents.js +141 -141
- package/dist/tools/calendar/getFree.js +225 -225
- package/dist/tools/calendar/index.js +19 -19
- package/dist/tools/calendar/listCalendars.js +38 -38
- package/dist/tools/calendar/listRecurringInstances.js +83 -83
- package/dist/tools/calendar/manageCalendar.js +121 -121
- package/dist/tools/calendar/manageEvent.js +258 -258
- package/dist/tools/calendar/moveEvent.js +66 -66
- package/dist/tools/docs/addTab.js +10 -12
- package/dist/tools/docs/appendToGoogleDoc.js +9 -4
- package/dist/tools/docs/deleteRange.js +9 -4
- package/dist/tools/docs/findAndReplace.js +9 -4
- package/dist/tools/docs/formatting/applyParagraphStyle.js +4 -1
- package/dist/tools/docs/formatting/getFormatting.js +171 -171
- package/dist/tools/docs/insertImage.js +15 -2
- package/dist/tools/docs/insertPageBreak.js +4 -1
- package/dist/tools/docs/insertTable.js +4 -1
- package/dist/tools/docs/insertTableWithData.js +4 -1
- package/dist/tools/docs/modifyText.js +14 -4
- package/dist/tools/docs/modifyText.test.js +84 -84
- package/dist/tools/docs/readGoogleDoc.js +71 -11
- package/dist/tools/docs/renameTab.js +12 -14
- package/dist/tools/drafts.js +165 -165
- package/dist/tools/drive/createDocument.js +13 -1
- package/dist/tools/drive/downloadFile.js +268 -268
- package/dist/tools/drive/getFileInfo.js +48 -48
- package/dist/tools/drive/getFilePath.js +50 -50
- package/dist/tools/drive/listDriveFiles.js +112 -112
- package/dist/tools/drive/listSharedDrives.js +52 -52
- package/dist/tools/drive/listSharedWithMe.js +97 -97
- package/dist/tools/drive/uploadFile.js +111 -111
- package/dist/tools/extras/index.js +9 -9
- package/dist/tools/extras/readDriveFile.js +212 -206
- package/dist/tools/extras/readFile.js +84 -84
- package/dist/tools/extras/searchFileContents.js +81 -81
- package/dist/tools/forms/batchUpdateForm.js +81 -81
- package/dist/tools/forms/createForm.js +54 -54
- package/dist/tools/forms/getForm.js +118 -118
- package/dist/tools/forms/getFormResponse.js +45 -45
- package/dist/tools/forms/index.js +15 -15
- package/dist/tools/forms/listFormResponses.js +56 -56
- package/dist/tools/forms/setPublishSettings.js +59 -59
- package/dist/tools/gmail/drafts.js +165 -165
- package/dist/tools/gmail/labels.js +54 -83
- package/dist/tools/gmail/messages.js +444 -441
- package/dist/tools/gmail/settings.js +358 -528
- package/dist/tools/gmail/threads.js +286 -164
- package/dist/tools/index.js +511 -473
- package/dist/tools/labels.js +103 -103
- package/dist/tools/legacyAliases.js +426 -0
- package/dist/tools/maps/directions.js +72 -0
- package/dist/tools/maps/geocode.js +16 -0
- package/dist/tools/maps/index.js +15 -0
- package/dist/tools/maps/mapsClient.js +83 -0
- package/dist/tools/maps/placeDetails.js +16 -0
- package/dist/tools/maps/reverseGeocode.js +19 -0
- package/dist/tools/maps/searchNearby.js +54 -0
- package/dist/tools/maps/searchPlaces.js +24 -0
- package/dist/tools/messages.js +448 -448
- package/dist/tools/settings.js +528 -528
- package/dist/tools/slides/createPresentation.js +104 -104
- package/dist/tools/slides/createShape.js +92 -92
- package/dist/tools/slides/createTextBox.js +82 -82
- package/dist/tools/slides/deleteSlide.js +30 -30
- package/dist/tools/slides/duplicateSlide.js +37 -37
- package/dist/tools/slides/exportThumbnail.js +42 -42
- package/dist/tools/slides/formatParagraph.js +72 -72
- package/dist/tools/slides/formatText.js +84 -84
- package/dist/tools/slides/getPresentation.js +87 -87
- package/dist/tools/slides/index.js +33 -33
- package/dist/tools/slides/reorderSlides.js +41 -41
- package/dist/tools/slides/replaceAllText.js +46 -46
- package/dist/tools/slides/setBackground.js +58 -58
- package/dist/tools/slides/speakerNotes.js +102 -102
- package/dist/tools/slides/styleShape.js +111 -111
- package/dist/tools/slides/updatePresentation.js +128 -128
- package/dist/tools/threads.js +145 -145
- package/dist/tools/utils/appendMarkdownToGoogleDoc.js +31 -7
- package/dist/tools/utils/replaceDocumentWithMarkdown.js +72 -9
- package/dist/updateCheck.js +202 -0
- package/dist/workspace.js +122 -0
- package/package.json +82 -80
package/dist/readTracker.js
CHANGED
|
@@ -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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
37
|
-
* external-change conflict is detected, the fetcher is used to grab current
|
|
38
|
-
* content and the
|
|
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
|
-
|
|
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
|
|
83
|
-
//
|
|
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 =
|
|
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
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
620
|
+
chalk.cyan(` codex mcp add google -- ${launch.shellDisplay}`),
|
|
443
621
|
'',
|
|
444
622
|
chalk.dim('Claude Code:'),
|
|
445
|
-
chalk.cyan(
|
|
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(
|
|
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: '
|
|
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
|
+
}
|