mcp-google-multi 6.0.0-alpha.3 → 6.0.0-alpha.5

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;
@@ -20,6 +20,10 @@ const RETRIABLE_NET_CODES = new Set([
20
20
  'EHOSTUNREACH', 'EPIPE', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET',
21
21
  ]);
22
22
  const NET_CODES = new Set([...RETRIABLE_NET_CODES, 'ENOTFOUND']);
23
+ // Local-filesystem syscall codes from caller-supplied paths (localPath/savePath).
24
+ // String codes, so they never collide with Google's numeric statuses; the
25
+ // network codes above are deliberately excluded.
26
+ const LOCAL_FS_CODES = new Set(['ENOENT', 'EACCES', 'EISDIR', 'ENOTDIR', 'EPERM', 'ELOOP', 'ENAMETOOLONG', 'ENOSPC']);
23
27
  /** First known network code on the error or its cause chain (GaxiosError.cause
24
28
  * -> FetchError; undici TypeError.cause -> AggregateError.errors). */
25
29
  function netCodeOf(error) {
@@ -137,6 +141,18 @@ export function mapGoogleError(error, account, forbiddenHint, scopeContext) {
137
141
  return { error: 'upstream_error', message, retriable: true, account };
138
142
  }
139
143
  if (status === undefined) {
144
+ const fsCode = typeof error?.code === 'string' && LOCAL_FS_CODES.has(error.code) ? error.code : undefined;
145
+ if (fsCode) {
146
+ const p = typeof error?.path === 'string' ? ` "${error.path}"` : '';
147
+ return {
148
+ error: 'invalid_params',
149
+ message: `Cannot access local path${p}: ${fsCode}`,
150
+ hint: 'The path must exist on the machine running this server and be accessible to it. ' +
151
+ 'When the server runs remotely (HTTP transport), paths on your own machine are not visible to it.',
152
+ retriable: false,
153
+ account,
154
+ };
155
+ }
140
156
  const netCode = netCodeOf(error);
141
157
  if (netCode) {
142
158
  return {
@@ -0,0 +1,3 @@
1
+ import * as fs from 'fs';
2
+ export declare function prepareLocalDest(savePath: string, filename: string): string;
3
+ export declare function openLocalReadStream(localPath: string): Promise<fs.ReadStream>;
@@ -0,0 +1,29 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ // path.basename() is a traversal guard — a caller-supplied filename must never escape savePath.
4
+ export function prepareLocalDest(savePath, filename) {
5
+ const dest = path.join(savePath, path.basename(filename));
6
+ fs.mkdirSync(savePath, { recursive: true });
7
+ return dest;
8
+ }
9
+ // fs.createReadStream() reports an unopenable path as an async 'error' EVENT;
10
+ // with no listener attached, that single event kills the whole process — fatal
11
+ // for the shared HTTP transport. Opening the fd first turns the open-failure
12
+ // class (ENOENT/EACCES/...) into a normal rejection the caller's try/catch can
13
+ // map to an error envelope.
14
+ export async function openLocalReadStream(localPath) {
15
+ const handle = await fs.promises.open(localPath, 'r');
16
+ // open() succeeds on a directory; fail it here rather than as an async read error.
17
+ if ((await handle.stat()).isDirectory()) {
18
+ await handle.close();
19
+ throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${localPath}'`), {
20
+ code: 'EISDIR',
21
+ path: localPath,
22
+ });
23
+ }
24
+ const stream = handle.createReadStream();
25
+ // Mid-read errors still reach the consumer through its own listeners; this
26
+ // one only closes the unhandled-'error' crash path.
27
+ stream.on('error', () => { });
28
+ return stream;
29
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
- export declare function prepareLocalDest(savePath: string, filename: string): string;
2
+ export declare function isTextualMime(mimeType: string): boolean;
3
3
  export declare const DRIVE_QUERY_HINT: string;
4
4
  export declare function normalizeDriveQuery(raw: string): string;
5
5
  export declare function isDriveInvalidQuery(error: any): boolean;
@@ -4,6 +4,7 @@ import { drive as driveClient } from '@googleapis/drive';
4
4
  import { accountAliasSchema, getAccountSet } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError } from './_errors.js';
7
+ import { openLocalReadStream, prepareLocalDest } from './_local-files.js';
7
8
  import { isAllowed, writeDisabledResult } from '../write-control.js';
8
9
  import { capText } from '../trim.js';
9
10
  import * as fs from 'fs';
@@ -24,6 +25,29 @@ const GOOGLE_WORKSPACE_TYPES = new Set([
24
25
  'application/vnd.google-apps.presentation',
25
26
  'application/vnd.google-apps.drawing',
26
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.';
27
51
  // Comment/Reply fields list — Drive API requires explicit `fields` on every call.
28
52
  const COMMENT_BASE_FIELDS = 'id,kind,content,htmlContent,createdTime,modifiedTime,resolved,anchor,author,deleted,quotedFileContent';
29
53
  const REPLY_SUBFIELDS = 'id,content,action,createdTime,modifiedTime,author,deleted';
@@ -31,12 +55,6 @@ const COMMENT_FIELDS = `${COMMENT_BASE_FIELDS},replies(${REPLY_SUBFIELDS})`;
31
55
  const COMMENT_LIST_FIELDS = `nextPageToken,comments(${COMMENT_BASE_FIELDS},replies(${REPLY_SUBFIELDS}))`;
32
56
  const REPLY_FIELDS = `kind,htmlContent,${REPLY_SUBFIELDS}`;
33
57
  const REPLY_LIST_FIELDS = `nextPageToken,replies(${REPLY_FIELDS})`;
34
- // path.basename() is a traversal guard — a caller-supplied filename must never escape savePath.
35
- export function prepareLocalDest(savePath, filename) {
36
- const dest = path.join(savePath, path.basename(filename));
37
- fs.mkdirSync(savePath, { recursive: true });
38
- return dest;
39
- }
40
58
  export const DRIVE_QUERY_HINT = "Drive search syntax: a plain keyword is treated as a full-text search, but a " +
41
59
  "structured query needs an operator, e.g. \"name contains 'report'\", " +
42
60
  "\"mimeType = 'application/pdf'\", or \"'me' in owners\". " +
@@ -131,7 +149,7 @@ export function registerDriveTools(server) {
131
149
  });
132
150
  server.registerTool('drive_read', {
133
151
  _meta: { 'anthropic/maxResultSizeChars': 100_000 },
134
- 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)',
152
+ 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)',
135
153
  inputSchema: {
136
154
  account: accountEnum.describe('Google account alias'),
137
155
  fileId: z.string().describe('Google Drive file ID'),
@@ -184,6 +202,7 @@ export function registerDriveTools(server) {
184
202
  name,
185
203
  mimeType,
186
204
  error: 'binary',
205
+ hint: BINARY_READ_HINT,
187
206
  webViewLink,
188
207
  }, null, 2),
189
208
  }],
@@ -204,7 +223,7 @@ export function registerDriveTools(server) {
204
223
  }],
