mcp-google-multi 6.0.0-alpha.19 → 6.0.0-alpha.20

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.
@@ -2,6 +2,13 @@ export declare const SUPPORTED_APIS: Record<string, {
2
2
  id: string;
3
3
  version: string;
4
4
  }>;
5
+ export declare const API_ALIASES: Record<string, string[]>;
6
+ /**
7
+ * Resolve an `api` argument to real SUPPORTED_APIS keys: exact match, then
8
+ * case/punctuation-normalized ("Search-Console" -> searchconsole), then the
9
+ * alias map. null = genuinely unknown.
10
+ */
11
+ export declare function resolveApiAliases(api: string): string[] | null;
5
12
  export interface DiscoveryParam {
6
13
  location: 'path' | 'query';
7
14
  required?: boolean;
@@ -35,6 +35,38 @@ export const SUPPORTED_APIS = {
35
35
  vault: { id: 'vault', version: 'v1' },
36
36
  workspaceevents: { id: 'workspaceevents', version: 'v1' },
37
37
  };
38
+ // Names agents actually type for an API, mapped to real SUPPORTED_APIS keys.
39
+ // Motivated by observed escape-hatch misses ("analytics" is two Discovery
40
+ // APIs, the Admin SDK is three); keep entries plural-target only when the
41
+ // split is real.
42
+ export const API_ALIASES = {
43
+ analytics: ['analyticsadmin', 'analyticsdata'],
44
+ ga4: ['analyticsadmin', 'analyticsdata'],
45
+ googleanalytics: ['analyticsadmin', 'analyticsdata'],
46
+ admin: ['admin_directory', 'admin_reports', 'admin_datatransfer'],
47
+ adminsdk: ['admin_directory', 'admin_reports', 'admin_datatransfer'],
48
+ directory: ['admin_directory'],
49
+ webmasters: ['searchconsole'],
50
+ gsc: ['searchconsole'],
51
+ contacts: ['people'],
52
+ appsscript: ['script'],
53
+ appscript: ['script'],
54
+ gmailpostmastertools: ['postmaster'],
55
+ };
56
+ /**
57
+ * Resolve an `api` argument to real SUPPORTED_APIS keys: exact match, then
58
+ * case/punctuation-normalized ("Search-Console" -> searchconsole), then the
59
+ * alias map. null = genuinely unknown.
60
+ */
61
+ export function resolveApiAliases(api) {
62
+ if (SUPPORTED_APIS[api])
63
+ return [api];
64
+ const norm = api.trim().toLowerCase().replace(/[^a-z0-9]/g, '');
65
+ const direct = Object.keys(SUPPORTED_APIS).find((k) => k.replace(/[^a-z0-9]/g, '') === norm);
66
+ if (direct)
67
+ return [direct];
68
+ return API_ALIASES[norm] ?? null;
69
+ }
38
70
  const TTL_MS = 7 * 24 * 60 * 60 * 1000;
39
71
  const STALE_RETRY_MS = 5 * 60 * 1000;
40
72
  const FETCH_TIMEOUT_MS = 10_000;
@@ -119,13 +119,32 @@ export function mapGoogleError(error, account, forbiddenHint, scopeContext) {
119
119
  account,
120
120
  };
121
121
  }
122
- return { error: 'forbidden', message, hint: forbiddenHint, retriable: false, account };
122
+ return {
123
+ error: 'forbidden',
124
+ message,
125
+ hint: forbiddenHint ??
126
+ `Google denied access at the resource level (not a scope problem): check that "${account}" actually has access to this item, e.g. it is shared with that account, and that you picked the right account alias.`,
127
+ retriable: false,
128
+ account,
129
+ };
123
130
  }
124
131
  if (status === 400 && /invalid[_ ]scope/i.test(message)) {
125
- return { error: 'invalid_scope', message, retriable: false, account };
132
+ return {
133
+ error: 'invalid_scope',
134
+ message,
135
+ hint: 'One of the requested OAuth scopes is malformed or unavailable to this client. Run `config check` to review the account scope profile, fix it, then re-auth.',
136
+ retriable: false,
137
+ account,
138
+ };
126
139
  }
127
140
  if (status === 404) {
128
- return { error: 'not_found', message, retriable: false, account };
141
+ return {
142
+ error: 'not_found',
143
+ message,
144
+ hint: `The ID does not exist or is not visible to "${account}". IDs are account-specific: re-fetch it with the matching list/search tool, and check the account alias is the one that owns the resource.`,
145
+ retriable: false,
146
+ account,
147
+ };
129
148
  }
