mcp-google-multi 6.0.0-alpha.2 → 6.0.0-alpha.4

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.
@@ -0,0 +1,18 @@
1
+ import type { ApiProbeResult } from './doctor.js';
2
+ export interface ApiProbeSpec {
3
+ service: string;
4
+ /** console library id for the enable deep-link, e.g. "calendar-json". */
5
+ api: string;
6
+ url: string;
7
+ scopePrefixes: string[];
8
+ /** id-required APIs have no no-arg read; a 404 on a nonexistent id still
9
+ * proves the API is enabled (accessNotConfigured wins before routing). */
10
+ notFoundMeansEnabled?: boolean;
11
+ }
12
+ export declare const API_PROBES: ApiProbeSpec[];
13
+ export declare function planProbes(granted: string[], probes?: ApiProbeSpec[]): ApiProbeSpec[];
14
+ export interface ApiProbeDeps {
15
+ grantedScopes: (alias: string) => string[];
16
+ request: (alias: string, url: string) => Promise<void>;
17
+ }
18
+ export declare function probeApiEnablement(alias: string, deps?: ApiProbeDeps): Promise<ApiProbeResult[]>;
@@ -0,0 +1,65 @@
1
+ import { getClient } from './client.js';
2
+ import { readToken } from './token-store.js';
3
+ import { mapGoogleError } from './tools/_errors.js';
4
+ const P = 'https://www.googleapis.com/auth/';
5
+ const BOGUS_ID = 'mcp-google-multi-probe-nonexistent';
6
+ export const API_PROBES = [
7
+ { service: 'gmail', api: 'gmail', url: 'https://gmail.googleapis.com/gmail/v1/users/me/profile', scopePrefixes: [`${P}gmail.`] },
8
+ { service: 'drive', api: 'drive', url: 'https://www.googleapis.com/drive/v3/about?fields=user', scopePrefixes: [`${P}drive`] },
9
+ { service: 'calendar', api: 'calendar-json', url: 'https://www.googleapis.com/calendar/v3/users/me/calendarList?maxResults=1', scopePrefixes: [`${P}calendar`] },
10
+ // people/me needs profile scopes, not contacts; connections is the read the
11
+ // contacts grant actually authorizes.
12
+ { service: 'contacts', api: 'people', url: 'https://people.googleapis.com/v1/people/me/connections?personFields=names&pageSize=1', scopePrefixes: [`${P}contacts`] },
13
+ { service: 'sheets', api: 'sheets', url: `https://sheets.googleapis.com/v4/spreadsheets/${BOGUS_ID}`, scopePrefixes: [`${P}spreadsheets`], notFoundMeansEnabled: true },
14
+ { service: 'docs', api: 'docs', url: `https://docs.googleapis.com/v1/documents/${BOGUS_ID}`, scopePrefixes: [`${P}documents`], notFoundMeansEnabled: true },
15
+ { service: 'searchconsole', api: 'searchconsole', url: 'https://www.googleapis.com/webmasters/v3/sites', scopePrefixes: [`${P}webmasters`] },
16
+ { service: 'tasks', api: 'tasks', url: 'https://tasks.googleapis.com/tasks/v1/users/@me/lists?maxResults=1', scopePrefixes: [`${P}tasks`] },
17
+ { service: 'chat', api: 'chat', url: 'https://chat.googleapis.com/v1/spaces?pageSize=1', scopePrefixes: [`${P}chat.`] },
18
+ { service: 'meet', api: 'meet', url: 'https://meet.googleapis.com/v2/conferenceRecords?pageSize=1', scopePrefixes: [`${P}meetings.`] },
19
+ { service: 'forms', api: 'forms', url: `https://forms.googleapis.com/v1/forms/${BOGUS_ID}`, scopePrefixes: [`${P}forms.`], notFoundMeansEnabled: true },
20
+ ];
21
+ export function planProbes(granted, probes = API_PROBES) {
22
+ return probes.filter((p) => granted.some((s) => p.scopePrefixes.some((prefix) => s.startsWith(prefix))));
23
+ }
24
+ const DEFAULT_DEPS = {
25
+ grantedScopes: (alias) => {
26
+ try {
27
+ const scope = readToken(alias)?.scope;
28
+ return typeof scope === 'string' ? scope.split(' ').filter(Boolean) : [];
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ },
34
+ request: async (alias, url) => {
35
+ const auth = await getClient(alias);
36
+ await auth.request({ url, timeout: 10_000 });
37
+ },
38
+ };
39
+ export async function probeApiEnablement(alias, deps = DEFAULT_DEPS) {
40
+ const results = [];
41
+ for (const spec of planProbes(deps.grantedScopes(alias))) {
42
+ try {
43
+ await deps.request(alias, spec.url);
44
+ results.push({ service: spec.service, api: spec.api, ok: true });
45
+ }
46
+ catch (error) {
47
+ const envelope = mapGoogleError(error, alias);
48
+ if (envelope.error === 'network_error') {
49
+ // One connect failure means they will all fail: abort so section 6
50
+ // reports a single WARN "Probe could not complete" with the code.
51
+ throw new Error(envelope.message, { cause: error });
52
+ }
53
+ if (envelope.error === 'api_not_enabled') {
54
+ results.push({ service: spec.service, api: spec.api, ok: false, notEnabled: true, message: envelope.message });
55
+ }
56
+ else if (spec.notFoundMeansEnabled && envelope.error === 'not_found') {
57
+ results.push({ service: spec.service, api: spec.api, ok: true });
58
+ }
59
+ else {
60
+ results.push({ service: spec.service, api: spec.api, ok: false, message: envelope.error });
61
+ }
62
+ }
63
+ }
64
+ return results;
65
+ }
package/dist/doctor.js CHANGED
@@ -6,6 +6,7 @@ import { deriveAccountHealth } from './tools/accounts-tool.js';
6
6
  import { peekMasterKeyProvenance, deleteMasterKeyMaterial } from './master-key.js';
7
7
  import { hasToken } from './token-store.js';
8
8
  import { configDir } from './config-file.js';
9
+ import { probeApiEnablement } from './api-probe.js';
9
10
  const MIN_NODE_MAJOR = 22;
10
11
  const DEFAULT_DEPS = {
11
12
  nodeVersion: process.versions.node,
@@ -23,6 +24,7 @@ const DEFAULT_DEPS = {
23
24
  masterKeyProvenance: () => peekMasterKeyProvenance(),
24
25
  anyTokensExist: (aliases) => aliases.some((a) => hasToken(a)),
25
26
  fileExists: fs.existsSync,
27
+ probeApi: (alias) => probeApiEnablement(alias),
26
28
  };
27
29
  /** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
28
30
  export function apiEnableLink(api) {
@@ -163,6 +165,9 @@ async function sectionApiEnablement(deps, aliases) {
163
165
  // Network / transient: WARN with the target, never crash the report.
164
166
  return { id: 6, title: 'API enablement', verdict: 'warn', lines: [`Probe could not complete: ${e?.message ?? e}`] };
165
167
  }
168
+ if (results.length === 0) {
169
+ return { id: 6, title: 'API enablement', verdict: 'unknown', lines: [`No probeable service scopes granted on "${healthy}".`] };
170
+ }
166
171
  const disabled = results.filter((r) => r.notEnabled);
167
172
  const lines = results.map((r) => `${r.service}: ${r.ok ? 'enabled' : r.notEnabled ? 'NOT ENABLED' : `unknown (${r.message ?? 'error'})`}`);
168
173
  if (disabled.length > 0) {
@@ -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,4 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
- export declare function prepareLocalDest(savePath: string, filename: string): string;
3
2
  export declare const DRIVE_QUERY_HINT: string;
4
3
  export declare function normalizeDriveQuery(raw: string): string;
5
4
  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';
@@ -31,12 +32,6 @@ const COMMENT_FIELDS = `${COMMENT_BASE_FIELDS},replies(${REPLY_SUBFIELDS})`;
31
32
  const COMMENT_LIST_FIELDS = `nextPageToken,comments(${COMMENT_BASE_FIELDS},replies(${REPLY_SUBFIELDS}))`;
32
33
  const REPLY_FIELDS = `kind,htmlContent,${REPLY_SUBFIELDS}`;
33
34
  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
35
  export const DRIVE_QUERY_HINT = "Drive search syntax: a plain keyword is treated as a full-text search, but a " +
41
36
  "structured query needs an operator, e.g. \"name contains 'report'\", " +
42
37
  "\"mimeType = 'application/pdf'\", or \"'me' in owners\". " +
@@ -275,7 +270,7 @@ export function registerDriveTools(server) {
275
270
  const auth = await getClient(account);
276
271
  const drive = driveClient({ version: 'v3', auth });
277
272
  const resolvedMime = mimeTypeArg ?? (mime.lookup(localPath) || 'application/octet-stream');
278
- const fileStream = fs.createReadStream(localPath);
273
+ const fileStream = await openLocalReadStream(localPath);
279
274
  const res = await drive.files.create({
280
275
  requestBody: {
281
276
  name: filename,
@@ -414,7 +409,7 @@ export function registerDriveTools(server) {
414
409
  if (localPathArg) {
415
410
  params.media = {
416
411
  mimeType: mimeTypeArg ?? (mime.lookup(localPathArg) || 'application/octet-stream'),
417
- body: fs.createReadStream(localPathArg),
412
+ body: await openLocalReadStream(localPathArg),
418
413
  };
419
414
  if (convertTo)
420
415
  requestBody.mimeType = convertTo;
@@ -1440,7 +1435,7 @@ async function downloadAndUpload(sourceDrive, targetDrive, fileId, sourceMime, p
1440
1435
  },
1441
1436
  media: {
1442
1437
  mimeType: plan.kind === 'native' ? plan.exportMime : (sourceMime ?? 'application/octet-stream'),
1443
- body: fs.createReadStream(tmp),
1438
+ body: await openLocalReadStream(tmp),
1444
1439
  },
1445
1440
  supportsAllDrives: true,
1446
1441
  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)` }],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.2",
3
+ "version": "6.0.0-alpha.4",
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",