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;
|
package/dist/discovery-client.js
CHANGED
|
@@ -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;
|
package/dist/tools/_errors.js
CHANGED
|
@@ -119,13 +119,32 @@ export function mapGoogleError(error, account, forbiddenHint, scopeContext) {
|
|
|
119
119
|
account,
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
|
-
return {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
6
|
-
|
|
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;
|
package/dist/tools/google-api.js
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
136
|
-
|
|
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(
|
|
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 ${
|
|
177
|
+
message: `No method "${methodId}" in ${apiKey}.`,
|
|
161
178
|
hint: `${near.length ? `Did you mean: ${near.join(', ')}? ` : ''}` +
|
|
162
|
-
`Use google_api_search({query: "...", api: "${
|
|
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(
|
|
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.
|
|
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",
|