130
149
  if (status === 429) {
131
150
  const retryAfter = error?.response?.headers?.['retry-after'];
@@ -151,7 +170,13 @@ export function mapGoogleError(error, account, forbiddenHint, scopeContext) {
151
170
  };
152
171
  }
153
172
  if (status !== undefined && status >= 500) {
154
- return { error: 'upstream_error', message, retriable: true, account };
173
+ return {
174
+ error: 'upstream_error',
175
+ message,
176
+ hint: 'Google-side server error, usually transient: retry, with backoff if it repeats.',
177
+ retriable: true,
178
+ account,
179
+ };
155
180
  }
156
181
  if (status === undefined) {
157
182
  const fsCode = typeof error?.code === 'string' && LOCAL_FS_CODES.has(error.code) ? error.code : undefined;
@@ -178,7 +203,17 @@ export function mapGoogleError(error, account, forbiddenHint, scopeContext) {
178
203
  };
179
204
  }
180
205
  }
181
- return { error: 'upstream_error', message, retriable: false, account };
206
+ // Passthrough floor: still emit a hint so no envelope leaves the mapper
207
+ // without a next step. A 400 here is a request Google parsed and rejected.
208
+ return {
209
+ error: 'upstream_error',
210
+ message,
211
+ hint: status === 400
212
+ ? 'Google rejected the request as malformed: an argument is likely wrong or missing. Check IDs, enum values and formats against the tool description before retrying.'
213
+ : 'Unclassified error: the message above is the best signal. Retry only if it reads as transient; otherwise change the request rather than repeating it.',
214
+ retriable: false,
215
+ account,
216
+ };
182
217
  }
