mcp-google-multi 6.0.0-alpha.4 → 6.0.0-alpha.6
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/dist/scope-catalog.d.ts +1 -0
- package/dist/scope-catalog.js +1 -1
- package/dist/tools/drive.d.ts +2 -0
- package/dist/tools/drive.js +71 -24
- package/dist/tools/gmail.js +1 -1
- package/dist/tools/google-api.d.ts +4 -1
- package/dist/tools/google-api.js +27 -2
- package/package.json +1 -1
package/dist/scope-catalog.d.ts
CHANGED
|
@@ -17,3 +17,4 @@ export declare const BUNDLE_ALIASES: Record<string, string>;
|
|
|
17
17
|
export declare function resolveBundleAliases(bundles: string[]): string[];
|
|
18
18
|
/** Closest catalog key for E_UNKNOWN_BUNDLE remediation (edit distance <= 2). */
|
|
19
19
|
export declare function closestBundle(name: string): string | undefined;
|
|
20
|
+
export declare function editDistance(a: string, b: string): number;
|
package/dist/scope-catalog.js
CHANGED
|
@@ -169,7 +169,7 @@ export function closestBundle(name) {
|
|
|
169
169
|
}
|
|
170
170
|
return best;
|
|
171
171
|
}
|
|
172
|
-
function editDistance(a, b) {
|
|
172
|
+
export function editDistance(a, b) {
|
|
173
173
|
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
174
174
|
for (let j = 1; j <= b.length; j++)
|
|
175
175
|
dp[0][j] = j;
|
package/dist/tools/drive.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ToolRegistry } from '../registry.js';
|
|
2
|
+
export declare function isTextualMime(mimeType: string): boolean;
|
|
3
|
+
export declare function resolveConvertTarget(convertTo: string | undefined): string | undefined;
|
|
2
4
|
export declare const DRIVE_QUERY_HINT: string;
|
|
3
5
|
export declare function normalizeDriveQuery(raw: string): string;
|
|
4
6
|
export declare function isDriveInvalidQuery(error: any): boolean;
|
package/dist/tools/drive.js
CHANGED
|
@@ -25,6 +25,52 @@ const GOOGLE_WORKSPACE_TYPES = new Set([
|
|
|
25
25
|
'application/vnd.google-apps.presentation',
|
|
26
26
|
'application/vnd.google-apps.drawing',
|
|
27
27
|
]);
|
|
28
|
+
// drive_read inlines only textual content. Beyond text/*, RFC 6839 structured-
|
|
29
|
+
// syntax suffixes (+json/+xml/...) and a few bare application/* types are text
|
|
30
|
+
// in practice — image/svg+xml was the motivating false "binary" refusal.
|
|
31
|
+
const TEXTUAL_EXACT = new Set([
|
|
32
|
+
'application/json',
|
|
33
|
+
'application/xml',
|
|
34
|
+
'application/javascript',
|
|
35
|
+
'application/x-ndjson',
|
|
36
|
+
'application/yaml',
|
|
37
|
+
'application/x-yaml',
|
|
38
|
+
'application/sql',
|
|
39
|
+
'application/x-sh',
|
|
40
|
+
'application/csv',
|
|
41
|
+
]);
|
|
42
|
+
export function isTextualMime(mimeType) {
|
|
43
|
+
const bare = mimeType.split(';')[0].trim().toLowerCase();
|
|
44
|
+
if (bare.startsWith('text/'))
|
|
45
|
+
return true;
|
|
46
|
+
if (/\+(json|xml|yaml|toml|csv)$/.test(bare))
|
|
47
|
+
return true;
|
|
48
|
+
return TEXTUAL_EXACT.has(bare);
|
|
49
|
+
}
|
|
50
|
+
const BINARY_READ_HINT = 'Binary content cannot be inlined. Use drive_download to save the file to disk, or drive_export for Google Workspace files.';
|
|
51
|
+
// Accepted alongside the full application/vnd.google-apps.* ids so the obvious
|
|
52
|
+
// short spelling ("document") works; the enum advertises both.
|
|
53
|
+
const CONVERT_SHORTHANDS = {
|
|
54
|
+
document: 'application/vnd.google-apps.document',
|
|
55
|
+
spreadsheet: 'application/vnd.google-apps.spreadsheet',
|
|
56
|
+
presentation: 'application/vnd.google-apps.presentation',
|
|
57
|
+
drawing: 'application/vnd.google-apps.drawing',
|
|
58
|
+
};
|
|
59
|
+
export function resolveConvertTarget(convertTo) {
|
|
60
|
+
if (!convertTo)
|
|
61
|
+
return undefined;
|
|
62
|
+
return CONVERT_SHORTHANDS[convertTo] ?? convertTo;
|
|
63
|
+
}
|
|
64
|
+
const CONVERT_TO_VALUES = [
|
|
65
|
+
'document',
|
|
66
|
+
'spreadsheet',
|
|
67
|
+
'presentation',
|
|
68
|
+
'drawing',
|
|
69
|
+
'application/vnd.google-apps.document',
|
|
70
|
+
'application/vnd.google-apps.spreadsheet',
|
|
71
|
+
'application/vnd.google-apps.presentation',
|
|
72
|
+
'application/vnd.google-apps.drawing',
|
|
73
|
+
];
|
|
28
74
|
// Comment/Reply fields list — Drive API requires explicit `fields` on every call.
|
|
29
75
|
const COMMENT_BASE_FIELDS = 'id,kind,content,htmlContent,createdTime,modifiedTime,resolved,anchor,author,deleted,quotedFileContent';
|
|
30
76
|
const REPLY_SUBFIELDS = 'id,content,action,createdTime,modifiedTime,author,deleted';
|
|
@@ -126,7 +172,7 @@ export function registerDriveTools(server) {
|
|
|
126
172
|
});
|
|
127
173
|
server.registerTool('drive_read', {
|
|
128
174
|
_meta: { 'anthropic/maxResultSizeChars': 100_000 },
|
|
129
|
-
description: 'Read the content of a Google Drive file (returns up to maxChars characters per call; non-Google-native files over 2MB return too_large)',
|
|
175
|
+
description: 'Read the content of a Google Drive file: Workspace docs and textual types (text/*, JSON/XML/SVG and similar) inline; other binaries return error:binary (returns up to maxChars characters per call; non-Google-native files over 2MB return too_large)',
|
|
130
176
|
inputSchema: {
|
|
131
177
|
account: accountEnum.describe('Google account alias'),
|
|
132
178
|
fileId: z.string().describe('Google Drive file ID'),
|
|
@@ -179,6 +225,7 @@ export function registerDriveTools(server) {
|
|
|
179
225
|
name,
|
|
180
226
|
mimeType,
|
|
181
227
|
error: 'binary',
|
|
228
|
+
hint: BINARY_READ_HINT,
|
|
182
229
|
webViewLink,
|
|
183
230
|
}, null, 2),
|
|
184
231
|
}],
|
|
@@ -199,7 +246,7 @@ export function registerDriveTools(server) {
|
|
|
199
246
|
}],
|
|
200
247
|
};
|
|
201
248
|
}
|
|
202
|
-
if (mimeType
|
|
249
|
+
if (mimeType && isTextualMime(mimeType)) {
|
|
203
250
|
const downloaded = await drive.files.get({ fileId, alt: 'media', supportsAllDrives: true }, { responseType: 'text' });
|
|
204
251
|
return respond(String(downloaded.data));
|
|
205
252
|
}
|
|
@@ -211,6 +258,7 @@ export function registerDriveTools(server) {
|
|
|
211
258
|
name,
|
|
212
259
|
mimeType,
|
|
213
260
|
error: 'binary',
|
|
261
|
+
hint: BINARY_READ_HINT,
|
|
214
262
|
webViewLink,
|
|
215
263
|
}, null, 2),
|
|
216
264
|
}],
|
|
@@ -254,15 +302,10 @@ export function registerDriveTools(server) {
|
|
|
254
302
|
description: 'Upload a local file to Google Drive. Pass `convertTo` to import it as a native, editable Google Doc/Sheet/Slides/Drawing instead of storing the raw bytes.',
|
|
255
303
|
inputSchema: {
|
|
256
304
|
account: accountEnum.describe('Google account alias'),
|
|
257
|
-
localPath: z.string().describe('Absolute path
|
|
305
|
+
localPath: z.string().describe('Absolute path of the SOURCE file on disk to upload (on the machine running the server; this is not savePath)'),
|
|
258
306
|
filename: z.string().describe('Name as it appears in Drive'),
|
|
259
307
|
mimeType: z.string().optional().describe('Source MIME type of the local file (inferred from extension if omitted). With `convertTo`, this is the format Drive imports from.'),
|
|
260
|
-
convertTo: z.enum(
|
|
261
|
-
'application/vnd.google-apps.document',
|
|
262
|
-
'application/vnd.google-apps.spreadsheet',
|
|
263
|
-
'application/vnd.google-apps.presentation',
|
|
264
|
-
'application/vnd.google-apps.drawing',
|
|
265
|
-
]).optional().describe('Convert the upload into this native Google Workspace type on import (e.g. upload .md/.html/.docx/.txt with convertTo=...google-apps.document to get a real Google Doc). Source must be an importable format. Omit to store the file as-is.'),
|
|
308
|
+
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.'),
|
|
266
309
|
parentFolderId: z.string().optional().describe('Parent folder ID (defaults to My Drive root)'),
|
|
267
310
|
},
|
|
268
311
|
}, async ({ account, localPath, filename, mimeType: mimeTypeArg, convertTo, parentFolderId }) => {
|
|
@@ -276,7 +319,7 @@ export function registerDriveTools(server) {
|
|
|
276
319
|
name: filename,
|
|
277
320
|
parents: parentFolderId ? [parentFolderId] : undefined,
|
|
278
321
|
// Setting a google-apps target type makes Drive convert the media on import.
|
|
279
|
-
...(convertTo ? { mimeType: convertTo } : {}),
|
|
322
|
+
...(convertTo ? { mimeType: resolveConvertTarget(convertTo) } : {}),
|
|
280
323
|
},
|
|
281
324
|
media: {
|
|
282
325
|
mimeType: resolvedMime,
|
|
@@ -298,14 +341,17 @@ export function registerDriveTools(server) {
|
|
|
298
341
|
inputSchema: {
|
|
299
342
|
account: accountEnum.describe('Google account alias'),
|
|
300
343
|
fileId: z.string().describe('Google Drive file ID'),
|
|
301
|
-
savePath: z.string().describe('Absolute
|
|
302
|
-
filename: z.string().describe('Filename to save as'),
|
|
344
|
+
savePath: z.string().describe('Absolute DIRECTORY path to save into (created if missing, on the machine running the server); the file name comes from `filename`'),
|
|
345
|
+
filename: z.string().optional().describe('Filename to save as (defaults to the file name in Drive)'),
|
|
303
346
|
},
|
|
304
347
|
}, async ({ account, fileId, savePath, filename }) => {
|
|
305
348
|
try {
|
|
306
349
|
const auth = await getClient(account);
|
|
307
350
|
const drive = driveClient({ version: 'v3', auth });
|
|
308
|
-
const
|
|
351
|
+
const name = filename
|
|
352
|
+
?? (await drive.files.get({ fileId, fields: 'name', supportsAllDrives: true })).data.name
|
|
353
|
+
?? fileId;
|
|
354
|
+
const dest = prepareLocalDest(savePath, name);
|
|
309
355
|
const res = await drive.files.get({ fileId, alt: 'media', supportsAllDrives: true }, { responseType: 'stream' });
|
|
310
356
|
// pipeline destroys both streams on source/sink error; raw .pipe leaks the partial file.
|
|
311
357
|
await pipeline(res.data, fs.createWriteStream(dest, { mode: 0o600 }));
|
|
@@ -324,14 +370,20 @@ export function registerDriveTools(server) {
|
|
|
324
370
|
account: accountEnum.describe('Google account alias'),
|
|
325
371
|
fileId: z.string().describe('Google Drive file ID'),
|
|
326
372
|
mimeType: z.string().describe('Target export MIME type (e.g. "application/pdf", "text/markdown", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")'),
|
|
327
|
-
savePath: z.string().describe('Absolute
|
|
328
|
-
filename: z.string().describe('Filename to save as'),
|
|
373
|
+
savePath: z.string().describe('Absolute DIRECTORY path to save into (created if missing, on the machine running the server); the file name comes from `filename`'),
|
|
374
|
+
filename: z.string().optional().describe('Filename to save as (defaults to the Drive name plus the extension implied by mimeType)'),
|
|
329
375
|
},
|
|
330
376
|
}, async ({ account, fileId, mimeType: exportMime, savePath, filename }) => {
|
|
331
377
|
try {
|
|
332
378
|
const auth = await getClient(account);
|
|
333
379
|
const drive = driveClient({ version: 'v3', auth });
|
|
334
|
-
|
|
380
|
+
let name = filename;
|
|
381
|
+
if (!name) {
|
|
382
|
+
const meta = await drive.files.get({ fileId, fields: 'name', supportsAllDrives: true });
|
|
383
|
+
const ext = mime.extension(exportMime);
|
|
384
|
+
name = `${meta.data.name ?? fileId}${ext ? `.${ext}` : ''}`;
|
|
385
|
+
}
|
|
386
|
+
const dest = prepareLocalDest(savePath, name);
|
|
335
387
|
const res = await drive.files.export({ fileId, mimeType: exportMime }, { responseType: 'stream' });
|
|
336
388
|
// pipeline destroys both streams on source/sink error; raw .pipe leaks the partial file.
|
|
337
389
|
await pipeline(res.data, fs.createWriteStream(dest, { mode: 0o600 }));
|
|
@@ -379,14 +431,9 @@ export function registerDriveTools(server) {
|
|
|
379
431
|
fileId: z.string().describe('Google Drive file ID'),
|
|
380
432
|
newName: z.string().optional().describe('New filename'),
|
|
381
433
|
newParentFolderId: z.string().optional().describe('Move to this folder'),
|
|
382
|
-
localPath: z.string().optional().describe('Replace file content with this local file'),
|
|
434
|
+
localPath: z.string().optional().describe('Replace file content with this local file (path on the machine running the server)'),
|
|
383
435
|
mimeType: z.string().optional().describe('MIME type of the replacement file (required if localPath is provided)'),
|
|
384
|
-
convertTo: z.enum(
|
|
385
|
-
'application/vnd.google-apps.document',
|
|
386
|
-
'application/vnd.google-apps.spreadsheet',
|
|
387
|
-
'application/vnd.google-apps.presentation',
|
|
388
|
-
'application/vnd.google-apps.drawing',
|
|
389
|
-
]).optional().describe('When replacing content via localPath, convert the new content into this native Google Workspace type on import (e.g. replace a Google Doc body from a local .docx). Source must be an importable format.'),
|
|
436
|
+
convertTo: z.enum(CONVERT_TO_VALUES).optional().describe('When replacing content via localPath, convert the new content into this native Google Workspace type on import: "document" | "spreadsheet" | "presentation" | "drawing" (full application/vnd.google-apps.* ids also accepted).'),
|
|
390
437
|
},
|
|
391
438
|
}, async ({ account, fileId, newName, newParentFolderId, localPath: localPathArg, mimeType: mimeTypeArg, convertTo }) => {
|
|
392
439
|
try {
|
|
@@ -412,7 +459,7 @@ export function registerDriveTools(server) {
|
|
|
412
459
|
body: await openLocalReadStream(localPathArg),
|
|
413
460
|
};
|
|
414
461
|
if (convertTo)
|
|
415
|
-
requestBody.mimeType = convertTo;
|
|
462
|
+
requestBody.mimeType = resolveConvertTarget(convertTo);
|
|
416
463
|
}
|
|
417
464
|
const res = await drive.files.update(params);
|
|
418
465
|
return {
|
package/dist/tools/gmail.js
CHANGED
|
@@ -665,7 +665,7 @@ export function registerGmailTools(server) {
|
|
|
665
665
|
messageId: z.string().describe('The Gmail message ID'),
|
|
666
666
|
attachmentId: z.string().describe('The attachment ID from gmail_read response'),
|
|
667
667
|
filename: z.string().describe('Filename to save as (e.g. report.xlsx)'),
|
|
668
|
-
savePath: z.string().describe('Absolute
|
|
668
|
+
savePath: z.string().describe('Absolute DIRECTORY path to save into (created if missing, on the machine running the server), e.g. /home/user/Downloads; the file name comes from `filename`'),
|
|
669
669
|
},
|
|
670
670
|
}, async ({ account, messageId, attachmentId, filename, savePath }) => {
|
|
671
671
|
try {
|
|
@@ -2,9 +2,12 @@ import type { ToolRegistry } from '../registry.js';
|
|
|
2
2
|
import { type Policy } from '../write-control.js';
|
|
3
3
|
import { getClient } from '../client.js';
|
|
4
4
|
import { type Toolsets } from '../toolsets.js';
|
|
5
|
-
import { type DiscoveryDeps } from '../discovery-client.js';
|
|
5
|
+
import { type DiscoveryDeps, type DiscoveryMethod } from '../discovery-client.js';
|
|
6
6
|
export interface EscapeDeps extends DiscoveryDeps {
|
|
7
7
|
getClientFn?: typeof getClient;
|
|
8
8
|
toolsets?: Toolsets;
|
|
9
9
|
}
|
|
10
|
+
/** Up to three closest known ids for the unknown_method did-you-mean hint;
|
|
11
|
+
* bounded distance so unrelated ids never masquerade as suggestions. */
|
|
12
|
+
export declare function nearestMethodIds(methodId: string, index: DiscoveryMethod[]): string[];
|
|
10
13
|
export declare function registerEscapeTools(registry: ToolRegistry, policy: Policy, deps?: EscapeDeps): void;
|
package/dist/tools/google-api.js
CHANGED
|
@@ -4,6 +4,7 @@ import { accountAliasSchema } from '../accounts.js';
|
|
|
4
4
|
import { getClient } from '../client.js';
|
|
5
5
|
import { coerceJson } from './_coerce.js';
|
|
6
6
|
import { getToolsets, toolsetEnabled } from '../toolsets.js';
|
|
7
|
+
import { editDistance } from '../scope-catalog.js';
|
|
7
8
|
import { executeApiMethod, jsonResult } from '../executor.js';
|
|
8
9
|
import { WORKSPACE_APIS, cudFromMethod, loadMethodIndex, searchMethods, } from '../discovery-client.js';
|
|
9
10
|
const accountEnum = accountAliasSchema.optional();
|
|
@@ -29,6 +30,17 @@ const SERVICE_FOR_ALIAS = {
|
|
|
29
30
|
admin_datatransfer: 'admin',
|
|
30
31
|
groupssettings: 'groupssettings',
|
|
31
32
|
};
|
|
33
|
+
/** Up to three closest known ids for the unknown_method did-you-mean hint;
|
|
34
|
+
* bounded distance so unrelated ids never masquerade as suggestions. */
|
|
35
|
+
export function nearestMethodIds(methodId, index) {
|
|
36
|
+
const maxDist = Math.max(3, Math.floor(methodId.length / 3));
|
|
37
|
+
return index
|
|
38
|
+
.map((m) => ({ id: m.id, d: editDistance(methodId.toLowerCase(), m.id.toLowerCase()) }))
|
|
39
|
+
.filter((x) => x.d <= maxDist)
|
|
40
|
+
.sort((a, b) => a.d - b.d)
|
|
41
|
+
.slice(0, 3)
|
|
42
|
+
.map((x) => x.id);
|
|
43
|
+
}
|
|
32
44
|
function describeMethod(m) {
|
|
33
45
|
return {
|
|
34
46
|
api: m.api,
|
|
@@ -127,12 +139,25 @@ export function registerEscapeTools(registry, policy, deps = {}) {
|
|
|
127
139
|
catch (err) {
|
|
128
140
|
return jsonResult({ error: 'discovery_unavailable', message: err.message, retriable: true, account }, true);
|
|
129
141
|
}
|
|
130
|
-
|
|
142
|
+
let method = index.find((m) => m.id === methodId);
|
|
143
|
+
if (!method) {
|
|
144
|
+
// Some discovery docs keep a legacy id prefix (the searchconsole doc's
|
|
145
|
+
// methods are webmasters.*): when the caller prefixed with our api
|
|
146
|
+
// alias, retry under the doc's own prefix before failing.
|
|
147
|
+
const docPrefix = index[0]?.id.split('.')[0];
|
|
148
|
+
const [head, ...rest] = String(methodId).split('.');
|
|
149
|
+
if (docPrefix && head === api && head !== docPrefix && rest.length > 0) {
|
|
150
|
+
const swapped = [docPrefix, ...rest].join('.');
|
|
151
|
+
method = index.find((m) => m.id === swapped);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
131
154
|
if (!method) {
|
|
155
|
+
const near = nearestMethodIds(String(methodId), index);
|
|
132
156
|
return jsonResult({
|
|
133
157
|
error: 'unknown_method',
|
|
134
158
|
message: `No method "${methodId}" in ${api}.`,
|
|
135
|
-
hint: `
|
|
159
|
+
hint: `${near.length ? `Did you mean: ${near.join(', ')}? ` : ''}` +
|
|
160
|
+
`Use google_api_search({query: "...", api: "${api}"}) to find the right method id.`,
|
|
136
161
|
retriable: false,
|
|
137
162
|
account,
|
|
138
163
|
}, true);
|
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.6",
|
|
4
4
|
"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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|