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/httpAuth.js
ADDED
|
@@ -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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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
|
+
};
|