183
218
  export function handleGoogleApiError(error, account, forbiddenHint, scopeContext) {
184
219
  const envelope = mapGoogleError(error, account, forbiddenHint, scopeContext);
@@ -2,8 +2,13 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  // path.basename() is a traversal guard — a caller-supplied filename must never escape savePath.
4
4
  export function prepareLocalDest(savePath, filename) {
5
- const dest = path.join(savePath, path.basename(filename));
6
- fs.mkdirSync(savePath, { recursive: true });
5
+ const name = path.basename(filename);
6
+ // Agents routinely pass the intended FILE path as savePath and repeat the
7
+ // name in `filename`; a blind join would mkdir a directory named like the
8
+ // file and bury the download inside it, so strip the duplicated leaf.
9
+ const dir = path.basename(savePath) === name ? path.dirname(savePath) : savePath;
10
+ const dest = path.join(dir, name);
11
+ fs.mkdirSync(dir, { recursive: true });
7
12
  return dest;
8
13
  }
9
14
  // fs.createReadStream() reports an unopenable path as an async 'error' EVENT;
@@ -6,7 +6,7 @@ import { coerceJson } from './_coerce.js';
6
6
  import { getToolsets, toolsetEnabled } from '../toolsets.js';
7
7
  import { editDistance } from '../scope-catalog.js';
8
8
  import { executeApiMethod, jsonResult } from '../executor.js';
9
- import { SUPPORTED_APIS, cudFromMethod, loadMethodIndex, searchMethods, } from '../discovery-client.js';
9
+ import { SUPPORTED_APIS, resolveApiAliases, cudFromMethod, loadMethodIndex, searchMethods, } from '../discovery-client.js';
10
10
  const accountEnum = accountAliasSchema.optional();
11
11
  // Policy/toolset namespace for each API alias must match the NAMED tools' service
12
12
  // names, or user deny globs and GOOGLE_TOOLSETS silently miss escape-hatch calls.
@@ -80,12 +80,20 @@ export function registerEscapeTools(registry, policy, deps = {}) {
80
80
  },
81
81
  annotations: { openWorldHint: true },
82
82
  }, async ({ query, api, maxResults }) => {
83
- if (api && !SUPPORTED_APIS[api]) {
84
- return jsonResult({ error: 'unknown_api', message: `Unknown api "${api}".`, hint: `Known APIs: ${apiList}`, retriable: false }, true);
83
+ // Alias resolution: "analytics" fans out to both GA4 Discovery APIs
84
+ // instead of dead-ending on unknown_api.
85
+ let requested = null;
86
+ if (api) {
87
+ requested = resolveApiAliases(api);
88
+ if (!requested) {
89
+ return jsonResult({ error: 'unknown_api', message: `Unknown api "${api}".`, hint: `Known APIs: ${apiList}`, retriable: false }, true);
90
+ }
91
+ const enabled = requested.filter(apiEnabled);
92
+ if (enabled.length === 0)
93
+ return toolsetDisabled(requested[0]);
94
+ requested = enabled;
85
95
  }
86
- if (api && !apiEnabled(api))
87
- return toolsetDisabled(api);
88
- const apis = api ? [api] : enabledApis;
96
+ const apis = requested ?? enabledApis;
89
97
  const unavailable = [];
90
98
  const indexes = await Promise.all(apis.map(async (a) => {
91
99
  try {
@@ -98,6 +106,9 @@ export function registerEscapeTools(registry, policy, deps = {}) {
98
106
  }));
99
107
  const matches = searchMethods(indexes.flat(), query, maxResults ?? 10);
100
108
  return jsonResult({
109
+ // Teach the resolved keys whenever the input wasn't already one, so the
110
+ // follow-up google_api_call uses a real key.
111
+ ...(requested && (requested.length > 1 || requested[0] !== api) ? { resolvedApi: requested } : {}),
101
112
  methods: matches.map(describeMethod),
102
113
  ...(unavailable.length > 0 ? { unavailableApis: unavailable } : {}),
103
114
  next: 'Invoke with google_api_call({account, api, methodId, pathParams, queryParams, body}).',
@@ -129,14 +140,20 @@ export function registerEscapeTools(registry, policy, deps = {}) {
129
140
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
130
141
  _meta: { 'anthropic/maxResultSizeChars': 100_000 },
131
142
  }, async ({ account, api, methodId, pathParams, queryParams, body }) => {
132
- if (!SUPPORTED_APIS[api]) {
143
+ const resolved = resolveApiAliases(api);
144
+ if (!resolved) {
133
145
  return jsonResult({ error: 'unknown_api', message: `Unknown api "${api}".`, hint: `Known APIs: ${apiList}`, retriable: false, account }, true);
134
146
  }
135
- if (!apiEnabled(api))
136
- return toolsetDisabled(api);
147
+ if (resolved.length > 1) {
148
+ // A call targets exactly one Discovery doc; only search can fan out.
149
+ return jsonResult({ error: 'unknown_api', message: `"${api}" maps to ${resolved.length} APIs.`, hint: `Pass one of: ${resolved.join(', ')} (method ids differ per API; google_api_search({query, api: "${api}"}) finds the right one).`, retriable: false, account }, true);
150
+ }
151
+ const apiKey = resolved[0];
152
+ if (!apiEnabled(apiKey))
153
+ return toolsetDisabled(apiKey);
137
154
  let index;
138
155
  try {
139
- index = await loadMethodIndex(api, deps);
156
+ index = await loadMethodIndex(apiKey, deps);
140
157
  }
141
158
  catch (err) {
142
159
  return jsonResult({ error: 'discovery_unavailable', message: err.message, retriable: true, account }, true);
@@ -148,7 +165,7 @@ export function registerEscapeTools(registry, policy, deps = {}) {
148
165
  // alias, retry under the doc's own prefix before failing.
149
166
  const docPrefix = index[0]?.id.split('.')[0];
150
167
  const [head, ...rest] = String(methodId).split('.');
151
- if (docPrefix && head === api && head !== docPrefix && rest.length > 0) {
168
+ if (docPrefix && (head === apiKey || head === api) && head !== docPrefix && rest.length > 0) {
152
169
  const swapped = [docPrefix, ...rest].join('.');
153
170
  method = index.find((m) => m.id === swapped);
154
171
  }
@@ -157,15 +174,15 @@ export function registerEscapeTools(registry, policy, deps = {}) {
157
174
  const near = nearestMethodIds(String(methodId), index);
158
175
  return jsonResult({
159
176
  error: 'unknown_method',
160
- message: `No method "${methodId}" in ${api}.`,
177
+ message: `No method "${methodId}" in ${apiKey}.`,
161
178
  hint: `${near.length ? `Did you mean: ${near.join(', ')}? ` : ''}` +
162
- `Use google_api_search({query: "...", api: "${api}"}) to find the right method id.`,
179
+ `Use google_api_search({query: "...", api: "${apiKey}"}) to find the right method id.`,
163
180
  retriable: false,
164
181
  account,
165
182
  }, true);
166
183
  }
167
184
  const cud = cudFromMethod(method);
168
- const policyService = serviceForAlias(api);
185
+ const policyService = serviceForAlias(apiKey);
169
186
  const lastSegment = method.id.split('.').pop() ?? method.id;
170
187
  const toolRef = { name: `${policyService}_${lastSegment}`, service: policyService, cud };
171
188
  if (cud !== 'read' && !isAllowed(toolRef, policy)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.19",
3
+ "version": "6.0.0-alpha.20",
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",