google-tools-mcp 1.2.12 → 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 +537 -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 +128 -0
  10. package/dist/markdown-transformer/index.js +17 -7
  11. package/dist/markdown-transformer/markdownToDocs.js +185 -27
  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 +12 -0
  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 +197 -196
  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 +30 -6
  94. package/dist/tools/utils/replaceDocumentWithMarkdown.js +71 -8
  95. package/dist/updateCheck.js +202 -0
  96. package/dist/workspace.js +122 -0
  97. package/package.json +82 -81
@@ -0,0 +1,296 @@
1
+ // Application-level authentication and origin guarding for the shared HTTP
2
+ // transport (PR #36 review).
3
+ //
4
+ // The HTTP transport exposes the full authenticated Google Workspace tool
5
+ // surface (Gmail, Drive, Calendar, Docs, ...) over a local URL. Even bound to
6
+ // loopback, that URL is reachable by any other process on the machine and — via
7
+ // a browser POST or a DNS-rebinding attack — potentially by untrusted web
8
+ // content. Protocol-level session isolation does not authorize the caller.
9
+ //
10
+ // This module adds two gates, applied only in HTTP mode:
11
+ // 1. A bearer token. Required by default; auto-generated and logged if the
12
+ // operator did not set one. This defeats untrusted local processes and any
13
+ // browser-delivered request that lacks the token.
14
+ // 2. Origin validation. Browser-originated cross-site requests always carry
15
+ // an Origin header; native MCP clients do not. Requests whose Origin is not
16
+ // loopback (or explicitly allow-listed) are rejected — the MCP spec's
17
+ // recommended DNS-rebinding protection.
18
+ //
19
+ // FastMCP's `authenticate(request)` runs before any tool executes and, in HTTP
20
+ // mode, a thrown error is turned into an HTTP 401 by the underlying transport.
21
+ // In stdio mode `authenticate` is invoked with no request, so these gates are
22
+ // inert there and the default transport is byte-for-byte unaffected.
23
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
24
+ import http from 'node:http';
25
+
26
+ /**
27
+ * Resolve HTTP auth configuration from the environment. Pure — no side effects.
28
+ * Does not validate the combination of `noAuth` and `host` — call
29
+ * `assertSafeHttpBinding` on the result before starting the HTTP transport.
30
+ * @param {NodeJS.ProcessEnv} [env]
31
+ */
32
+ export function resolveHttpAuthConfig(env = process.env) {
33
+ const explicitToken = (env.GOOGLE_MCP_HTTP_TOKEN || '').trim();
34
+ const noAuth = /^(1|true|yes|on)$/i.test((env.GOOGLE_MCP_HTTP_NO_AUTH || '').trim());
35
+ const host = (env.GOOGLE_MCP_HTTP_HOST || '127.0.0.1').trim();
36
+ const allowedOrigins = (env.GOOGLE_MCP_HTTP_ALLOWED_ORIGINS || '')
37
+ .split(',')
38
+ .map((s) => s.trim())
39
+ .filter(Boolean);
40
+ return { explicitToken, noAuth, host, allowedOrigins };
41
+ }
42
+
43
+ /**
44
+ * Refuse configurations that would expose the authenticated Google Workspace
45
+ * tool surface (Gmail, Drive, Calendar, Docs, ...) with no way to keep out an
46
+ * unauthenticated remote caller.
47
+ *
48
+ * Two things are unsafe on their own and must stop startup, not just log a
49
+ * warning after the fact:
50
+ * 1. `GOOGLE_MCP_HTTP_NO_AUTH=1` (no bearer token) combined with a bind host
51
+ * that is not strictly loopback (e.g. `0.0.0.0`, `::`, a LAN IP, or a
52
+ * hostname). That is a remotely reachable server with zero
53
+ * authentication.
54
+ * 2. An empty or whitespace-only host after trimming. `env.HOST || '...'`
55
+ * treats a whitespace string as truthy, so `GOOGLE_MCP_HTTP_HOST=" "`
56
+ * previously resolved to `''`, which Node's http server can bind as "all
57
+ * interfaces" instead of the intended loopback default.
58
+ *
59
+ * @param {{ host: string, noAuth: boolean }} config result of resolveHttpAuthConfig
60
+ * @throws {Error} with an operator-facing message if the combination is unsafe
61
+ */
62
+ export function assertSafeHttpBinding({ host, noAuth }) {
63
+ const trimmedHost = typeof host === 'string' ? host.trim() : '';
64
+ if (!trimmedHost) {
65
+ throw new Error(
66
+ 'GOOGLE_MCP_HTTP_HOST resolved to an empty value after trimming. Refusing to start: ' +
67
+ 'an empty host can make the HTTP server bind to all interfaces instead of loopback. ' +
68
+ 'Unset GOOGLE_MCP_HTTP_HOST to use the 127.0.0.1 default, or set it to a real host.'
69
+ );
70
+ }
71
+ if (noAuth && !isLoopbackHost(trimmedHost)) {
72
+ throw new Error(
73
+ `Refusing to start: GOOGLE_MCP_HTTP_NO_AUTH is set and GOOGLE_MCP_HTTP_HOST is ` +
74
+ `'${trimmedHost}', which is not loopback. That combination starts a remotely ` +
75
+ 'reachable server with zero authentication in front of every Google Workspace tool ' +
76
+ '(Gmail, Drive, Docs, Calendar, ...). Either remove GOOGLE_MCP_HTTP_NO_AUTH so the ' +
77
+ 'bearer token stays required, or bind to a loopback host (127.0.0.1, localhost, ::1).'
78
+ );
79
+ }
80
+ }
81
+
82
+ /** Generate a high-entropy URL-safe token. */
83
+ export function generateToken() {
84
+ return randomBytes(24).toString('base64url');
85
+ }
86
+
87
+ /** True if `hostname` is a loopback address. */
88
+ export function isLoopbackHost(hostname) {
89
+ return (
90
+ hostname === 'localhost' ||
91
+ hostname === '127.0.0.1' ||
92
+ hostname === '::1' ||
93
+ hostname === '[::1]'
94
+ );
95
+ }
96
+
97
+ /**
98
+ * Decide whether a request's Origin header is acceptable.
99
+ * Absent Origin (native, non-browser clients) is allowed. A present Origin must
100
+ * be loopback or in the explicit allow-list.
101
+ * @param {string|undefined|null} origin
102
+ * @param {string[]} allowedOrigins
103
+ */
104
+ export function isOriginAllowed(origin, allowedOrigins = []) {
105
+ if (!origin) return true; // native MCP clients don't send Origin
106
+ if (allowedOrigins.includes(origin)) return true;
107
+ try {
108
+ const u = new URL(origin);
109
+ return isLoopbackHost(u.hostname);
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ function firstHeader(value) {
116
+ return Array.isArray(value) ? value[0] : value;
117
+ }
118
+
119
+ /**
120
+ * Pull a bearer token from an incoming request. Accepts
121
+ * `Authorization: Bearer <token>` or an `X-MCP-Token: <token>` header.
122
+ * @param {import('node:http').IncomingMessage} request
123
+ * @returns {string|null}
124
+ */
125
+ export function extractBearerToken(request) {
126
+ const headers = request?.headers || {};
127
+ const authHeader = firstHeader(headers['authorization'] ?? headers['Authorization']);
128
+ if (typeof authHeader === 'string') {
129
+ const m = /^Bearer\s+(.+)$/i.exec(authHeader.trim());
130
+ if (m) return m[1].trim();
131
+ }
132
+ const alt = firstHeader(headers['x-mcp-token']);
133
+ if (typeof alt === 'string' && alt.trim()) return alt.trim();
134
+ return null;
135
+ }
136
+
137
+ /** Constant-time string comparison that won't throw on length mismatch. */
138
+ export function tokensMatch(a, b) {
139
+ if (typeof a !== 'string' || typeof b !== 'string' || a.length === 0) return false;
140
+ const ab = Buffer.from(a, 'utf8');
141
+ const bb = Buffer.from(b, 'utf8');
142
+ if (ab.length !== bb.length) return false;
143
+ return timingSafeEqual(ab, bb);
144
+ }
145
+
146
+ /**
147
+ * Build the FastMCP `authenticate(request)` function for HTTP mode.
148
+ * Throws (→ HTTP 401) on a disallowed origin or a missing/invalid token.
149
+ * Returns a session auth object on success.
150
+ *
151
+ * @param {{ token?: string, noAuth?: boolean, allowedOrigins?: string[] }} config
152
+ * @param {{ warn?: Function }} [logger]
153
+ */
154
+ export function createHttpAuthenticate(config, logger) {
155
+ return async function authenticate(request) {
156
+ // stdio transport calls authenticate() with no request — nothing to guard.
157
+ if (!request) return {};
158
+
159
+ const result = checkHttpAuth(request.headers, config);
160
+ if (!result.ok) {
161
+ logger?.warn?.(result.reason);
162
+ throw new Error(result.message);
163
+ }
164
+
165
+ return { authenticated: true };
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Apply the origin and token gates to a plain headers object.
171
+ *
172
+ * Both the FastMCP `authenticate` hook and the transport middleware call this,
173
+ * so there is one definition of what an acceptable request looks like and the
174
+ * two cannot drift apart.
175
+ *
176
+ * @param {Record<string, string|string[]|undefined>} headers
177
+ * @param {{ token?: string, noAuth?: boolean, allowedOrigins?: string[] }} config
178
+ * @returns {{ok: true} | {ok: false, status: number, message: string, reason: string}}
179
+ */
180
+ export function checkHttpAuth(headers, config) {
181
+ const { token, noAuth = false, allowedOrigins = [] } = config || {};
182
+
183
+ const origin = firstHeader(headers?.origin ?? headers?.Origin);
184
+ if (!isOriginAllowed(origin, allowedOrigins)) {
185
+ return {
186
+ ok: false,
187
+ status: 403,
188
+ message: 'Forbidden: request Origin is not allowed',
189
+ reason: `Rejected HTTP request from disallowed Origin: ${origin}`,
190
+ };
191
+ }
192
+
193
+ if (!noAuth) {
194
+ const provided = extractBearerToken({ headers });
195
+ if (!tokensMatch(provided, token)) {
196
+ return {
197
+ ok: false,
198
+ status: 401,
199
+ message: 'Unauthorized: missing or invalid authentication token',
200
+ reason: 'Rejected HTTP request: missing or invalid authentication token.',
201
+ };
202
+ }
203
+ }
204
+
205
+ return { ok: true };
206
+ }
207
+
208
+ /**
209
+ * Build a request guard that applies the same gates to every HTTP method and
210
+ * every route the underlying listener can dispatch to, not just the
211
+ * configured streamable-HTTP endpoint.
212
+ *
213
+ * FastMCP's `authenticate` hook is only reached on session creation. In the
214
+ * pinned mcp-proxy that means POST to the streamable endpoint: its GET branch
215
+ * (attaching to a session's event stream) and DELETE branch (terminating a
216
+ * session) dispatch on the Mcp-Session-Id header alone and never call the
217
+ * hook. Anyone who learned a session id could read from or kill that session
218
+ * without presenting a token, while the README promises every request is
219
+ * bearer-protected.
220
+ *
221
+ * That gap is not limited to the configured endpoint (normally `/mcp`).
222
+ * FastMCP's call into mcp-proxy's `startHTTPServer` never passes
223
+ * `sseEndpoint`, so mcp-proxy's default of `/sse` (plus its companion POST
224
+ * `/messages` route) is always live alongside the streamable endpoint,
225
+ * whatever `GOOGLE_MCP_ENDPOINT` is set to, and there is no supported FastMCP
226
+ * option to turn that legacy SSE compatibility transport off. Guarding only
227
+ * the configured endpoint would leave `/sse` and `/messages` completely
228
+ * unauthenticated, so this guard instead applies to every path except the two
229
+ * that must stay open for the protocol to work at all.
230
+ *
231
+ * Returns true when it has already written a rejection, false to let the
232
+ * request continue.
233
+ *
234
+ * OPTIONS is let through: browsers never attach Authorization to a preflight,
235
+ * and the real request behind it is still gated. `GET /ping` is let through:
236
+ * it is mcp-proxy's own unauthenticated health check, carries no session data
237
+ * and no tool surface, and is documented as staying open.
238
+ *
239
+ * @param {{ token?: string, noAuth?: boolean, allowedOrigins?: string[] }} config
240
+ * @param {{ warn?: Function }} [logger]
241
+ */
242
+ export function createHttpRequestGuard(config, logger) {
243
+ return function guardRequest(req, res) {
244
+ if (req.method === 'OPTIONS') return false;
245
+
246
+ let pathname;
247
+ try {
248
+ pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
249
+ } catch {
250
+ return false;
251
+ }
252
+ if (req.method === 'GET' && pathname === '/ping') return false;
253
+
254
+ const result = checkHttpAuth(req.headers, config);
255
+ if (result.ok) return false;
256
+
257
+ logger?.warn?.(`${result.reason} (${req.method} ${pathname})`);
258
+ res.setHeader('Content-Type', 'application/json');
259
+ res.writeHead(result.status).end(JSON.stringify({ error: result.message }));
260
+ return true;
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Run `start` with every HTTP server it creates wrapped in `guard`.
266
+ *
267
+ * There is no supported way to reach mcp-proxy's request listener: FastMCP does
268
+ * not accept middleware for the stream transport, its Hono app is not what
269
+ * serves this endpoint, and startHTTPServer returns only a close function. So
270
+ * we substitute http.createServer for the duration of startup, wrap whatever
271
+ * listener gets handed to it, and put the original back before returning. The
272
+ * substitution is confined to that window and never touches anything the caller
273
+ * creates before or after.
274
+ *
275
+ * @param {() => Promise<void>} start
276
+ * @param {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => boolean} guard
277
+ */
278
+ export async function startWithRequestGuard(start, guard) {
279
+ const originalCreateServer = http.createServer;
280
+ http.createServer = function patchedCreateServer(...args) {
281
+ const listenerIndex = args.findIndex((arg) => typeof arg === 'function');
282
+ if (listenerIndex !== -1) {
283
+ const listener = args[listenerIndex];
284
+ args[listenerIndex] = function guardedListener(req, res) {
285
+ if (guard(req, res)) return undefined;
286
+ return listener.call(this, req, res);
287
+ };
288
+ }
289
+ return originalCreateServer.apply(this, args);
290
+ };
291
+ try {
292
+ return await start();
293
+ } finally {
294
+ http.createServer = originalCreateServer;
295
+ }
296
+ }
package/dist/index.js CHANGED
@@ -8,9 +8,22 @@
8
8
  // google-tools-mcp Start the MCP server (default)
9
9
  // google-tools-mcp auth Run the interactive OAuth flow
10
10
  // google-tools-mcp setup Guided setup: enable APIs, create credentials, authenticate
11
+ import { createRequire } from 'module';
11
12
  import { FastMCP } from 'fastmcp';
12
13
  import { registerAllTools } from './tools/index.js';
13
14
  import { logger } from './logger.js';
15
+ import { getConfigDir } from './auth.js';
16
+ import { checkForUpdate } from './updateCheck.js';
17
+ import { resolveHttpAuthConfig, assertSafeHttpBinding, generateToken, createHttpAuthenticate, createHttpRequestGuard, startWithRequestGuard } from './httpAuth.js';
18
+ import { clearSession } from './readTracker.js';
19
+
20
+ // Read our own published version straight from package.json rather than
21
+ // hardcoding it. `files: ["dist"]` in package.json only restricts what npm
22
+ // packs; package.json itself always ships at the package root (npm
23
+ // includes it unconditionally), so this resolves the same way both in this
24
+ // checkout and once installed globally.
25
+ const require = createRequire(import.meta.url);
26
+ const { version: packageVersion } = require('../package.json');
14
27
 
15
28
  // --- Setup subcommand ---
16
29
  if (process.argv[2] === 'setup') {
@@ -37,6 +50,54 @@ if (process.argv[2] === 'auth') {
37
50
  }
38
51
  }
39
52
 
53
+ // --- Transport selection ---
54
+ // Default is stdio (one server spawned per MCP client, the classic model).
55
+ // Set GOOGLE_MCP_TRANSPORT=http to run a single long-lived HTTP server that
56
+ // many clients share over a localhost URL — one process instead of one per
57
+ // session. Accepts "http" or "httpStream" (both map to FastMCP httpStream).
58
+ const transportEnv = (process.env.GOOGLE_MCP_TRANSPORT || 'stdio').toLowerCase();
59
+ const useHttp = transportEnv === 'http' || transportEnv === 'httpstream';
60
+ const httpPort = Number(process.env.GOOGLE_MCP_PORT) || 3939;
61
+ const httpEndpoint = process.env.GOOGLE_MCP_ENDPOINT || '/mcp';
62
+
63
+ // --- HTTP security ---
64
+ // The HTTP transport exposes the authenticated Google tool surface over a local
65
+ // URL. Gate it behind a bearer token + Origin validation and bind to loopback
66
+ // by default so it isn't reachable by untrusted local processes, browser-
67
+ // delivered requests, or the network. (PR #36 review)
68
+ const httpAuth = resolveHttpAuthConfig(process.env);
69
+ if (useHttp) {
70
+ // Refuse to start rather than log a warning after the fact: a non-loopback
71
+ // host combined with GOOGLE_MCP_HTTP_NO_AUTH=1 is a remotely reachable,
72
+ // completely unauthenticated server in front of Gmail/Drive/Docs/Calendar,
73
+ // and an empty host after trimming can make Node bind to all interfaces.
74
+ // Written straight to stderr (not the logger) so LOG_LEVEL=error/silent
75
+ // can't hide the reason the process refused to start.
76
+ try {
77
+ assertSafeHttpBinding(httpAuth);
78
+ } catch (configError) {
79
+ process.stderr.write(`FATAL: ${configError.message}\n`);
80
+ process.exit(1);
81
+ }
82
+ }
83
+ let httpToken = httpAuth.explicitToken;
84
+ if (useHttp && !httpAuth.noAuth && !httpToken) {
85
+ // No token configured — generate a one-time one so the server is never
86
+ // unauthenticated by accident. Log it prominently; the operator should set
87
+ // GOOGLE_MCP_HTTP_TOKEN to a fixed value to keep it stable across restarts.
88
+ httpToken = generateToken();
89
+ // Write straight to stderr rather than through the logger. LOG_LEVEL=error
90
+ // and LOG_LEVEL=silent are both documented settings, and either one would
91
+ // swallow a warn-level message — starting a server that demands a token
92
+ // nobody ever saw, so every client gets rejected with no way to recover.
93
+ process.stderr.write(
94
+ 'GOOGLE_MCP_HTTP_TOKEN is not set — generated a one-time token for this run.\n' +
95
+ ` Token: ${httpToken}\n` +
96
+ ' Clients must send: Authorization: Bearer <token>\n' +
97
+ ' Set GOOGLE_MCP_HTTP_TOKEN to keep this stable across restarts.\n'
98
+ );
99
+ }
100
+
40
101
  // --- Process lifecycle logging ---
41
102
  process.on('uncaughtException', (error) => {
42
103
  logger.error('Uncaught Exception:', error);
@@ -55,31 +116,121 @@ process.on('SIGTERM', () => {
55
116
  // Exit when the MCP client closes the stdio pipe.
56
117
  // This is the primary shutdown path for stdio MCP servers — SIGTERM is not
57
118
  // reliably delivered on Windows when a parent process exits.
58
- process.stdin.on('close', () => {
59
- logger.info('stdin closed MCP client disconnected. Shutting down.');
60
- process.exit(0);
61
- });
62
- process.stdin.on('end', () => {
63
- logger.info('stdin ended MCP client disconnected. Shutting down.');
64
- process.exit(0);
65
- });
119
+ //
120
+ // IMPORTANT: only wire these in stdio mode. In HTTP mode the server is a
121
+ // detached daemon whose stdin ends/closes immediately at launch — attaching
122
+ // these there would make the daemon exit the instant it starts.
123
+ if (!useHttp) {
124
+ process.stdin.on('close', () => {
125
+ logger.info('stdin closed — MCP client disconnected. Shutting down.');
126
+ process.exit(0);
127
+ });
128
+ process.stdin.on('end', () => {
129
+ logger.info('stdin ended — MCP client disconnected. Shutting down.');
130
+ process.exit(0);
131
+ });
132
+ }
66
133
  process.on('exit', (code) => {
67
134
  logger.info(`Process exiting with code ${code}.`);
68
135
  });
69
136
 
70
137
  // --- Server startup ---
71
- const server = new FastMCP({
138
+ const serverOptions = {
72
139
  name: 'google-tools-mcp',
73
140
  version: '1.0.0',
141
+ };
142
+ if (useHttp) {
143
+ // authenticate() runs before any tool; a throw becomes an HTTP 401. It is
144
+ // only attached in HTTP mode, so stdio behavior is unchanged.
145
+ serverOptions.authenticate = createHttpAuthenticate(
146
+ { token: httpToken, noAuth: httpAuth.noAuth, allowedOrigins: httpAuth.allowedOrigins },
147
+ logger,
148
+ );
149
+ }
150
+ const server = new FastMCP(serverOptions);
151
+
152
+ // Free per-session tracker state when an HTTP client disconnects, so a long-
153
+ // lived shared server doesn't accumulate it indefinitely.
154
+ server.on('disconnect', ({ session }) => {
155
+ try {
156
+ const key = session?.sessionId;
157
+ if (key) clearSession(key);
158
+ } catch (cleanupError) {
159
+ logger.warn(`Session cleanup failed: ${cleanupError.message || cleanupError}`);
160
+ }
74
161
  });
75
162
 
76
163
  await registerAllTools(server);
77
164
 
78
165
  try {
79
166
  logger.info('Starting google-tools-mcp server...');
80
- await server.start({ transportType: 'stdio' });
81
- logger.info('MCP Server running using stdio. Awaiting client connection...');
167
+ // process.uptime() covers the whole life of this node process, including
168
+ // module load time, so a slow number here means the server itself is
169
+ // slow to boot. If this is fast (~1s) but the MCP client still reports a
170
+ // long time-to-connect, the delay is happening before this process even
171
+ // started — e.g. npx re-resolving the dependency tree on every launch.
172
+ // See https://github.com/karthikcsq/google-tools-mcp/issues/46
173
+ //
174
+ // Measured inside each branch rather than once before them, so the number
175
+ // covers the transport actually being brought up: binding a port is not
176
+ // the same work as attaching to a stdio pipe.
177
+ let readyMs;
178
+ if (useHttp) {
179
+ // authenticate() above only runs on session creation (POST). The guard
180
+ // covers every method and every route mcp-proxy can dispatch to
181
+ // (including the legacy /sse and /messages compatibility endpoints
182
+ // FastMCP always stands up alongside the configured streamable
183
+ // endpoint), so GET stream attachment and DELETE session termination
184
+ // have to present the same token instead of riding on a session id
185
+ // alone.
186
+ const guard = createHttpRequestGuard(
187
+ { token: httpToken, noAuth: httpAuth.noAuth, allowedOrigins: httpAuth.allowedOrigins },
188
+ logger,
189
+ );
190
+ await startWithRequestGuard(() => server.start({
191
+ transportType: 'httpStream',
192
+ httpStream: { port: httpPort, endpoint: httpEndpoint, host: httpAuth.host },
193
+ }), guard);
194
+ readyMs = Math.round(process.uptime() * 1000);
195
+ logger.info(`MCP Server running over HTTP at http://${httpAuth.host}:${httpPort}${httpEndpoint} in ${readyMs}ms.`);
196
+ logger.info(`Auth: ${httpAuth.noAuth ? 'DISABLED (GOOGLE_MCP_HTTP_NO_AUTH) — do not use on a shared machine' : 'bearer token required'}; bound to ${httpAuth.host}.`);
197
+ logger.info('Shared mode: point every client at this URL instead of spawning per-session stdio servers.');
198
+ } else {
199
+ await server.start({ transportType: 'stdio' });
200
+ readyMs = Math.round(process.uptime() * 1000);
201
+ logger.info(`MCP Server running using stdio in ${readyMs}ms. Awaiting client connection...`);
202
+ }
203
+ if (readyMs > 5000) {
204
+ logger.warn(`Startup took ${readyMs}ms. If the MCP client also reports a long connection time, ` +
205
+ 'this process itself is slow — check for antivirus/disk contention. If this number is small ' +
206
+ 'but the client-reported connect time is much larger (e.g. near 30000ms), the delay is happening ' +
207
+ 'before this process starts (commonly npx re-resolving the dependency tree on every launch); ' +
208
+ 'see the README troubleshooting section for a fix.');
209
+ }
82
210
  logger.info('Google auth will run automatically on first tool call.');
211
+
212
+ // Best-effort update nudge. This runs AFTER the transport above is
213
+ // already established, and is deliberately not awaited: a slow or
214
+ // unreachable registry can never delay or block the MCP handshake this
215
+ // way. checkForUpdate() is itself time-boxed and caches its result, so
216
+ // most launches don't even make a network call. See updateCheck.js for
217
+ // why this exists: pointing MCP clients at a fixed global-install path
218
+ // (see setup.js) fixed the npx timeout race but also means nothing else
219
+ // ever re-runs `npm install -g` to pick up new releases.
220
+ checkForUpdate({ currentVersion: packageVersion, configDir: getConfigDir() })
221
+ .then((result) => {
222
+ if (result?.updateAvailable) {
223
+ logger.warn(
224
+ `A newer version of google-tools-mcp is available: ${result.latestVersion} ` +
225
+ `(currently running ${packageVersion}). Update with: npm install -g google-tools-mcp@latest`
226
+ );
227
+ }
228
+ })
229
+ .catch(() => {
230
+ // checkForUpdate() already swallows its own errors; this catch
231
+ // only guards against a truly unexpected throw so it can never
232
+ // surface as an unhandled rejection.
233
+ });
83
234
  } catch (startError) {
84
235
  logger.error('FATAL: Server failed to start:', startError.message || startError);
85
236
  process.exit(1);
package/dist/logger.js CHANGED
@@ -1,87 +1,87 @@
1
- // Centralized logger with LOG_LEVEL support.
2
- // All log output goes to stderr (stdout reserved for MCP protocol).
3
- // If GOOGLE_MCP_LOG_FILE is set, logs are also appended to that file.
4
- import * as fs from 'fs';
5
- import * as path from 'path';
6
- import * as os from 'os';
7
-
8
- const LOG_LEVELS = {
9
- debug: 0,
10
- info: 1,
11
- warn: 2,
12
- error: 3,
13
- silent: 4,
14
- };
15
- function resolveLevel() {
16
- const env = process.env.LOG_LEVEL?.toLowerCase();
17
- if (env && env in LOG_LEVELS) {
18
- return env;
19
- }
20
- return 'info';
21
- }
22
- let currentLevel = resolveLevel();
23
- export function refreshLogLevel() {
24
- currentLevel = resolveLevel();
25
- }
26
- function shouldLog(level) {
27
- return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
28
- }
29
-
30
- // --- File logging ---
31
- let logStream = null;
32
-
33
- function getDefaultLogPath() {
34
- const xdg = process.env.XDG_CONFIG_HOME;
35
- const base = xdg || path.join(os.homedir(), '.config');
36
- const baseDir = path.join(base, 'google-tools-mcp');
37
- const profile = process.env.GOOGLE_MCP_PROFILE;
38
- const dir = profile ? path.join(baseDir, profile) : baseDir;
39
- return path.join(dir, 'server.log');
40
- }
41
-
42
- function initLogFile() {
43
- if (logStream) return;
44
- const logPath = process.env.GOOGLE_MCP_LOG_FILE === '1'
45
- ? getDefaultLogPath()
46
- : process.env.GOOGLE_MCP_LOG_FILE;
47
- if (!logPath) return;
48
- try {
49
- fs.mkdirSync(path.dirname(logPath), { recursive: true });
50
- logStream = fs.createWriteStream(logPath, { flags: 'a' });
51
- } catch {
52
- // If we can't open the log file, continue without file logging
53
- }
54
- }
55
- initLogFile();
56
-
57
- function timestamp() {
58
- return new Date().toISOString();
59
- }
60
-
61
- function formatArgs(args) {
62
- return args.map(a => {
63
- if (a instanceof Error) return a.stack || a.message;
64
- if (typeof a === 'object') {
65
- try { return JSON.stringify(a); } catch { return String(a); }
66
- }
67
- return String(a);
68
- }).join(' ');
69
- }
70
-
71
- function log(level, args) {
72
- if (!shouldLog(level)) return;
73
- const tag = level.toUpperCase();
74
- const ts = timestamp();
75
- const msg = formatArgs(args);
76
- console.error(`${ts} [${tag}] ${msg}`);
77
- if (logStream) {
78
- logStream.write(`${ts} [${tag}] ${msg}\n`);
79
- }
80
- }
81
-
82
- export const logger = {
83
- debug(...args) { log('debug', args); },
84
- info(...args) { log('info', args); },
85
- warn(...args) { log('warn', args); },
86
- error(...args) { log('error', args); },
87
- };
1
+ // Centralized logger with LOG_LEVEL support.
2
+ // All log output goes to stderr (stdout reserved for MCP protocol).
3
+ // If GOOGLE_MCP_LOG_FILE is set, logs are also appended to that file.
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import * as os from 'os';
7
+
8
+ const LOG_LEVELS = {
9
+ debug: 0,
10
+ info: 1,
11
+ warn: 2,
12
+ error: 3,
13
+ silent: 4,
14
+ };
15
+ function resolveLevel() {
16
+ const env = process.env.LOG_LEVEL?.toLowerCase();
17
+ if (env && env in LOG_LEVELS) {
18
+ return env;
19
+ }
20
+ return 'info';
21
+ }
22
+ let currentLevel = resolveLevel();
23
+ export function refreshLogLevel() {
24
+ currentLevel = resolveLevel();
25
+ }
26
+ function shouldLog(level) {
27
+ return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
28
+ }
29
+
30
+ // --- File logging ---
31
+ let logStream = null;
32
+
33
+ function getDefaultLogPath() {
34
+ const xdg = process.env.XDG_CONFIG_HOME;
35
+ const base = xdg || path.join(os.homedir(), '.config');
36
+ const baseDir = path.join(base, 'google-tools-mcp');
37
+ const profile = process.env.GOOGLE_MCP_PROFILE;
38
+ const dir = profile ? path.join(baseDir, profile) : baseDir;
39
+ return path.join(dir, 'server.log');
40
+ }
41
+
42
+ function initLogFile() {
43
+ if (logStream) return;
44
+ const logPath = process.env.GOOGLE_MCP_LOG_FILE === '1'
45
+ ? getDefaultLogPath()
46
+ : process.env.GOOGLE_MCP_LOG_FILE;
47
+ if (!logPath) return;
48
+ try {
49
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
50
+ logStream = fs.createWriteStream(logPath, { flags: 'a' });
51
+ } catch {
52
+ // If we can't open the log file, continue without file logging
53
+ }
54
+ }
55
+ initLogFile();
56
+
57
+ function timestamp() {
58
+ return new Date().toISOString();
59
+ }
60
+
61
+ function formatArgs(args) {
62
+ return args.map(a => {
63
+ if (a instanceof Error) return a.stack || a.message;
64
+ if (typeof a === 'object') {
65
+ try { return JSON.stringify(a); } catch { return String(a); }
66
+ }
67
+ return String(a);
68
+ }).join(' ');
69
+ }
70
+
71
+ function log(level, args) {
72
+ if (!shouldLog(level)) return;
73
+ const tag = level.toUpperCase();
74
+ const ts = timestamp();
75
+ const msg = formatArgs(args);
76
+ console.error(`${ts} [${tag}] ${msg}`);
77
+ if (logStream) {
78
+ logStream.write(`${ts} [${tag}] ${msg}\n`);
79
+ }
80
+ }
81
+
82
+ export const logger = {
83
+ debug(...args) { log('debug', args); },
84
+ info(...args) { log('info', args); },
85
+ warn(...args) { log('warn', args); },
86
+ error(...args) { log('error', args); },
87
+ };