mcp-google-multi 6.0.0-alpha.75 → 6.0.0-alpha.77
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/README.md +1 -1
- package/dist/fs-atomic.js +98 -12
- package/dist/tools/docs.js +41 -12
- package/dist/tools/drive.js +54 -12
- package/dist/tools/sheets.d.ts +10 -0
- package/dist/tools/sheets.js +109 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ The most complete **local Google Workspace MCP server**: Gmail, Drive, Calendar,
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/mcp-google-multi)
|
|
6
6
|
|
|
7
|
-
- 🧰 **Exhaustive** —
|
|
7
|
+
- 🧰 **Exhaustive** — 942 tools across 29 services, now including Google Analytics (GA4), + an escape hatch for anything else → [COVERAGE.md](./COVERAGE.md)
|
|
8
8
|
- 🔑 **Multi-account** — drive any number of Google accounts by alias, or fan one call out across all of them
|
|
9
9
|
- 🔒 **Private by design** — your own OAuth app, tokens encrypted at rest (AES-256-GCM), writes deny-by-default, no telemetry, no metering — it talks only to Google
|
|
10
10
|
- 🌐 **Local or remote** — runs locally over stdio, or self-hosted over HTTP with its own built-in OAuth 2.1 server (Claude Code's `/mcp` login and the claude.ai connector, zero custom UI). Pull-and-up Docker Compose with optional automatic HTTPS → [remote setup](./docs/http-setup.md)
|
package/dist/fs-atomic.js
CHANGED
|
@@ -10,6 +10,9 @@ const RENAME_RETRY_MS = 20;
|
|
|
10
10
|
const LOCK_LEASE_MS = 60_000;
|
|
11
11
|
// Breaking a lock takes microseconds, so an older breaker file was left by a crash.
|
|
12
12
|
const BREAKER_STALE_MS = 2_000;
|
|
13
|
+
// The no-hard-link fallback writes the lock in two syscalls (create, then the
|
|
14
|
+
// pid); a reader can catch it empty in between. See docs/internals.md.
|
|
15
|
+
const LOCK_WRITE_GRACE_MS = 2_000;
|
|
13
16
|
const PROCESS_START_MS = performance.timeOrigin;
|
|
14
17
|
const OWNER_NAME = /^(\d+)\.(\d+|host)\.(\d+)\.([0-9a-f]{8}|0)\.[0-9a-f]{12}\.owner$/;
|
|
15
18
|
function startOf(pid) {
|
|
@@ -63,11 +66,19 @@ function sameFile(a, b) {
|
|
|
63
66
|
function isEnoent(error) {
|
|
64
67
|
return error.code === 'ENOENT';
|
|
65
68
|
}
|
|
69
|
+
function bindingRecord(id) {
|
|
70
|
+
return `${id.dev}:${id.ino}:${id.mtimeNs}`;
|
|
71
|
+
}
|
|
72
|
+
function matchesBinding(content, id) {
|
|
73
|
+
const m = /^(\d+):(\d+):(\d+)$/.exec(content);
|
|
74
|
+
return m !== null && BigInt(m[1]) === id.dev && BigInt(m[2]) === id.ino && BigInt(m[3]) === id.mtimeNs;
|
|
75
|
+
}
|
|
66
76
|
// Undefined when the lock changed hands while it was being read.
|
|
67
77
|
function readHolder(lock) {
|
|
68
78
|
const before = fs.lstatSync(lock, { bigint: true });
|
|
69
79
|
const body = before.isFile() ? fs.readFileSync(lock, 'utf8') : '';
|
|
70
80
|
let owner;
|
|
81
|
+
let ownerPending = false;
|
|
71
82
|
if (before.isFile()) {
|
|
72
83
|
const dir = path.dirname(lock);
|
|
73
84
|
const prefix = `${path.basename(lock)}.`;
|
|
@@ -79,10 +90,12 @@ function readHolder(lock) {
|
|
|
79
90
|
continue;
|
|
80
91
|
const ownerPath = path.join(dir, name);
|
|
81
92
|
try {
|
|
82
|
-
|
|
93
|
+
const ownerStat = fs.lstatSync(ownerPath, { bigint: true });
|
|
94
|
+
if (sameFile(ownerStat, before) || matchesBinding(fs.readFileSync(ownerPath, 'utf8'), before)) {
|
|
83
95
|
owner = { path: ownerPath, ns: m[2], start: m[3], boot: m[4] };
|
|
84
96
|
break;
|
|
85
97
|
}
|
|
98
|
+
ownerPending = true;
|
|
86
99
|
}
|
|
87
100
|
catch (error) {
|
|
88
101
|
if (!isEnoent(error))
|
|
@@ -93,7 +106,7 @@ function readHolder(lock) {
|
|
|
93
106
|
const after = fs.lstatSync(lock, { bigint: true });
|
|
94
107
|
if (!sameFile(before, after))
|
|
95
108
|
return undefined;
|
|
96
|
-
return { body, ino: after.ino, dev: after.dev, mtimeNs: after.mtimeNs, mtimeMs: Number(after.mtimeNs) / 1e6, owner };
|
|
109
|
+
return { body, ino: after.ino, dev: after.dev, mtimeNs: after.mtimeNs, mtimeMs: Number(after.mtimeNs) / 1e6, owner, ownerPending };
|
|
97
110
|
}
|
|
98
111
|
// The owner file's random name tells two holders apart even when the second
|
|
99
112
|
// lock reuses the first one's freed inode.
|
|
@@ -115,10 +128,21 @@ function probeDead(pid) {
|
|
|
115
128
|
return false;
|
|
116
129
|
}
|
|
117
130
|
}
|
|
131
|
+
// Symmetric: a clock step back must not make a write-in-progress lock look
|
|
132
|
+
// permanently fresh (see docs/internals.md).
|
|
133
|
+
function withinWriteGrace(now, mtimeMs) {
|
|
134
|
+
return Math.abs(now - mtimeMs) < LOCK_WRITE_GRACE_MS;
|
|
135
|
+
}
|
|
118
136
|
function holderDead(holder, now) {
|
|
137
|
+
if (holder.body === '' && withinWriteGrace(now, holder.mtimeMs))
|
|
138
|
+
return false;
|
|
119
139
|
const pid = Number(holder.body);
|
|
120
140
|
if (!(Number.isSafeInteger(pid) && pid > 0))
|
|
121
141
|
return true;
|
|
142
|
+
// A same-pid owner file exists but is not yet bound (the fallback lock's two
|
|
143
|
+
// writes race a reader); give it the same grace as an empty lock.
|
|
144
|
+
if (!holder.owner && holder.ownerPending && withinWriteGrace(now, holder.mtimeMs))
|
|
145
|
+
return false;
|
|
122
146
|
const leaseOver = now - holder.mtimeMs >= LOCK_LEASE_MS;
|
|
123
147
|
const owner = holder.owner;
|
|
124
148
|
// No owner file: an older writer, whose namespace is unknown.
|
|
@@ -196,6 +220,53 @@ function releaseBreaker(breaker, token) {
|
|
|
196
220
|
throw error;
|
|
197
221
|
}
|
|
198
222
|
}
|
|
223
|
+
// No hard links here (Android/Termux denies link() in private storage, some
|
|
224
|
+
// filesystems lack it entirely): the lock is created directly with O_EXCL,
|
|
225
|
+
// carrying the bare pid a linked owner file would carry, and the owner file
|
|
226
|
+
// is bound to it by a recorded identity instead of a hard link. See docs/internals.md.
|
|
227
|
+
function takeFallbackLock(lock, ownerFile) {
|
|
228
|
+
const fd = fs.openSync(lock, 'wx', 0o600);
|
|
229
|
+
let acquired;
|
|
230
|
+
try {
|
|
231
|
+
fs.writeSync(fd, String(process.pid));
|
|
232
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
233
|
+
acquired = { dev: stat.dev, ino: stat.ino, mtimeNs: stat.mtimeNs };
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
// The lock is left empty; remove it only if it is still the one we just
|
|
237
|
+
// created, then rethrow so the caller never retries a write failure.
|
|
238
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
239
|
+
fs.closeSync(fd);
|
|
240
|
+
try {
|
|
241
|
+
if (sameFile(fs.lstatSync(lock, { bigint: true }), stat))
|
|
242
|
+
fs.rmSync(lock, { force: true });
|
|
243
|
+
}
|
|
244
|
+
catch (cleanupError) {
|
|
245
|
+
if (!isEnoent(cleanupError))
|
|
246
|
+
throw cleanupError;
|
|
247
|
+
}
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
fs.closeSync(fd);
|
|
251
|
+
try {
|
|
252
|
+
fs.writeFileSync(ownerFile, bindingRecord(acquired));
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
// The lock already exists holding our live pid; without this cleanup
|
|
256
|
+
// every other writer sees an ownerless holder and waits out the full lease.
|
|
257
|
+
try {
|
|
258
|
+
const current = fs.lstatSync(lock, { bigint: true });
|
|
259
|
+
if (sameFile(current, acquired) && current.mtimeNs === acquired.mtimeNs)
|
|
260
|
+
fs.rmSync(lock, { force: true });
|
|
261
|
+
}
|
|
262
|
+
catch (cleanupError) {
|
|
263
|
+
if (!isEnoent(cleanupError))
|
|
264
|
+
throw cleanupError;
|
|
265
|
+
}
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
return acquired;
|
|
269
|
+
}
|
|
199
270
|
// Hardlink-based advisory lock; the lock body is the bare pid, as older
|
|
200
271
|
// readers expect. See docs/internals.md.
|
|
201
272
|
export function withFileLock(absPath, fn, label = `lock: ${absPath}`) {
|
|
@@ -206,17 +277,31 @@ export function withFileLock(absPath, fn, label = `lock: ${absPath}`) {
|
|
|
206
277
|
fs.writeFileSync(ownerFile, String(process.pid), { mode: 0o600, flag: 'wx' });
|
|
207
278
|
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
208
279
|
let linked = false;
|
|
280
|
+
let acquired;
|
|
209
281
|
try {
|
|
210
282
|
while (true) {
|
|
211
283
|
try {
|
|
212
284
|
fs.linkSync(ownerFile, lock);
|
|
213
285
|
linked = true;
|
|
286
|
+
acquired = fs.lstatSync(lock, { bigint: true });
|
|
214
287
|
break;
|
|
215
288
|
}
|
|
216
289
|
catch (error) {
|
|
217
290
|
const err = error;
|
|
218
|
-
if (err.code
|
|
291
|
+
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
|
292
|
+
try {
|
|
293
|
+
acquired = takeFallbackLock(lock, ownerFile);
|
|
294
|
+
linked = true;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
catch (fbError) {
|
|
298
|
+
if (fbError.code !== 'EEXIST')
|
|
299
|
+
throw fbError;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else if (err.code !== 'EEXIST') {
|
|
219
303
|
throw error;
|
|
304
|
+
}
|
|
220
305
|
try {
|
|
221
306
|
const holder = readHolder(lock);
|
|
222
307
|
if (holder && holderDead(holder, Date.now()) && breakLock(lock, holder))
|
|
@@ -238,23 +323,24 @@ export function withFileLock(absPath, fn, label = `lock: ${absPath}`) {
|
|
|
238
323
|
if (!linked)
|
|
239
324
|
fs.rmSync(ownerFile, { force: true });
|
|
240
325
|
}
|
|
241
|
-
let bound = false;
|
|
242
326
|
try {
|
|
243
|
-
// Some network and FUSE filesystems give each name of one file its own
|
|
244
|
-
// inode number; there the owner file cannot vouch for the lock.
|
|
245
|
-
bound = sameFile(fs.lstatSync(ownerFile, { bigint: true }), fs.lstatSync(lock, { bigint: true }));
|
|
246
327
|
return fn();
|
|
247
328
|
}
|
|
248
329
|
finally {
|
|
249
|
-
releaseLock(lock, ownerFile,
|
|
330
|
+
releaseLock(lock, ownerFile, acquired);
|
|
250
331
|
}
|
|
251
332
|
}
|
|
252
333
|
// A holder whose lock was broken (its owner file removed with it) must not
|
|
253
|
-
// remove its successor's lock
|
|
254
|
-
|
|
334
|
+
// remove its successor's lock: only drop the lock if it is still the exact
|
|
335
|
+
// file this call created, and our own owner file is still there to vouch for it.
|
|
336
|
+
function releaseLock(lock, ownerFile, acquired) {
|
|
255
337
|
try {
|
|
256
|
-
if (
|
|
257
|
-
fs.
|
|
338
|
+
if (acquired) {
|
|
339
|
+
const current = fs.lstatSync(lock, { bigint: true });
|
|
340
|
+
if (sameFile(current, acquired) && current.mtimeNs === acquired.mtimeNs && fs.existsSync(ownerFile)) {
|
|
341
|
+
fs.rmSync(lock, { force: true });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
258
344
|
}
|
|
259
345
|
catch (error) {
|
|
260
346
|
if (!isEnoent(error))
|
package/dist/tools/docs.js
CHANGED
|
@@ -380,35 +380,64 @@ export function registerDocsTools(server, deps = {}) {
|
|
|
380
380
|
server.registerTool('docs_replace_text', {
|
|
381
381
|
// Insert/append-capable or non-convergent: a retry duplicates content.
|
|
382
382
|
annotations: { idempotentHint: false },
|
|
383
|
-
description: 'Find and replace
|
|
383
|
+
description: 'Find and replace text in a document. Pass findText/replaceText for a single pair, or replacements for several pairs applied in one batchUpdate.',
|
|
384
384
|
inputSchema: {
|
|
385
385
|
account: accountEnum.describe('Google account alias'),
|
|
386
386
|
documentId: z.string().min(1).describe('Google Docs document ID'),
|
|
387
|
-
findText: z.string().describe('Text to search for'),
|
|
388
|
-
replaceText: z.string().describe('Replacement text'),
|
|
387
|
+
findText: z.string().optional().describe('Text to search for. Use with replaceText; exactly one of this pair or `replacements` is required.'),
|
|
388
|
+
replaceText: z.string().optional().describe('Replacement text for findText'),
|
|
389
389
|
matchCase: coerceBoolean.default(true).optional()
|
|
390
390
|
.describe('Case-sensitive match (default: true)'),
|
|
391
|
+
replacements: coerceJson(z.array(z.object({
|
|
392
|
+
findText: z.string(),
|
|
393
|
+
replaceText: z.string(),
|
|
394
|
+
matchCase: coerceBoolean.optional(),
|
|
395
|
+
})).min(1)).optional().describe('Several find/replace pairs applied in one batchUpdate, each returning its own count. Exactly one of this or findText/replaceText is required.'),
|
|
391
396
|
},
|
|
392
|
-
}, async ({ account, documentId, findText, replaceText, matchCase }) => {
|
|
397
|
+
}, async ({ account, documentId, findText, replaceText, matchCase, replacements }) => {
|
|
398
|
+
const singlePair = findText !== undefined || replaceText !== undefined;
|
|
399
|
+
if (singlePair && replacements) {
|
|
400
|
+
return invalidParams(account, 'Pass either findText/replaceText or replacements, not both.', 'Use findText and replaceText for one pair, or replacements for several pairs in one call.');
|
|
401
|
+
}
|
|
402
|
+
if (!singlePair && !replacements) {
|
|
403
|
+
return invalidParams(account, 'Pass findText and replaceText, or a replacements array.', 'Use findText and replaceText for one pair, or replacements for several pairs in one call.');
|
|
404
|
+
}
|
|
405
|
+
if (singlePair && (findText === undefined || replaceText === undefined)) {
|
|
406
|
+
return invalidParams(account, 'Both findText and replaceText are required together.', 'Pass both, or use replacements for several pairs.');
|
|
407
|
+
}
|
|
408
|
+
const pairs = replacements ?? [{ findText: findText, replaceText: replaceText, matchCase }];
|
|
393
409
|
try {
|
|
394
410
|
const auth = await getClientFn(account);
|
|
395
411
|
const docs = docsClient({ version: 'v1', auth });
|
|
396
412
|
const res = await docs.documents.batchUpdate({
|
|
397
413
|
documentId,
|
|
398
414
|
requestBody: {
|
|
399
|
-
requests:
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
415
|
+
requests: pairs.map((p) => ({
|
|
416
|
+
replaceAllText: {
|
|
417
|
+
containsText: { text: p.findText, matchCase: p.matchCase ?? true },
|
|
418
|
+
replaceText: p.replaceText,
|
|
419
|
+
},
|
|
420
|
+
})),
|
|
405
421
|
},
|
|
406
422
|
});
|
|
407
|
-
const
|
|
423
|
+
const counts = (res.data.replies ?? []).map((r) => r?.replaceAllText?.occurrencesChanged ?? 0);
|
|
424
|
+
const warning = counts.every((c) => c === 0)
|
|
425
|
+
? 'No occurrences were replaced; check spelling and matchCase.'
|
|
426
|
+
: undefined;
|
|
427
|
+
if (replacements) {
|
|
428
|
+
return {
|
|
429
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
430
|
+
documentId: res.data.documentId,
|
|
431
|
+
replacements: pairs.map((p, i) => ({ findText: p.findText, replaceText: p.replaceText, occurrencesReplaced: counts[i] })),
|
|
432
|
+
...(warning ? { warning } : {}),
|
|
433
|
+
}, null, 2) }],
|
|
434
|
+
};
|
|
435
|
+
}
|
|
408
436
|
return {
|
|
409
437
|
content: [{ type: 'text', text: JSON.stringify({
|
|
410
438
|
documentId: res.data.documentId,
|
|
411
|
-
occurrencesReplaced:
|
|
439
|
+
occurrencesReplaced: counts[0],
|
|
440
|
+
...(warning ? { warning } : {}),
|
|
412
441
|
}, null, 2) }],
|
|
413
442
|
};
|
|
414
443
|
}
|
package/dist/tools/drive.js
CHANGED
|
@@ -13,11 +13,13 @@ import * as path from 'path';
|
|
|
13
13
|
import * as os from 'os';
|
|
14
14
|
import * as crypto from 'crypto';
|
|
15
15
|
import { pipeline } from 'node:stream/promises';
|
|
16
|
+
import { Readable } from 'node:stream';
|
|
16
17
|
import mime from 'mime-types';
|
|
17
18
|
// drive_transfer's two-account form is the sanctioned schema exception and
|
|
18
19
|
// stays REQUIRED: omission must fail at the schema, not as a runtime riddle.
|
|
19
20
|
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
|
20
21
|
const FALLBACK_MAX_BYTES = 1024 * 1024 * 1024; // 1GB
|
|
22
|
+
const MAX_UPLOAD_BASE64_BYTES = 10 * 1024 * 1024; // 10MB decoded
|
|
21
23
|
const GOOGLE_WORKSPACE_TYPES = new Set([
|
|
22
24
|
'application/vnd.google-apps.document',
|
|
23
25
|
'application/vnd.google-apps.spreadsheet',
|
|
@@ -135,8 +137,10 @@ export function registerDriveTools(server, deps = {}) {
|
|
|
135
137
|
driveId: z.string().optional().describe('Optional shared drive ID'),
|
|
136
138
|
pageToken: z.string().min(1).optional()
|
|
137
139
|
.describe('Continuation token from a previous call\'s nextPageToken'),
|
|
140
|
+
orderBy: z.string().optional()
|
|
141
|
+
.describe('Sort key(s) for files.list, e.g. "modifiedTime desc" or "folder,name". Default: Drive\'s relevance ranking for the query, an arbitrary stable order otherwise.'),
|
|
138
142
|
},
|
|
139
|
-
}, async ({ account, query, maxResults, driveId, pageToken }) => {
|
|
143
|
+
}, async ({ account, query, maxResults, driveId, pageToken, orderBy }) => {
|
|
140
144
|
try {
|
|
141
145
|
// normalizeDriveQuery trims to '' and Drive reads that as "everything", so
|
|
142
146
|
// a blank query would list the whole drive as a "search" result.
|
|
@@ -160,6 +164,8 @@ export function registerDriveTools(server, deps = {}) {
|
|
|
160
164
|
}
|
|
161
165
|
if (pageToken)
|
|
162
166
|
params.pageToken = pageToken;
|
|
167
|
+
if (orderBy)
|
|
168
|
+
params.orderBy = orderBy;
|
|
163
169
|
const res = await drive.files.list(params);
|
|
164
170
|
const incomplete = res.data.incompleteSearch === true;
|
|
165
171
|
return {
|
|
@@ -337,22 +343,55 @@ export function registerDriveTools(server, deps = {}) {
|
|
|
337
343
|
}
|
|
338
344
|
});
|
|
339
345
|
// ─── Write / upload / download ─────────────────────────────────────────
|
|
340
|
-
|
|
341
|
-
description: 'Upload a
|
|
346
|
+
server.registerTool('drive_upload', {
|
|
347
|
+
description: 'Upload a file to Google Drive, from inline base64 content or (when this server exposes the host filesystem) a local path. Pass `convertTo` to import it as a native, editable Google Doc/Sheet/Slides/Drawing instead of storing the raw bytes.',
|
|
342
348
|
inputSchema: {
|
|
343
349
|
account: accountEnum.describe('Google account alias'),
|
|
344
|
-
localPath: z.string().min(1).describe('Absolute path of the SOURCE file on disk to upload (on the machine running the server; this is not savePath)'),
|
|
345
350
|
filename: z.string().describe('Name as it appears in Drive'),
|
|
346
|
-
|
|
351
|
+
contentBase64: z.string().optional().describe(`Base64-encoded file content. Exactly one of contentBase64${localFiles ? ' or localPath' : ''} is required. Decoded size is capped at ${MAX_UPLOAD_BASE64_BYTES / (1024 * 1024)} MB.`),
|
|
352
|
+
...(localFiles
|
|
353
|
+
? {
|
|
354
|
+
localPath: z.string().min(1).optional().describe('Absolute path of the SOURCE file on disk to upload (on the machine running the server; this is not savePath). Exactly one of contentBase64 or localPath is required.'),
|
|
355
|
+
}
|
|
356
|
+
: {}),
|
|
357
|
+
mimeType: z.string().optional().describe('Source MIME type of the content (inferred from filename if omitted). With `convertTo`, this is the format Drive imports from.'),
|
|
347
358
|
convertTo: z.enum(CONVERT_TO_VALUES).optional().describe('Convert the upload into this native Google Workspace type on import: "document" | "spreadsheet" | "presentation" | "drawing" (full application/vnd.google-apps.* ids also accepted). E.g. upload .md/.html/.docx/.txt with convertTo=document to get a real Google Doc. Source must be an importable format. Omit to store the file as-is.'),
|
|
348
359
|
parentFolderId: z.string().optional().describe('Parent folder ID, not parentId. Defaults to My Drive root'),
|
|
349
360
|
},
|
|
350
|
-
}, async (
|
|
361
|
+
}, async (args) => {
|
|
362
|
+
const { account, filename, contentBase64, mimeType: mimeTypeArg, convertTo, parentFolderId } = args;
|
|
363
|
+
// Declared only when the context may read host files (see the shape).
|
|
364
|
+
const { localPath: localPathArg } = args;
|
|
365
|
+
if (localPathArg && !localFiles)
|
|
366
|
+
return hostFilesRefused(account, 'localPath');
|
|
367
|
+
const hasBase64 = contentBase64 !== undefined;
|
|
368
|
+
const hasLocalPath = !!localPathArg;
|
|
369
|
+
if (hasBase64 === hasLocalPath) {
|
|
370
|
+
return invalidParams(account, `Pass exactly one of contentBase64${localFiles ? ' or localPath' : ''}.`, localFiles
|
|
371
|
+
? 'Use contentBase64 for inline content, or localPath for a file already on the machine running the server.'
|
|
372
|
+
: 'Pass contentBase64 with the file\'s base64-encoded content.');
|
|
373
|
+
}
|
|
351
374
|
try {
|
|
375
|
+
let media;
|
|
376
|
+
if (hasBase64) {
|
|
377
|
+
const maxB64Length = Math.ceil(MAX_UPLOAD_BASE64_BYTES / 3) * 4 + 4;
|
|
378
|
+
if (contentBase64.length > maxB64Length) {
|
|
379
|
+
return invalidParams(account, `Base64-encoded contentBase64 string length ${contentBase64.length} exceeds the ${maxB64Length} character limit (implies decoded size over ${MAX_UPLOAD_BASE64_BYTES} bytes).`, 'Upload a smaller file, or use localPath if this server exposes the host filesystem.');
|
|
380
|
+
}
|
|
381
|
+
const buf = Buffer.from(contentBase64, 'base64');
|
|
382
|
+
if (buf.length > MAX_UPLOAD_BASE64_BYTES) {
|
|
383
|
+
return invalidParams(account, `Decoded contentBase64 is ${buf.length} bytes, over the ${MAX_UPLOAD_BASE64_BYTES} byte cap.`, 'Upload a smaller file, or use localPath if this server exposes the host filesystem.');
|
|
384
|
+
}
|
|
385
|
+
media = { mimeType: mimeTypeArg ?? (mime.lookup(filename) || 'application/octet-stream'), body: Readable.from(buf) };
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
media = {
|
|
389
|
+
mimeType: mimeTypeArg ?? (mime.lookup(localPathArg) || 'application/octet-stream'),
|
|
390
|
+
body: await openLocalReadStream(localPathArg),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
352
393
|
const auth = await getClientFn(account);
|
|
353
394
|
const drive = driveClient({ version: 'v3', auth });
|
|
354
|
-
const resolvedMime = mimeTypeArg ?? (mime.lookup(localPath) || 'application/octet-stream');
|
|
355
|
-
const fileStream = await openLocalReadStream(localPath);
|
|
356
395
|
const res = await drive.files.create({
|
|
357
396
|
requestBody: {
|
|
358
397
|
name: filename,
|
|
@@ -360,10 +399,7 @@ export function registerDriveTools(server, deps = {}) {
|
|
|
360
399
|
// Setting a google-apps target type makes Drive convert the media on import.
|
|
361
400
|
...(convertTo ? { mimeType: resolveConvertTarget(convertTo) } : {}),
|
|
362
401
|
},
|
|
363
|
-
media
|
|
364
|
-
mimeType: resolvedMime,
|
|
365
|
-
body: fileStream,
|
|
366
|
-
},
|
|
402
|
+
media,
|
|
367
403
|
fields: 'id,name,mimeType,webViewLink,size',
|
|
368
404
|
supportsAllDrives: true,
|
|
369
405
|
});
|
|
@@ -676,6 +712,12 @@ export function registerDriveTools(server, deps = {}) {
|
|
|
676
712
|
expirationTime: z.string().optional().describe('RFC 3339 timestamp when access expires. Only valid for role="reader" or "commenter".'),
|
|
677
713
|
},
|
|
678
714
|
}, async ({ account, fileId, type, role, emailAddress, domain, sendNotification, emailMessage, transferOwnership, expirationTime }) => {
|
|
715
|
+
if ((type === 'user' || type === 'group') && !emailAddress) {
|
|
716
|
+
return invalidParams(account, `type is "${type}" but emailAddress was not supplied.`, 'Pass emailAddress, the grantee to share with.');
|
|
717
|
+
}
|
|
718
|
+
if (type === 'domain' && !domain) {
|
|
719
|
+
return invalidParams(account, 'type is "domain" but domain was not supplied.', 'Pass domain, e.g. "example.com".');
|
|
720
|
+
}
|
|
679
721
|
{
|
|
680
722
|
// Outbound allowlist (off unless GOOGLE_OUTBOUND_ALLOWLIST is set): user/group
|
|
681
723
|
// grantees must match, a domain share needs its exact "@domain" entry, "anyone" is refused while active.
|
package/dist/tools/sheets.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import type { ToolRegistry } from '../registry.js';
|
|
2
2
|
import { type CuratedToolDeps } from '../client.js';
|
|
3
3
|
export declare function registerSheetsTools(server: ToolRegistry, deps?: CuratedToolDeps): void;
|
|
4
|
+
interface GridRangeIndexes {
|
|
5
|
+
startRowIndex?: number;
|
|
6
|
+
endRowIndex?: number;
|
|
7
|
+
startColumnIndex?: number;
|
|
8
|
+
endColumnIndex?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function parseA1Range(a1: string): GridRangeIndexes & {
|
|
11
|
+
sheetName: string;
|
|
12
|
+
};
|
|
4
13
|
export declare function buildCellFormat(input: {
|
|
5
14
|
backgroundColor?: {
|
|
6
15
|
red?: number;
|
|
@@ -33,3 +42,4 @@ export declare function buildCellFormat(input: {
|
|
|
33
42
|
format: any;
|
|
34
43
|
fields: string;
|
|
35
44
|
};
|
|
45
|
+
export {};
|
package/dist/tools/sheets.js
CHANGED
|
@@ -369,6 +369,31 @@ export function registerSheetsTools(server, deps = {}) {
|
|
|
369
369
|
return handleSheetsError(error, account);
|
|
370
370
|
}
|
|
371
371
|
});
|
|
372
|
+
server.registerTool('sheets_copy_sheet_to', {
|
|
373
|
+
description: 'Copy a tab into another spreadsheet (which may belong to a different account)',
|
|
374
|
+
inputSchema: {
|
|
375
|
+
account: accountEnum.describe('Google account alias'),
|
|
376
|
+
spreadsheetId: z.string().min(1).describe('Source spreadsheet ID'),
|
|
377
|
+
sheetId: z.number().describe('Source sheet ID'),
|
|
378
|
+
destinationSpreadsheetId: z.string().min(1).describe('Destination spreadsheet ID'),
|
|
379
|
+
},
|
|
380
|
+
}, async ({ account, spreadsheetId, sheetId, destinationSpreadsheetId }) => {
|
|
381
|
+
try {
|
|
382
|
+
const auth = await getClientFn(account);
|
|
383
|
+
const sheets = sheetsClient({ version: 'v4', auth });
|
|
384
|
+
const res = await sheets.spreadsheets.sheets.copyTo({
|
|
385
|
+
spreadsheetId,
|
|
386
|
+
sheetId,
|
|
387
|
+
requestBody: { destinationSpreadsheetId },
|
|
388
|
+
});
|
|
389
|
+
return {
|
|
390
|
+
content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }],
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
return handleSheetsError(error, account);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
372
397
|
server.registerTool('sheets_update_sheet_properties', {
|
|
373
398
|
description: 'Rename a tab, change tab color, freeze rows/columns, hide/show, or change grid dimensions',
|
|
374
399
|
inputSchema: {
|
|
@@ -457,7 +482,8 @@ export function registerSheetsTools(server, deps = {}) {
|
|
|
457
482
|
inputSchema: {
|
|
458
483
|
account: accountEnum.describe('Google account alias'),
|
|
459
484
|
spreadsheetId: z.string().min(1).describe('Spreadsheet ID'),
|
|
460
|
-
range:
|
|
485
|
+
range: z.union([gridRangeSchema, z.string().min(1)])
|
|
486
|
+
.describe('Target range: a GridRange object (half-open indexes), or an A1 string such as "Sheet1!A1:D10" (the sheet title is resolved to a sheetId with one extra call)'),
|
|
461
487
|
backgroundColor: rgbColorSchema.optional().describe('Cell background (RGB 0..1)'),
|
|
462
488
|
textFormat: z.object({
|
|
463
489
|
foregroundColor: rgbColorSchema.optional(),
|
|
@@ -484,12 +510,16 @@ export function registerSheetsTools(server, deps = {}) {
|
|
|
484
510
|
if (built.fields.length === 0) {
|
|
485
511
|
return invalidParams(account, 'No formatting to apply: every optional format property was omitted.', 'Pass at least one of: backgroundColor, textFormat, horizontalAlignment, verticalAlignment, wrapStrategy, numberFormat.');
|
|
486
512
|
}
|
|
513
|
+
const targetRange = typeof range === 'string' ? await resolveA1GridRange(sheets, spreadsheetId, range) : range;
|
|
514
|
+
if ('error' in targetRange) {
|
|
515
|
+
return invalidParams(account, targetRange.error, targetRange.hint);
|
|
516
|
+
}
|
|
487
517
|
await sheets.spreadsheets.batchUpdate({
|
|
488
518
|
spreadsheetId,
|
|
489
519
|
requestBody: {
|
|
490
520
|
requests: [{
|
|
491
521
|
repeatCell: {
|
|
492
|
-
range,
|
|
522
|
+
range: targetRange,
|
|
493
523
|
cell: { userEnteredFormat: built.format },
|
|
494
524
|
fields: built.fields,
|
|
495
525
|
},
|
|
@@ -1118,6 +1148,83 @@ const gridRangeSchema = z.object({
|
|
|
1118
1148
|
startColumnIndex: z.number().min(0).optional(),
|
|
1119
1149
|
endColumnIndex: z.number().min(0).optional(),
|
|
1120
1150
|
});
|
|
1151
|
+
function columnLettersToIndex(letters) {
|
|
1152
|
+
let n = 0;
|
|
1153
|
+
for (const ch of letters.toUpperCase())
|
|
1154
|
+
n = n * 26 + (ch.charCodeAt(0) - 64);
|
|
1155
|
+
return n - 1;
|
|
1156
|
+
}
|
|
1157
|
+
const A1_CELL_RE = /^([A-Za-z]+)?(\d+)?$/;
|
|
1158
|
+
// Either side of ':', and either half of a side, may be omitted: whole column, row or sheet.
|
|
1159
|
+
export function parseA1Range(a1) {
|
|
1160
|
+
const bang = a1.lastIndexOf('!');
|
|
1161
|
+
const sheetPart = bang === -1 ? a1 : a1.slice(0, bang);
|
|
1162
|
+
const rangePart = bang === -1 ? '' : a1.slice(bang + 1);
|
|
1163
|
+
const sheetName = sheetPart.startsWith("'") && sheetPart.endsWith("'")
|
|
1164
|
+
? sheetPart.slice(1, -1).replace(/''/g, "'")
|
|
1165
|
+
: sheetPart;
|
|
1166
|
+
if (rangePart === '')
|
|
1167
|
+
return { sheetName };
|
|
1168
|
+
const [startRaw, endRaw] = rangePart.split(':');
|
|
1169
|
+
const m1 = startRaw.match(A1_CELL_RE);
|
|
1170
|
+
if (!m1 || (!m1[1] && !m1[2]))
|
|
1171
|
+
throw new Error(`invalid A1 range "${a1}"`);
|
|
1172
|
+
const [, col1, row1] = m1;
|
|
1173
|
+
const out = { sheetName };
|
|
1174
|
+
if (endRaw === undefined) {
|
|
1175
|
+
if (col1) {
|
|
1176
|
+
const c = columnLettersToIndex(col1);
|
|
1177
|
+
out.startColumnIndex = c;
|
|
1178
|
+
out.endColumnIndex = c + 1;
|
|
1179
|
+
}
|
|
1180
|
+
if (row1) {
|
|
1181
|
+
const r = Number(row1) - 1;
|
|
1182
|
+
out.startRowIndex = r;
|
|
1183
|
+
out.endRowIndex = r + 1;
|
|
1184
|
+
}
|
|
1185
|
+
return out;
|
|
1186
|
+
}
|
|
1187
|
+
const m2 = endRaw.match(A1_CELL_RE);
|
|
1188
|
+
if (!m2 || (!m2[1] && !m2[2]))
|
|
1189
|
+
throw new Error(`invalid A1 range "${a1}"`);
|
|
1190
|
+
const [, col2, row2] = m2;
|
|
1191
|
+
if (col1)
|
|
1192
|
+
out.startColumnIndex = columnLettersToIndex(col1);
|
|
1193
|
+
if (col2)
|
|
1194
|
+
out.endColumnIndex = columnLettersToIndex(col2) + 1;
|
|
1195
|
+
if (row1)
|
|
1196
|
+
out.startRowIndex = Number(row1) - 1;
|
|
1197
|
+
if (row2)
|
|
1198
|
+
out.endRowIndex = Number(row2);
|
|
1199
|
+
return out;
|
|
1200
|
+
}
|
|
1201
|
+
async function resolveA1GridRange(sheets, spreadsheetId, a1) {
|
|
1202
|
+
let parsed;
|
|
1203
|
+
try {
|
|
1204
|
+
parsed = parseA1Range(a1);
|
|
1205
|
+
}
|
|
1206
|
+
catch {
|
|
1207
|
+
return {
|
|
1208
|
+
error: `Could not parse A1 range "${a1}".`,
|
|
1209
|
+
hint: 'Use a form like "Sheet1!A1:D10", "Sheet1!A:D", or "Sheet1" for the whole sheet.',
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
const meta = await sheets.spreadsheets.get({ spreadsheetId, fields: 'sheets.properties(sheetId,title)' });
|
|
1213
|
+
const match = meta.data.sheets?.find((s) => s.properties?.title === parsed.sheetName);
|
|
1214
|
+
if (!match || match.properties?.sheetId == null) {
|
|
1215
|
+
return {
|
|
1216
|
+
error: `No sheet/tab named "${parsed.sheetName}" in this spreadsheet.`,
|
|
1217
|
+
hint: 'Check the tab name, or use sheets_get to list sheet titles and IDs.',
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
return {
|
|
1221
|
+
sheetId: match.properties.sheetId,
|
|
1222
|
+
startRowIndex: parsed.startRowIndex,
|
|
1223
|
+
endRowIndex: parsed.endRowIndex,
|
|
1224
|
+
startColumnIndex: parsed.startColumnIndex,
|
|
1225
|
+
endColumnIndex: parsed.endColumnIndex,
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1121
1228
|
const borderSchema = z.object({
|
|
1122
1229
|
style: z.enum(['DOTTED', 'DASHED', 'SOLID', 'SOLID_MEDIUM', 'SOLID_THICK', 'NONE', 'DOUBLE']),
|
|
1123
1230
|
color: rgbColorSchema.optional(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-google-multi",
|
|
3
|
-
"version": "6.0.0-alpha.
|
|
3
|
+
"version": "6.0.0-alpha.77",
|
|
4
4
|
"mcpName": "io.github.bakissation/mcp-google-multi",
|
|
5
5
|
"description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
|
|
6
6
|
"type": "module",
|