205
224
  };
206
225
  }
207
- if (mimeType?.startsWith('text/')) {
226
+ if (mimeType && isTextualMime(mimeType)) {
208
227
  const downloaded = await drive.files.get({ fileId, alt: 'media', supportsAllDrives: true }, { responseType: 'text' });
209
228
  return respond(String(downloaded.data));
210
229
  }
@@ -216,6 +235,7 @@ export function registerDriveTools(server) {
216
235
  name,
217
236
  mimeType,
218
237
  error: 'binary',
238
+ hint: BINARY_READ_HINT,
219
239
  webViewLink,
220
240
  }, null, 2),
221
241
  }],
@@ -275,7 +295,7 @@ export function registerDriveTools(server) {
275
295
  const auth = await getClient(account);
276
296
  const drive = driveClient({ version: 'v3', auth });
277
297
  const resolvedMime = mimeTypeArg ?? (mime.lookup(localPath) || 'application/octet-stream');
278
- const fileStream = fs.createReadStream(localPath);
298
+ const fileStream = await openLocalReadStream(localPath);
279
299
  const res = await drive.files.create({
280
300
  requestBody: {
281
301
  name: filename,
@@ -414,7 +434,7 @@ export function registerDriveTools(server) {
414
434
  if (localPathArg) {
415
435
  params.media = {
416
436
  mimeType: mimeTypeArg ?? (mime.lookup(localPathArg) || 'application/octet-stream'),
417
- body: fs.createReadStream(localPathArg),
437
+ body: await openLocalReadStream(localPathArg),
418
438
  };
419
439
  if (convertTo)
420
440
  requestBody.mimeType = convertTo;
@@ -1440,7 +1460,7 @@ async function downloadAndUpload(sourceDrive, targetDrive, fileId, sourceMime, p
1440
1460
  },
1441
1461
  media: {
1442
1462
  mimeType: plan.kind === 'native' ? plan.exportMime : (sourceMime ?? 'application/octet-stream'),
1443
- body: fs.createReadStream(tmp),
1463
+ body: await openLocalReadStream(tmp),
1444
1464
  },
1445
1465
  supportsAllDrives: true,
1446
1466
  fields: 'id,name,mimeType,webViewLink',
@@ -5,6 +5,7 @@ import { accountAliasSchema } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError, mapGoogleError } from './_errors.js';
7
7
  import { buildReplyHeaders, composeRaw, renderMarkdown, htmlToMarkdown, HeaderInjectionError } from './gmail-mime.js';
8
+ import { prepareLocalDest } from './_local-files.js';
8
9
  import addressparser from 'nodemailer/lib/addressparser/index.js';
9
10
  import { lookup as lookupMime } from 'mime-types';
10
11
  import { configDir } from '../config-file.js';
@@ -679,8 +680,7 @@ export function registerGmailTools(server) {
679
680
  if (!data)
680
681
  throw new Error('No attachment data returned');
681
682
  const buffer = Buffer.from(data, 'base64url');
682
- // Strip path components so callers can't escape savePath via "../".
683
- const fullPath = path.join(savePath, path.basename(filename));
683
+ const fullPath = prepareLocalDest(savePath, filename);
684
684
  await fs.promises.writeFile(fullPath, buffer, { mode: 0o600 });
685
685
  return {
686
686
  content: [{ type: 'text', text: `Saved to ${fullPath} (${buffer.length} bytes)` }],
@@ -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.3",
3
+ "version": "6.0.0-alpha.5",
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",