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.
@@ -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;
@@ -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;
@@ -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;
@@ -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?.startsWith('text/')) {
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 to file on disk'),
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 directory path to save into'),
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 dest = prepareLocalDest(savePath, filename);
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 directory path to save into'),
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
- const dest = prepareLocalDest(savePath, filename);
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 {
@@ -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 directory path to save into, e.g. /home/user/Downloads'),
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;
@@ -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
- const method = index.find((m) => m.id === methodId);
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: `Use google_api_search({query: "...", api: "${api}"}) to find the right method id.`,
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.4",
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",