mcp-google-multi 6.0.0-beta.3 → 6.0.0-beta.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.
- package/README.md +20 -6
- package/dist/accounts.d.ts +3 -3
- package/dist/accounts.js +18 -31
- package/dist/arg-normalize.js +15 -35
- package/dist/arg-strict.js +8 -28
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +5 -10
- package/dist/boot-gates.d.ts +1 -1
- package/dist/boot-gates.js +3 -5
- package/dist/cimd-cache.js +5 -11
- package/dist/client-config.d.ts +2 -2
- package/dist/client-config.js +8 -14
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +8 -1
- package/dist/compose.js +11 -11
- package/dist/config-file.js +7 -12
- package/dist/discover.js +6 -12
- package/dist/discovery-client.js +14 -33
- package/dist/doctor.d.ts +3 -3
- package/dist/doctor.js +12 -18
- package/dist/env-load.js +2 -5
- package/dist/executor.js +2 -4
- package/dist/fanout.js +8 -17
- package/dist/fs-atomic.d.ts +0 -3
- package/dist/fs-atomic.js +104 -22
- package/dist/hint-provider.d.ts +4 -1
- package/dist/hint-provider.js +4 -10
- package/dist/http-config.d.ts +2 -2
- package/dist/http-config.js +3 -7
- package/dist/http-transport.d.ts +5 -5
- package/dist/http-transport.js +39 -92
- package/dist/identity.d.ts +2 -2
- package/dist/identity.js +6 -9
- package/dist/index.js +19 -31
- package/dist/master-key.d.ts +3 -3
- package/dist/master-key.js +11 -22
- package/dist/mcp-token.d.ts +40 -16
- package/dist/mcp-token.js +121 -44
- package/dist/metrics-cli.d.ts +1 -1
- package/dist/metrics-cli.js +1 -1
- package/dist/metrics-tap.js +2 -5
- package/dist/migrate-config.d.ts +1 -2
- package/dist/migrate-config.js +3 -5
- package/dist/net-tuning.js +2 -4
- package/dist/oauth-as.d.ts +7 -2
- package/dist/oauth-as.js +45 -78
- package/dist/oauth-consent.js +4 -10
- package/dist/outbound-allowlist.js +4 -9
- package/dist/reauth-hint.js +2 -5
- package/dist/registry-transfer.d.ts +1 -1
- package/dist/registry-transfer.js +5 -13
- package/dist/registry.d.ts +3 -9
- package/dist/registry.js +97 -76
- package/dist/scope-catalog.d.ts +5 -0
- package/dist/scope-catalog.js +17 -7
- package/dist/scope-observability.d.ts +2 -3
- package/dist/scope-observability.js +8 -14
- package/dist/services.d.ts +3 -0
- package/dist/services.js +5 -10
- package/dist/ssrf-guard.js +11 -22
- package/dist/tenant-purge.js +2 -4
- package/dist/token-store.js +0 -0
- package/dist/tools/_coerce.d.ts +4 -4
- package/dist/tools/_errors.js +22 -39
- package/dist/tools/_local-files.js +4 -8
- package/dist/tools/account-wizard.d.ts +4 -3
- package/dist/tools/account-wizard.js +48 -54
- package/dist/tools/accounts-tool.d.ts +8 -1
- package/dist/tools/accounts-tool.js +18 -7
- package/dist/tools/admin.js +8 -4
- package/dist/tools/analytics.js +32 -16
- package/dist/tools/calendar.d.ts +5 -0
- package/dist/tools/calendar.js +63 -7
- package/dist/tools/chat.js +2 -3
- package/dist/tools/contacts.js +19 -14
- package/dist/tools/docs.js +45 -21
- package/dist/tools/drive.js +72 -39
- package/dist/tools/forms.js +2 -3
- package/dist/tools/generated/gmail.js +0 -14
- package/dist/tools/generated/method-map.js +1 -1
- package/dist/tools/gmail-mime.d.ts +1 -1
- package/dist/tools/gmail-mime.js +7 -12
- package/dist/tools/gmail.d.ts +3 -3
- package/dist/tools/gmail.js +135 -52
- package/dist/tools/google-api.js +5 -7
- package/dist/tools/meet.js +2 -3
- package/dist/tools/searchconsole.js +2 -3
- package/dist/tools/sheets.d.ts +10 -0
- package/dist/tools/sheets.js +111 -5
- package/dist/tools/slides.js +2 -3
- package/dist/tools/tasks.js +2 -3
- package/dist/trim.js +2 -3
- package/dist/usage-metrics.js +5 -12
- package/dist/write-control.d.ts +4 -8
- package/dist/write-control.js +18 -6
- package/package.json +24 -22
- package/skills/google-workspace/SKILL.md +162 -0
- package/skills/google-workspace/reference/admin.md +105 -0
- package/skills/google-workspace/reference/analytics.md +70 -0
- package/skills/google-workspace/reference/appsmarket.md +5 -0
- package/skills/google-workspace/reference/calendar.md +175 -0
- package/skills/google-workspace/reference/chat.md +59 -0
- package/skills/google-workspace/reference/classroom.md +5 -0
- package/skills/google-workspace/reference/cloudidentity.md +5 -0
- package/skills/google-workspace/reference/cloudsearch.md +5 -0
- package/skills/google-workspace/reference/contacts.md +138 -0
- package/skills/google-workspace/reference/docs.md +395 -0
- package/skills/google-workspace/reference/drive.md +485 -0
- package/skills/google-workspace/reference/driveactivity.md +5 -0
- package/skills/google-workspace/reference/drivelabels.md +5 -0
- package/skills/google-workspace/reference/forms.md +92 -0
- package/skills/google-workspace/reference/gmail.md +305 -0
- package/skills/google-workspace/reference/groupsmigration.md +5 -0
- package/skills/google-workspace/reference/groupssettings.md +5 -0
- package/skills/google-workspace/reference/keep.md +5 -0
- package/skills/google-workspace/reference/licensing.md +5 -0
- package/skills/google-workspace/reference/meet.md +68 -0
- package/skills/google-workspace/reference/postmaster.md +5 -0
- package/skills/google-workspace/reference/reseller.md +5 -0
- package/skills/google-workspace/reference/script.md +5 -0
- package/skills/google-workspace/reference/searchconsole.md +128 -0
- package/skills/google-workspace/reference/sheets.md +430 -0
- package/skills/google-workspace/reference/slides.md +67 -0
- package/skills/google-workspace/reference/tasks.md +167 -0
- package/skills/google-workspace/reference/vault.md +5 -0
- package/skills/google-workspace/reference/workspaceevents.md +5 -0
package/dist/config-file.js
CHANGED
|
@@ -13,9 +13,8 @@ export function configFilePath(env = process.env) {
|
|
|
13
13
|
export function tenantsDir(env = process.env) {
|
|
14
14
|
return path.join(configDir(env), 'tenants');
|
|
15
15
|
}
|
|
16
|
-
// A tenant id becomes a filesystem path segment
|
|
17
|
-
//
|
|
18
|
-
// context's subject and client lane, so no tenant may take it.
|
|
16
|
+
// A tenant id becomes a filesystem path segment like an account alias (same
|
|
17
|
+
// traversal guard); 'owner' is the single-owner subject, so no tenant may take it.
|
|
19
18
|
function assertTenantId(tenantId) {
|
|
20
19
|
if (!ALIAS_RE.test(tenantId) || isReservedAlias(tenantId) || tenantId === 'owner') {
|
|
21
20
|
throw new Error(`E_TENANT_ID_INVALID: invalid tenant id "${tenantId}".`);
|
|
@@ -52,9 +51,7 @@ const accountEntrySchema = z.strictObject({
|
|
|
52
51
|
admin: z.boolean().optional(),
|
|
53
52
|
});
|
|
54
53
|
// .strict() everywhere: any unknown or secret-shaped key (clientSecret,
|
|
55
|
-
// masterKey, jwtKey, clientId) fails validation, structurally enforcing
|
|
56
|
-
// secrets-env-only. scopeProfiles/defaultAccount/discovery/toolsets are part
|
|
57
|
-
// of the frozen envelope; their consumers land in later slices.
|
|
54
|
+
// masterKey, jwtKey, clientId) fails validation, structurally enforcing secrets-env-only.
|
|
58
55
|
export const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
|
|
59
56
|
export function isReservedAlias(name) {
|
|
60
57
|
return RESERVED_ALIASES.includes(name);
|
|
@@ -72,9 +69,8 @@ const configSchema = z.strictObject({
|
|
|
72
69
|
defaultAccount: z.string().optional(),
|
|
73
70
|
discovery: z.enum(['lazy', 'curated', 'eager']).optional(),
|
|
74
71
|
toolsets: z.string().optional(),
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// (strictObject); remove the key first.
|
|
72
|
+
// Absent = off. A pre-6.0 build reading a config carrying this key fails
|
|
73
|
+
// E_CONFIG_INVALID (strictObject); remove the key first to downgrade.
|
|
78
74
|
usageMetrics: z.boolean().optional(),
|
|
79
75
|
});
|
|
80
76
|
export function failStartup(slug, message) {
|
|
@@ -125,9 +121,8 @@ export function loadConfigFile(filePath = configFilePath(), onInvalid = 'exit')
|
|
|
125
121
|
const issue = result.error.issues[0];
|
|
126
122
|
fail('E_CONFIG_INVALID', `${filePath}: ${issue.path.join('.') || '(root)'}: ${issue.message}. Secrets never belong in config.json.`);
|
|
127
123
|
}
|
|
128
|
-
// Post-schema
|
|
129
|
-
//
|
|
130
|
-
// plain-object lookups downstream.
|
|
124
|
+
// Post-schema: zod's key regex passes these, but a __proto__ record key is
|
|
125
|
+
// a prototype-pollution foothold downstream.
|
|
131
126
|
const data = result.data;
|
|
132
127
|
for (const [section, keys] of [
|
|
133
128
|
['accounts', Object.keys(data.accounts ?? {})],
|
package/dist/discover.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { describePolicy } from './write-control.js';
|
|
3
3
|
import { hints } from './hint-provider.js';
|
|
4
|
-
// The description budget is the whole point of lazy mode:
|
|
5
|
-
//
|
|
6
|
-
// CAPPED vocabulary and the complete catalog stays in this tool's RESULT.
|
|
4
|
+
// The description budget is the whole point of lazy mode: descriptions carry
|
|
5
|
+
// a CAPPED vocabulary, and the complete catalog stays in this tool's RESULT.
|
|
7
6
|
const CURATED_OPS_CAP = 10;
|
|
8
7
|
const GENERATED_GROUPS_CAP = 6;
|
|
9
8
|
function opVocabulary(registry, service) {
|
|
@@ -15,8 +14,7 @@ function opVocabulary(registry, service) {
|
|
|
15
14
|
parts.push(`${shown.join(', ')}${more > 0 ? ` +${more} more` : ''}`);
|
|
16
15
|
}
|
|
17
16
|
if (generated.length > 0) {
|
|
18
|
-
//
|
|
19
|
-
// more selective than any truncated name list.
|
|
17
|
+
// Compresses to resource groups: denser than any truncated name list.
|
|
20
18
|
const counts = new Map();
|
|
21
19
|
for (const op of generated) {
|
|
22
20
|
const group = op.split('_')[0];
|
|
@@ -31,10 +29,7 @@ function opVocabulary(registry, service) {
|
|
|
31
29
|
}
|
|
32
30
|
export function registerDiscoverTools(registry, policy) {
|
|
33
31
|
const registerMeta = registry.registerMeta;
|
|
34
|
-
//
|
|
35
|
-
// curated quality layer for heavy Google work, collapse to reclaim the
|
|
36
|
-
// name/schema context budget after. stdio-only semantics — over stateless
|
|
37
|
-
// HTTP the mode is forced curated and these are no-ops.
|
|
32
|
+
// stdio-only semantics: over stateless HTTP the mode is forced curated and these are no-ops.
|
|
38
33
|
registerMeta('discover_all', {
|
|
39
34
|
description: 'Reveal ALL curated Google tools at once (instead of per-service discovery). Use when ' +
|
|
40
35
|
'starting substantial Google work; prefer these over google_api_call. Pair with discover_reset.',
|
|
@@ -101,9 +96,8 @@ export function registerDiscoverTools(registry, policy) {
|
|
|
101
96
|
service,
|
|
102
97
|
operations,
|
|
103
98
|
writeControl: rendered?.hint ?? describePolicy(policy),
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
: `No ${service} operation matches "${query}". Call again without query for the full catalog.`,
|
|
99
|
+
// catalog() falls back to a full one-line listing when nothing matches, so this is never empty.
|
|
100
|
+
next: 'Call the chosen tool by its exact name. A client that loads tools on demand may need its own tool-search or load step before the call succeeds.',
|
|
107
101
|
}),
|
|
108
102
|
},
|
|
109
103
|
],
|
package/dist/discovery-client.js
CHANGED
|
@@ -35,10 +35,8 @@ 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
|
-
//
|
|
40
|
-
// APIs, the Admin SDK is three); keep entries plural-target only when the
|
|
41
|
-
// split is real.
|
|
38
|
+
// Names agents actually type for an API, mapped to real SUPPORTED_APIS keys;
|
|
39
|
+
// plural-target entries only where the split ("analytics" is two APIs) is real.
|
|
42
40
|
export const API_ALIASES = {
|
|
43
41
|
analytics: ['analyticsadmin', 'analyticsdata'],
|
|
44
42
|
ga4: ['analyticsadmin', 'analyticsdata'],
|
|
@@ -74,9 +72,8 @@ export function discoveryCacheDir(env = process.env) {
|
|
|
74
72
|
return (env.DISCOVERY_CACHE_PATH ??
|
|
75
73
|
path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'mcp-google-multi', 'discovery'));
|
|
76
74
|
}
|
|
77
|
-
// Default cache path only (an operator's DISCOVERY_CACHE_PATH may be shared
|
|
78
|
-
//
|
|
79
|
-
// effort. Clears group/other bits and keeps setuid/setgid/sticky.
|
|
75
|
+
// Default cache path only (an operator's DISCOVERY_CACHE_PATH may be shared
|
|
76
|
+
// and keeps its mode), only when this process owns it; clears group/other bits.
|
|
80
77
|
function tightenOwnDir(dir) {
|
|
81
78
|
if (process.platform === 'win32')
|
|
82
79
|
return;
|
|
@@ -110,9 +107,8 @@ export function buildMethodIndex(doc, api) {
|
|
|
110
107
|
if (!method.id || !method.httpMethod || !method.path)
|
|
111
108
|
continue;
|
|
112
109
|
const params = method.parameters ?? {};
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
// tie-break) to keep gen-tools output deterministic.
|
|
110
|
+
// parameters{} has unstable key order between fetches; sort by the
|
|
111
|
+
// canonical parameterOrder (alphabetical tie-break) for deterministic output.
|
|
116
112
|
const order = method.parameterOrder ?? [];
|
|
117
113
|
const pos = (n) => {
|
|
118
114
|
const i = order.indexOf(n);
|
|
@@ -174,9 +170,8 @@ export async function loadMethodIndex(api, deps = {}) {
|
|
|
174
170
|
if (cacheFresh())
|
|
175
171
|
doc = readCache();
|
|
176
172
|
if (!doc) {
|
|
177
|
-
// Newer APIs
|
|
178
|
-
//
|
|
179
|
-
// authoritative, so try both — same fallback as scripts/fetch-discovery.
|
|
173
|
+
// Newer APIs are absent from the central discovery directory (404); try
|
|
174
|
+
// each service's own $discovery endpoint too, as scripts/fetch-discovery does.
|
|
180
175
|
const urls = [
|
|
181
176
|
`https://www.googleapis.com/discovery/v1/apis/${spec.id}/${spec.version}/rest`,
|
|
182
177
|
`https://${spec.id}.googleapis.com/$discovery/rest?version=${spec.version}`,
|
|
@@ -217,20 +212,11 @@ export function clearDiscoveryMemoryCache() {
|
|
|
217
212
|
// GA4-style report execution (runReport, batchRunPivotReports, runAccessReport)
|
|
218
213
|
// and check* predicates are POSTs purely for the request-body size — reads.
|
|
219
214
|
const POST_READ_VERB = /^(get|list|search|query|lookup|count|batchGet|generateIds|export|download|inspect|check|suggest|(batch)?run\w*report)/i;
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
// before delete for exactly that reason.
|
|
223
|
-
// State transitions on something that already exists: reversible, and none of
|
|
224
|
-
// them creates anything, so `create` was both wrong and the most permissive
|
|
225
|
-
// class available. `(batch)?` mirrors the delete list, without which every
|
|
226
|
-
// `batchUpdate*` POST fell through to `create`.
|
|
215
|
+
// Update, tested before delete, so cancelWipe (calling off a wipe) doesn't
|
|
216
|
+
// reach the delete list via its "cancel" suffix. See docs/internals.md.
|
|
227
217
|
const POST_UPDATE_VERB = /^(cancelWipe|(batch)?(untrash|undelete|restore|modify|move|set|sort|merge|unmerge|replace|resize|publish|resolve|update|patch|write|format|change|close|reopen|enable|disable|hide|unhide|accept|approve|decline|reassign|reactivate|renew|mark|turn|suspend|activate|make|return|reclaim|complete))/i;
|
|
228
|
-
// archive
|
|
229
|
-
//
|
|
230
|
-
// So do the teardown verbs: `stop` and `cancel` tear down a push channel or a
|
|
231
|
-
// long-running operation, `wipe`/`invalidate`/`signOut` destroy device data,
|
|
232
|
-
// codes and sessions, `reset` discards a configuration, and `end` terminates a
|
|
233
|
-
// live conference. Each removes something that existed.
|
|
218
|
+
// archive/wipe/reset/stop/cancel/end etc. all remove or permanently discard
|
|
219
|
+
// something that existed, so the most restrictive class is the safe one. See docs/internals.md.
|
|
234
220
|
const POST_DELETE_VERB = /^(batch)?(delete|remove|trash|clear|empty|obliterate|purge|revoke|wipeout|archive|wipe|invalidate|signOut|stop|cancel|unregister|unreserve|reset|end)/i;
|
|
235
221
|
export function cudFromMethod(method) {
|
|
236
222
|
switch (method.httpMethod) {
|
|
@@ -262,13 +248,8 @@ export function expandPath(template, pathParams) {
|
|
|
262
248
|
if (value === undefined) {
|
|
263
249
|
throw new Error(`Missing required path parameter "${name}"`);
|
|
264
250
|
}
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
// back as the whole site LIST, reported as success. Dot segments resolve
|
|
268
|
-
// at URL-parse time, so `../v1/contactGroups` retargets the call at a
|
|
269
|
-
// different endpoint and returns ITS response as success. Both are
|
|
270
|
-
// silent-wrong, so refuse locally rather than send a different request.
|
|
271
|
-
// `{+param}` keeps its slashes, so every segment has to be checked.
|
|
251
|
+
// Both a blank segment and a "." / ".." segment silently retarget the
|
|
252
|
+
// request to a different, successfully-answered endpoint. See docs/internals.md.
|
|
272
253
|
const segments = plus ? String(value).split('/') : [String(value)];
|
|
273
254
|
for (const segment of segments) {
|
|
274
255
|
if (segment.trim() === '') {
|
package/dist/doctor.d.ts
CHANGED
|
@@ -52,10 +52,10 @@ export interface HttpProbeResult {
|
|
|
52
52
|
unreachable?: boolean;
|
|
53
53
|
problem?: string;
|
|
54
54
|
}
|
|
55
|
-
/** Console deep-link to enable one API (section-6 hint
|
|
55
|
+
/** Console deep-link to enable one API (section-6 hint). */
|
|
56
56
|
export declare function apiEnableLink(api: string): string;
|
|
57
57
|
/** One line, state AND source, so the people being measured can see both
|
|
58
|
-
* here and in `diagnose
|
|
58
|
+
* here and in `diagnose`. Read-only. */
|
|
59
59
|
export declare function usageMetricsStatusLine(env: Record<string, string | undefined>): string;
|
|
60
60
|
/** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
|
|
61
61
|
export declare function overallVerdict(sections: DiagnosticSection[]): Verdict;
|
|
@@ -64,7 +64,7 @@ export declare function runDiagnostics(deps?: DiagnosticsDeps, opts?: {
|
|
|
64
64
|
}): Promise<DiagnosticsReport>;
|
|
65
65
|
/** doctor CLI (brew-doctor style): glyph-coded human report. */
|
|
66
66
|
export declare function renderDoctorText(report: DiagnosticsReport): string;
|
|
67
|
-
/** Redacted, paste-ready bug report
|
|
67
|
+
/** Redacted, paste-ready bug report: verdicts + provenance labels + token
|
|
68
68
|
* statuses + slugs; NO token values, secrets, keys, or full email addresses. */
|
|
69
69
|
export declare function renderReport(report: DiagnosticsReport, version: string): string;
|
|
70
70
|
/** Exit non-zero when any section FAILs; with strict, WARN counts too. */
|
package/dist/doctor.js
CHANGED
|
@@ -35,9 +35,8 @@ const DEFAULT_DEPS = {
|
|
|
35
35
|
probeApi: (alias) => probeApiEnablement(alias),
|
|
36
36
|
probeHttp: (cfg) => probeHttpEndpoints(cfg),
|
|
37
37
|
};
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
* compute from the public URL is the perpetual-401 interop bug (BR4). */
|
|
38
|
+
// §7 live check: a mismatch between PRM/AS metadata and what clients compute
|
|
39
|
+
// from the public URL is the perpetual-401 interop bug.
|
|
41
40
|
async function probeHttpEndpoints(cfg) {
|
|
42
41
|
try {
|
|
43
42
|
const prmRes = await fetch(`${cfg.publicUrl}/.well-known/oauth-protected-resource`, {
|
|
@@ -66,7 +65,7 @@ async function probeHttpEndpoints(cfg) {
|
|
|
66
65
|
return { ok: false, unreachable: true };
|
|
67
66
|
}
|
|
68
67
|
}
|
|
69
|
-
/** Console deep-link to enable one API (section-6 hint
|
|
68
|
+
/** Console deep-link to enable one API (section-6 hint). */
|
|
70
69
|
export function apiEnableLink(api) {
|
|
71
70
|
return `https://console.cloud.google.com/apis/library/${api}.googleapis.com`;
|
|
72
71
|
}
|
|
@@ -75,8 +74,8 @@ function sectionRuntime(deps, tenantScope = false) {
|
|
|
75
74
|
const major = Number.parseInt(deps.nodeVersion.split('.')[0] ?? '0', 10);
|
|
76
75
|
if (!Number.isFinite(major) || major < MIN_NODE_MAJOR) {
|
|
77
76
|
if (tenantScope) {
|
|
78
|
-
// The host version is operator infrastructure, not the tenant's
|
|
79
|
-
//
|
|
77
|
+
// The host version is operator infrastructure, not the tenant's: no
|
|
78
|
+
// version string, and the fix is the operator's to apply.
|
|
80
79
|
const rendered = hints()?.render({ kind: 'operator_only', slug: 'E_NODE_TOO_OLD' });
|
|
81
80
|
return {
|
|
82
81
|
id: 1,
|
|
@@ -150,7 +149,7 @@ function sectionConfig(deps, set, tenantScope = false) {
|
|
|
150
149
|
return { id: 2, title: 'Config', verdict, lines, ...(hint ? { hint } : {}), ...(slug ? { slug } : {}) };
|
|
151
150
|
}
|
|
152
151
|
/** One line, state AND source, so the people being measured can see both
|
|
153
|
-
* here and in `diagnose
|
|
152
|
+
* here and in `diagnose`. Read-only. */
|
|
154
153
|
export function usageMetricsStatusLine(env) {
|
|
155
154
|
const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
|
|
156
155
|
let configValue;
|
|
@@ -240,8 +239,7 @@ async function sectionApiEnablement(deps, aliases, tenantScope = false) {
|
|
|
240
239
|
}
|
|
241
240
|
catch (e) {
|
|
242
241
|
// Network / transient: WARN with the target, never crash the report. The
|
|
243
|
-
// raw text can name an egress or proxy host
|
|
244
|
-
// drops it.
|
|
242
|
+
// raw text can name an egress or proxy host: tenant scope drops it.
|
|
245
243
|
return {
|
|
246
244
|
id: 6,
|
|
247
245
|
title: 'API enablement',
|
|
@@ -338,10 +336,8 @@ export function overallVerdict(sections) {
|
|
|
338
336
|
return worst;
|
|
339
337
|
}
|
|
340
338
|
export async function runDiagnostics(deps = DEFAULT_DEPS, opts = {}) {
|
|
341
|
-
// Tenant scope omits the OPERATOR sections
|
|
342
|
-
//
|
|
343
|
-
// values — other people's addresses from a tenant's seat), and trims §2 to
|
|
344
|
-
// the context's own account count.
|
|
339
|
+
// Tenant scope omits the OPERATOR sections (§3 key provenance, §7 owner-gate
|
|
340
|
+
// emails) and trims §2 to the context's own account count.
|
|
345
341
|
const tenantScope = opts.scope === 'tenant';
|
|
346
342
|
const sections = [];
|
|
347
343
|
sections.push(sectionRuntime(deps, tenantScope));
|
|
@@ -381,10 +377,8 @@ export function renderDoctorText(report) {
|
|
|
381
377
|
}
|
|
382
378
|
const EMAIL_LOCAL_CHAR = /[A-Za-z0-9._%+-]/;
|
|
383
379
|
const EMAIL_DOMAIN_CHAR = /[A-Za-z0-9.-]/;
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
* polynomially on long local-charset runs without an @ (CodeQL
|
|
387
|
-
* js/polynomial-redos), and report text can embed arbitrary error strings. */
|
|
380
|
+
// A linear scan, not a regex: `local*@domain` backtracks polynomially on long
|
|
381
|
+
// local-charset runs without an @ (CodeQL js/polynomial-redos).
|
|
388
382
|
function maskEmails(text) {
|
|
389
383
|
let out = '';
|
|
390
384
|
let i = 0;
|
|
@@ -409,7 +403,7 @@ function maskEmails(text) {
|
|
|
409
403
|
}
|
|
410
404
|
return out;
|
|
411
405
|
}
|
|
412
|
-
/** Redacted, paste-ready bug report
|
|
406
|
+
/** Redacted, paste-ready bug report: verdicts + provenance labels + token
|
|
413
407
|
* statuses + slugs; NO token values, secrets, keys, or full email addresses. */
|
|
414
408
|
export function renderReport(report, version) {
|
|
415
409
|
const out = [`mcp-google-multi doctor report (v${version})`, `overall: ${report.verdict}`, ''];
|
package/dist/env-load.js
CHANGED
|
@@ -3,11 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
-
// Precedence: real env > CWD .env > package-root .env > ~/.config .env
|
|
7
|
-
//
|
|
8
|
-
// tests/env-load.test.ts T4), so highest-priority file loads FIRST and real
|
|
9
|
-
// env wins by already being present; the boot snapshot re-assert is a second
|
|
10
|
-
// line of defense should those semantics ever change.
|
|
6
|
+
// Precedence: real env > CWD .env > package-root .env > ~/.config .env;
|
|
7
|
+
// the boot snapshot re-assert below is a second line of defense.
|
|
11
8
|
export function loadEnvFiles(paths = {}) {
|
|
12
9
|
// loadEnvFile exists since 20.12, so a feature check alone would let 20.12-21.x through.
|
|
13
10
|
const major = Number(process.versions.node.split('.', 1)[0]);
|
package/dist/executor.js
CHANGED
|
@@ -91,10 +91,8 @@ export async function executeApiMethod(method, args, deps = {}) {
|
|
|
91
91
|
});
|
|
92
92
|
const text = JSON.stringify(res.data ?? null);
|
|
93
93
|
if (text.length > MAX_RESPONSE_CHARS) {
|
|
94
|
-
// Budget the ENCODED head: jsonResult re-stringifies it, so
|
|
95
|
-
//
|
|
96
|
-
// result exceed the cap this tool advertises, by up to 15 percent on a
|
|
97
|
-
// quote-dense listing.
|
|
94
|
+
// Budget the ENCODED head: jsonResult re-stringifies it, so slicing raw
|
|
95
|
+
// characters overshot the advertised cap on a quote-dense listing.
|
|
98
96
|
return jsonResult({
|
|
99
97
|
truncated: true,
|
|
100
98
|
totalChars: text.length,
|
package/dist/fanout.js
CHANGED
|
@@ -4,19 +4,13 @@ import { echoName, echoNames, MAX_SUGGESTED_KEYS } from './arg-strict.js';
|
|
|
4
4
|
import { hints } from './hint-provider.js';
|
|
5
5
|
export const CSV_RE = /^[a-zA-Z0-9_-]+(\s*,\s*[a-zA-Z0-9_-]+)+$/;
|
|
6
6
|
const FANOUT_CONCURRENCY = 5;
|
|
7
|
-
// `aliases` is
|
|
8
|
-
//
|
|
9
|
-
// '*' would advertise and accept another registry's aliases. It is a getter:
|
|
10
|
-
// the set is read at parse time, so an alias added mid-session (account_add,
|
|
11
|
-
// a tenant link) is valid on tools registered before it existed.
|
|
7
|
+
// `aliases` is a getter read at parse time (not baked), so a registry only
|
|
8
|
+
// ever sees ITS OWN account set, and a mid-session alias (account_add) is valid on tools registered before it existed.
|
|
12
9
|
export function fanoutAccountField(description, aliases) {
|
|
13
10
|
const boot = aliases();
|
|
14
11
|
const csvExample = boot.length > 1 ? `; or a CSV subset like "${boot.slice(0, 2).join(',')}"` : '';
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// branch verbatim, which is the CSV regex, so a mistyped alias used to read
|
|
18
|
-
// as "Invalid string: must match pattern /^[a-zA-Z0-9_-]+(,...)/" and never
|
|
19
|
-
// named an alias. Hence the same message on BOTH.
|
|
12
|
+
// Same message on both union branches: for a string, zod surfaces the
|
|
13
|
+
// single non-aborted branch (the CSV regex) verbatim, which never named the alias.
|
|
20
14
|
const bad = () => unknownAliasMessage(aliases(), true);
|
|
21
15
|
return z
|
|
22
16
|
// The selector branch stays first: tools/list injects the live
|
|
@@ -46,9 +40,8 @@ export function noAccountsResult() {
|
|
|
46
40
|
export function parseAccountSelector(value, accounts = getAccountSet().aliases) {
|
|
47
41
|
if (value === '*')
|
|
48
42
|
return { ok: true, fanout: true, aliases: [...accounts] };
|
|
49
|
-
// A single alias used to skip validation
|
|
50
|
-
//
|
|
51
|
-
// check and same envelope wherever the alias was typed.
|
|
43
|
+
// A single alias used to skip validation and reach getClient, whose throw
|
|
44
|
+
// landed on the mapper's floor as an unclassified upstream_error.
|
|
52
45
|
if (!value.includes(',')) {
|
|
53
46
|
return accounts.includes(value)
|
|
54
47
|
? { ok: true, fanout: false, aliases: [value] }
|
|
@@ -132,10 +125,8 @@ export async function runFanout(handler, args, aliases) {
|
|
|
132
125
|
await Promise.all(Array.from({ length: Math.min(FANOUT_CONCURRENCY, aliases.length) }, () => worker()));
|
|
133
126
|
const failed = results.filter((r) => !r.ok).length;
|
|
134
127
|
if (failed === results.length && results.length > 0) {
|
|
135
|
-
// Every account failed, so this IS an error result
|
|
136
|
-
//
|
|
137
|
-
// successful-looking object flagged as an error, and metrics bucketed it
|
|
138
|
-
// as `other`. Hoist a real envelope, keeping the per-account detail.
|
|
128
|
+
// Every account failed, so this IS an error result: hoist a real
|
|
129
|
+
// top-level envelope (error/hint/retriable), keeping the per-account detail.
|
|
139
130
|
const slugs = [...new Set(results.map((r) => r.error?.error ?? 'internal'))];
|
|
140
131
|
const hints = [...new Set(results.map((r) => r.error?.hint).filter(Boolean))];
|
|
141
132
|
const retriable = results.every((r) => r.error?.retriable === true);
|
package/dist/fs-atomic.d.ts
CHANGED
package/dist/fs-atomic.js
CHANGED
|
@@ -10,6 +10,9 @@ const RENAME_RETRY_MS = 20;
|
|
|
10
10
|
const LOCK_LEASE_MS = 60_000;
|
|
11
11
|
// Breaking a lock takes microseconds, so an older breaker file was left by a crash.
|
|
12
12
|
const BREAKER_STALE_MS = 2_000;
|
|
13
|
+
// The no-hard-link fallback writes the lock in two syscalls (create, then the
|
|
14
|
+
// pid); a reader can catch it empty in between. See docs/internals.md.
|
|
15
|
+
const LOCK_WRITE_GRACE_MS = 2_000;
|
|
13
16
|
const PROCESS_START_MS = performance.timeOrigin;
|
|
14
17
|
const OWNER_NAME = /^(\d+)\.(\d+|host)\.(\d+)\.([0-9a-f]{8}|0)\.[0-9a-f]{12}\.owner$/;
|
|
15
18
|
function startOf(pid) {
|
|
@@ -63,11 +66,19 @@ function sameFile(a, b) {
|
|
|
63
66
|
function isEnoent(error) {
|
|
64
67
|
return error.code === 'ENOENT';
|
|
65
68
|
}
|
|
69
|
+
function bindingRecord(id) {
|
|
70
|
+
return `${id.dev}:${id.ino}:${id.mtimeNs}`;
|
|
71
|
+
}
|
|
72
|
+
function matchesBinding(content, id) {
|
|
73
|
+
const m = /^(\d+):(\d+):(\d+)$/.exec(content);
|
|
74
|
+
return m !== null && BigInt(m[1]) === id.dev && BigInt(m[2]) === id.ino && BigInt(m[3]) === id.mtimeNs;
|
|
75
|
+
}
|
|
66
76
|
// Undefined when the lock changed hands while it was being read.
|
|
67
77
|
function readHolder(lock) {
|
|
68
78
|
const before = fs.lstatSync(lock, { bigint: true });
|
|
69
79
|
const body = before.isFile() ? fs.readFileSync(lock, 'utf8') : '';
|
|
70
80
|
let owner;
|
|
81
|
+
let ownerPending = false;
|
|
71
82
|
if (before.isFile()) {
|
|
72
83
|
const dir = path.dirname(lock);
|
|
73
84
|
const prefix = `${path.basename(lock)}.`;
|
|
@@ -79,10 +90,12 @@ function readHolder(lock) {
|
|
|
79
90
|
continue;
|
|
80
91
|
const ownerPath = path.join(dir, name);
|
|
81
92
|
try {
|
|
82
|
-
|
|
93
|
+
const ownerStat = fs.lstatSync(ownerPath, { bigint: true });
|
|
94
|
+
if (sameFile(ownerStat, before) || matchesBinding(fs.readFileSync(ownerPath, 'utf8'), before)) {
|
|
83
95
|
owner = { path: ownerPath, ns: m[2], start: m[3], boot: m[4] };
|
|
84
96
|
break;
|
|
85
97
|
}
|
|
98
|
+
ownerPending = true;
|
|
86
99
|
}
|
|
87
100
|
catch (error) {
|
|
88
101
|
if (!isEnoent(error))
|
|
@@ -93,7 +106,7 @@ function readHolder(lock) {
|
|
|
93
106
|
const after = fs.lstatSync(lock, { bigint: true });
|
|
94
107
|
if (!sameFile(before, after))
|
|
95
108
|
return undefined;
|
|
96
|
-
return { body, ino: after.ino, dev: after.dev, mtimeNs: after.mtimeNs, mtimeMs: Number(after.mtimeNs) / 1e6, owner };
|
|
109
|
+
return { body, ino: after.ino, dev: after.dev, mtimeNs: after.mtimeNs, mtimeMs: Number(after.mtimeNs) / 1e6, owner, ownerPending };
|
|
97
110
|
}
|
|
98
111
|
// The owner file's random name tells two holders apart even when the second
|
|
99
112
|
// lock reuses the first one's freed inode.
|
|
@@ -115,10 +128,21 @@ function probeDead(pid) {
|
|
|
115
128
|
return false;
|
|
116
129
|
}
|
|
117
130
|
}
|
|
131
|
+
// Symmetric: a clock step back must not make a write-in-progress lock look
|
|
132
|
+
// permanently fresh (see docs/internals.md).
|
|
133
|
+
function withinWriteGrace(now, mtimeMs) {
|
|
134
|
+
return Math.abs(now - mtimeMs) < LOCK_WRITE_GRACE_MS;
|
|
135
|
+
}
|
|
118
136
|
function holderDead(holder, now) {
|
|
137
|
+
if (holder.body === '' && withinWriteGrace(now, holder.mtimeMs))
|
|
138
|
+
return false;
|
|
119
139
|
const pid = Number(holder.body);
|
|
120
140
|
if (!(Number.isSafeInteger(pid) && pid > 0))
|
|
121
141
|
return true;
|
|
142
|
+
// A same-pid owner file exists but is not yet bound (the fallback lock's two
|
|
143
|
+
// writes race a reader); give it the same grace as an empty lock.
|
|
144
|
+
if (!holder.owner && holder.ownerPending && withinWriteGrace(now, holder.mtimeMs))
|
|
145
|
+
return false;
|
|
122
146
|
const leaseOver = now - holder.mtimeMs >= LOCK_LEASE_MS;
|
|
123
147
|
const owner = holder.owner;
|
|
124
148
|
// No owner file: an older writer, whose namespace is unknown.
|
|
@@ -129,9 +153,8 @@ function holderDead(holder, now) {
|
|
|
129
153
|
// Two unreadable parts compare equal without naming the same namespace or
|
|
130
154
|
// boot, so only a fully known identity is looked up by start time.
|
|
131
155
|
if (SELF.ns !== 'host' && SELF.boot !== '0' && SELF.start !== '0' && owner.start !== '0') {
|
|
132
|
-
// A live namespace id is never shared, so the recorded pid is ours to
|
|
133
|
-
//
|
|
134
|
-
// the start time.
|
|
156
|
+
// A live namespace id is never shared, so the recorded pid is ours to look
|
|
157
|
+
// up: a restarted container reuses both the id and the pid, but not the start time.
|
|
135
158
|
const start = startOf(pid);
|
|
136
159
|
if (start !== undefined)
|
|
137
160
|
return start !== owner.start;
|
|
@@ -141,9 +164,8 @@ function holderDead(holder, now) {
|
|
|
141
164
|
return holder.mtimeMs < PROCESS_START_MS;
|
|
142
165
|
return probeDead(pid) || leaseOver;
|
|
143
166
|
}
|
|
144
|
-
// One waiter at a time breaks a dead lock
|
|
145
|
-
//
|
|
146
|
-
// False when another waiter is breaking it.
|
|
167
|
+
// One waiter at a time breaks a dead lock, or the second could unlink the
|
|
168
|
+
// first one's new lock; false when another waiter is already breaking it.
|
|
147
169
|
function breakLock(lock, holder) {
|
|
148
170
|
const breaker = `${lock}.break`;
|
|
149
171
|
// A token, not the inode: a breaker created after ours was removed can
|
|
@@ -198,10 +220,55 @@ function releaseBreaker(breaker, token) {
|
|
|
198
220
|
throw error;
|
|
199
221
|
}
|
|
200
222
|
}
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
// the
|
|
204
|
-
//
|
|
223
|
+
// No hard links here (Android/Termux denies link() in private storage, some
|
|
224
|
+
// filesystems lack it entirely): the lock is created directly with O_EXCL,
|
|
225
|
+
// carrying the bare pid a linked owner file would carry, and the owner file
|
|
226
|
+
// is bound to it by a recorded identity instead of a hard link. See docs/internals.md.
|
|
227
|
+
function takeFallbackLock(lock, ownerFile) {
|
|
228
|
+
const fd = fs.openSync(lock, 'wx', 0o600);
|
|
229
|
+
let acquired;
|
|
230
|
+
try {
|
|
231
|
+
fs.writeSync(fd, String(process.pid));
|
|
232
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
233
|
+
acquired = { dev: stat.dev, ino: stat.ino, mtimeNs: stat.mtimeNs };
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
// The lock is left empty; remove it only if it is still the one we just
|
|
237
|
+
// created, then rethrow so the caller never retries a write failure.
|
|
238
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
239
|
+
fs.closeSync(fd);
|
|
240
|
+
try {
|
|
241
|
+
if (sameFile(fs.lstatSync(lock, { bigint: true }), stat))
|
|
242
|
+
fs.rmSync(lock, { force: true });
|
|
243
|
+
}
|
|
244
|
+
catch (cleanupError) {
|
|
245
|
+
if (!isEnoent(cleanupError))
|
|
246
|
+
throw cleanupError;
|
|
247
|
+
}
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
fs.closeSync(fd);
|
|
251
|
+
try {
|
|
252
|
+
fs.writeFileSync(ownerFile, bindingRecord(acquired));
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
// The lock already exists holding our live pid; without this cleanup
|
|
256
|
+
// every other writer sees an ownerless holder and waits out the full lease.
|
|
257
|
+
try {
|
|
258
|
+
const current = fs.lstatSync(lock, { bigint: true });
|
|
259
|
+
if (sameFile(current, acquired) && current.mtimeNs === acquired.mtimeNs)
|
|
260
|
+
fs.rmSync(lock, { force: true });
|
|
261
|
+
}
|
|
262
|
+
catch (cleanupError) {
|
|
263
|
+
if (!isEnoent(cleanupError))
|
|
264
|
+
throw cleanupError;
|
|
265
|
+
}
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
return acquired;
|
|
269
|
+
}
|
|
270
|
+
// Hardlink-based advisory lock; the lock body is the bare pid, as older
|
|
271
|
+
// readers expect. See docs/internals.md.
|
|
205
272
|
export function withFileLock(absPath, fn, label = `lock: ${absPath}`) {
|
|
206
273
|
const dir = path.dirname(absPath);
|
|
207
274
|
const lock = path.join(dir, `.${path.basename(absPath)}.lock`);
|
|
@@ -210,17 +277,31 @@ export function withFileLock(absPath, fn, label = `lock: ${absPath}`) {
|
|
|
210
277
|
fs.writeFileSync(ownerFile, String(process.pid), { mode: 0o600, flag: 'wx' });
|
|
211
278
|
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
212
279
|
let linked = false;
|
|
280
|
+
let acquired;
|
|
213
281
|
try {
|
|
214
282
|
while (true) {
|
|
215
283
|
try {
|
|
216
284
|
fs.linkSync(ownerFile, lock);
|
|
217
285
|
linked = true;
|
|
286
|
+
acquired = fs.lstatSync(lock, { bigint: true });
|
|
218
287
|
break;
|
|
219
288
|
}
|
|
220
289
|
catch (error) {
|
|
221
290
|
const err = error;
|
|
222
|
-
if (err.code
|
|
291
|
+
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
|
292
|
+
try {
|
|
293
|
+
acquired = takeFallbackLock(lock, ownerFile);
|
|
294
|
+
linked = true;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
catch (fbError) {
|
|
298
|
+
if (fbError.code !== 'EEXIST')
|
|
299
|
+
throw fbError;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else if (err.code !== 'EEXIST') {
|
|
223
303
|
throw error;
|
|
304
|
+
}
|
|
224
305
|
try {
|
|
225
306
|
const holder = readHolder(lock);
|
|
226
307
|
if (holder && holderDead(holder, Date.now()) && breakLock(lock, holder))
|
|
@@ -242,23 +323,24 @@ export function withFileLock(absPath, fn, label = `lock: ${absPath}`) {
|
|
|
242
323
|
if (!linked)
|
|
243
324
|
fs.rmSync(ownerFile, { force: true });
|
|
244
325
|
}
|
|
245
|
-
let bound = false;
|
|
246
326
|
try {
|
|
247
|
-
// Some network and FUSE filesystems give each name of one file its own
|
|
248
|
-
// inode number; there the owner file cannot vouch for the lock.
|
|
249
|
-
bound = sameFile(fs.lstatSync(ownerFile, { bigint: true }), fs.lstatSync(lock, { bigint: true }));
|
|
250
327
|
return fn();
|
|
251
328
|
}
|
|
252
329
|
finally {
|
|
253
|
-
releaseLock(lock, ownerFile,
|
|
330
|
+
releaseLock(lock, ownerFile, acquired);
|
|
254
331
|
}
|
|
255
332
|
}
|
|
256
333
|
// A holder whose lock was broken (its owner file removed with it) must not
|
|
257
|
-
// remove its successor's lock
|
|
258
|
-
|
|
334
|
+
// remove its successor's lock: only drop the lock if it is still the exact
|
|
335
|
+
// file this call created, and our own owner file is still there to vouch for it.
|
|
336
|
+
function releaseLock(lock, ownerFile, acquired) {
|
|
259
337
|
try {
|
|
260
|
-
if (
|
|
261
|
-
fs.
|
|
338
|
+
if (acquired) {
|
|
339
|
+
const current = fs.lstatSync(lock, { bigint: true });
|
|
340
|
+
if (sameFile(current, acquired) && current.mtimeNs === acquired.mtimeNs && fs.existsSync(ownerFile)) {
|
|
341
|
+
fs.rmSync(lock, { force: true });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
262
344
|
}
|
|
263
345
|
catch (error) {
|
|
264
346
|
if (!isEnoent(error))
|