mcp-google-multi 6.0.0-beta.2 → 6.0.0-beta.3
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/dist/accounts.d.ts +17 -1
- package/dist/accounts.js +52 -15
- package/dist/api-probe.d.ts +5 -0
- package/dist/api-probe.js +20 -15
- package/dist/arg-normalize.js +6 -5
- package/dist/arg-strict.d.ts +7 -0
- package/dist/arg-strict.js +34 -4
- package/dist/auth.d.ts +4 -3
- package/dist/auth.js +11 -13
- package/dist/boot-gates.d.ts +26 -0
- package/dist/boot-gates.js +38 -0
- package/dist/cimd-cache.d.ts +38 -0
- package/dist/cimd-cache.js +117 -0
- package/dist/client.d.ts +32 -2
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +11 -0
- package/dist/compose.js +109 -0
- package/dist/config-file.d.ts +13 -0
- package/dist/config-file.js +37 -1
- package/dist/discover.js +3 -1
- package/dist/discovery-client.d.ts +3 -0
- package/dist/discovery-client.js +25 -2
- package/dist/doctor.d.ts +6 -2
- package/dist/doctor.js +119 -23
- package/dist/executor.d.ts +3 -0
- package/dist/executor.js +4 -2
- package/dist/fanout.d.ts +9 -1
- package/dist/fanout.js +44 -10
- package/dist/fs-atomic.d.ts +11 -0
- package/dist/fs-atomic.js +219 -28
- package/dist/hint-provider.d.ts +129 -0
- package/dist/hint-provider.js +64 -0
- package/dist/http-transport.d.ts +62 -4
- package/dist/http-transport.js +343 -24
- package/dist/identity.d.ts +4 -1
- package/dist/identity.js +25 -6
- package/dist/index.js +66 -138
- package/dist/master-key.js +13 -5
- package/dist/mcp-token.d.ts +136 -19
- package/dist/mcp-token.js +569 -72
- package/dist/migrate-config.js +2 -2
- package/dist/oauth-as.d.ts +103 -3
- package/dist/oauth-as.js +374 -79
- package/dist/oauth-consent.d.ts +10 -0
- package/dist/oauth-consent.js +16 -0
- package/dist/outbound-allowlist.js +5 -2
- package/dist/reauth-hint.d.ts +1 -1
- package/dist/reauth-hint.js +12 -8
- package/dist/registry-transfer.d.ts +5 -0
- package/dist/registry-transfer.js +65 -5
- package/dist/registry.d.ts +18 -1
- package/dist/registry.js +71 -15
- package/dist/scope-catalog.d.ts +11 -0
- package/dist/scope-catalog.js +23 -6
- package/dist/scope-observability.d.ts +8 -6
- package/dist/scope-observability.js +29 -13
- package/dist/services.d.ts +15 -10
- package/dist/services.js +59 -15
- package/dist/ssrf-guard.d.ts +23 -9
- package/dist/ssrf-guard.js +101 -62
- package/dist/tenant-purge.d.ts +26 -0
- package/dist/tenant-purge.js +67 -0
- package/dist/token-store.d.ts +20 -2
- package/dist/token-store.js +0 -0
- package/dist/tools/_errors.d.ts +3 -0
- package/dist/tools/_errors.js +53 -24
- package/dist/tools/_local-files.d.ts +9 -0
- package/dist/tools/_local-files.js +18 -0
- package/dist/tools/account-wizard.d.ts +10 -1
- package/dist/tools/account-wizard.js +74 -17
- package/dist/tools/accounts-tool.d.ts +7 -0
- package/dist/tools/accounts-tool.js +34 -18
- package/dist/tools/admin.d.ts +10 -1
- package/dist/tools/admin.js +15 -11
- package/dist/tools/analytics.d.ts +10 -1
- package/dist/tools/analytics.js +13 -8
- package/dist/tools/calendar.d.ts +2 -1
- package/dist/tools/calendar.js +18 -14
- package/dist/tools/chat.d.ts +10 -1
- package/dist/tools/chat.js +13 -8
- package/dist/tools/contacts.d.ts +2 -1
- package/dist/tools/contacts.js +17 -13
- package/dist/tools/docs.d.ts +2 -1
- package/dist/tools/docs.js +34 -30
- package/dist/tools/drive.d.ts +2 -1
- package/dist/tools/drive.js +65 -50
- package/dist/tools/forms.d.ts +10 -1
- package/dist/tools/forms.js +19 -12
- package/dist/tools/generated/_shared.d.ts +2 -1
- package/dist/tools/generated/_shared.js +7 -3
- package/dist/tools/generated/admin.d.ts +2 -1
- package/dist/tools/generated/admin.js +265 -265
- package/dist/tools/generated/analytics.d.ts +2 -1
- package/dist/tools/generated/analytics.js +125 -125
- package/dist/tools/generated/appsmarket.d.ts +2 -1
- package/dist/tools/generated/appsmarket.js +5 -5
- package/dist/tools/generated/calendar.d.ts +2 -1
- package/dist/tools/generated/calendar.js +55 -55
- package/dist/tools/generated/chat.d.ts +2 -1
- package/dist/tools/generated/chat.js +95 -95
- package/dist/tools/generated/classroom.d.ts +2 -1
- package/dist/tools/generated/classroom.js +209 -209
- package/dist/tools/generated/cloudidentity.d.ts +2 -1
- package/dist/tools/generated/cloudidentity.js +133 -133
- package/dist/tools/generated/cloudsearch.d.ts +2 -1
- package/dist/tools/generated/cloudsearch.js +99 -99
- package/dist/tools/generated/contacts.d.ts +2 -1
- package/dist/tools/generated/contacts.js +29 -29
- package/dist/tools/generated/drive.d.ts +2 -1
- package/dist/tools/generated/drive.js +71 -71
- package/dist/tools/generated/driveactivity.d.ts +2 -1
- package/dist/tools/generated/driveactivity.js +3 -3
- package/dist/tools/generated/drivelabels.d.ts +2 -1
- package/dist/tools/generated/drivelabels.js +53 -53
- package/dist/tools/generated/forms.d.ts +2 -1
- package/dist/tools/generated/forms.js +7 -7
- package/dist/tools/generated/gmail.d.ts +2 -1
- package/dist/tools/generated/gmail.js +117 -117
- package/dist/tools/generated/groupsmigration.d.ts +2 -1
- package/dist/tools/generated/groupsmigration.js +3 -3
- package/dist/tools/generated/groupssettings.d.ts +2 -1
- package/dist/tools/generated/groupssettings.js +7 -7
- package/dist/tools/generated/index.d.ts +2 -1
- package/dist/tools/generated/keep.d.ts +2 -1
- package/dist/tools/generated/keep.js +15 -15
- package/dist/tools/generated/licensing.d.ts +2 -1
- package/dist/tools/generated/licensing.js +15 -15
- package/dist/tools/generated/meet.d.ts +2 -1
- package/dist/tools/generated/meet.js +27 -27
- package/dist/tools/generated/postmaster.d.ts +2 -1
- package/dist/tools/generated/postmaster.js +9 -9
- package/dist/tools/generated/reseller.d.ts +2 -1
- package/dist/tools/generated/reseller.js +35 -35
- package/dist/tools/generated/script.d.ts +2 -1
- package/dist/tools/generated/script.js +33 -33
- package/dist/tools/generated/searchconsole.d.ts +2 -1
- package/dist/tools/generated/searchconsole.js +3 -3
- package/dist/tools/generated/sheets.d.ts +2 -1
- package/dist/tools/generated/sheets.js +15 -15
- package/dist/tools/generated/tasks.d.ts +2 -1
- package/dist/tools/generated/tasks.js +5 -5
- package/dist/tools/generated/vault.d.ts +2 -1
- package/dist/tools/generated/vault.js +67 -67
- package/dist/tools/generated/workspaceevents.d.ts +2 -1
- package/dist/tools/generated/workspaceevents.js +31 -31
- package/dist/tools/gmail.d.ts +2 -1
- package/dist/tools/gmail.js +45 -33
- package/dist/tools/google-api.d.ts +2 -3
- package/dist/tools/google-api.js +30 -16
- package/dist/tools/meet.d.ts +10 -1
- package/dist/tools/meet.js +13 -9
- package/dist/tools/searchconsole.d.ts +2 -1
- package/dist/tools/searchconsole.js +17 -13
- package/dist/tools/sheets.d.ts +2 -1
- package/dist/tools/sheets.js +36 -32
- package/dist/tools/slides.d.ts +10 -1
- package/dist/tools/slides.js +17 -10
- package/dist/tools/tasks.d.ts +2 -1
- package/dist/tools/tasks.js +19 -15
- package/dist/trim.d.ts +5 -0
- package/dist/trim.js +20 -0
- package/dist/usage-metrics.d.ts +23 -0
- package/dist/usage-metrics.js +65 -47
- package/dist/write-control.js +10 -2
- package/package.json +27 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// H4 F6: GET /authorize is unauthenticated, and a client_id on an allowlisted
|
|
2
|
+
// issuer with a path never seen before forces an outbound fetch. So the
|
|
3
|
+
// fetches running at once are capped, one client_id is fetched once however
|
|
4
|
+
// many requests wait on it, and only a document that passed validation is
|
|
5
|
+
// kept. Errors are never cached (CIMD section 5.2), and a negative cache would
|
|
6
|
+
// not help anyway: the attack varies the path, so its key never repeats.
|
|
7
|
+
import { CimdHttpError, isTransientFetchError, SsrfBlockedError } from './ssrf-guard.js';
|
|
8
|
+
import { logSafe } from './trim.js';
|
|
9
|
+
export const CIMD_TTL_MS = 5 * 60_000;
|
|
10
|
+
/** The oldest validated document still served while the cap is full or the issuer cannot answer. */
|
|
11
|
+
export const CIMD_MAX_STALE_MS = 24 * 60 * 60_000;
|
|
12
|
+
/** Fetches at once. A lookup can outlive the fetch that gave up on it, so ssrf-guard caps its own lookups apart. */
|
|
13
|
+
export const CIMD_MAX_IN_FLIGHT = 4;
|
|
14
|
+
export const CIMD_CACHE_MAX = 256;
|
|
15
|
+
const BUSY_LOG_EVERY_MS = 60_000;
|
|
16
|
+
export class CimdClientCache {
|
|
17
|
+
deps;
|
|
18
|
+
entries = new Map();
|
|
19
|
+
// One entry per running fetch, so its size is the number of permits taken.
|
|
20
|
+
inFlight = new Map();
|
|
21
|
+
busyLoggedAt = -Infinity;
|
|
22
|
+
busySuppressed = 0;
|
|
23
|
+
constructor(deps) {
|
|
24
|
+
this.deps = deps;
|
|
25
|
+
}
|
|
26
|
+
get size() {
|
|
27
|
+
return this.entries.size;
|
|
28
|
+
}
|
|
29
|
+
/** `validate` runs once per fetch, in the request that started it, and every
|
|
30
|
+
* request waiting on that fetch gets the same answer. */
|
|
31
|
+
async resolve(clientId, validate) {
|
|
32
|
+
const entry = this.entries.get(clientId);
|
|
33
|
+
const age = entry ? this.deps.now() - entry.at : 0;
|
|
34
|
+
if (entry && age < CIMD_TTL_MS)
|
|
35
|
+
return { doc: entry.doc, fresh: false };
|
|
36
|
+
if (entry && age >= CIMD_MAX_STALE_MS)
|
|
37
|
+
this.entries.delete(clientId);
|
|
38
|
+
const running = this.inFlight.get(clientId);
|
|
39
|
+
if (running) {
|
|
40
|
+
const out = await running;
|
|
41
|
+
return 'doc' in out ? { doc: out.doc, fresh: false } : out;
|
|
42
|
+
}
|
|
43
|
+
if (this.inFlight.size >= CIMD_MAX_IN_FLIGHT) {
|
|
44
|
+
// A flood of fresh paths is exactly when the cap is full, and a client
|
|
45
|
+
// that signed in before must still be able to.
|
|
46
|
+
const stale = this.entries.get(clientId);
|
|
47
|
+
if (stale)
|
|
48
|
+
return { doc: stale.doc, fresh: false };
|
|
49
|
+
this.noteBusy();
|
|
50
|
+
return { busy: true };
|
|
51
|
+
}
|
|
52
|
+
return this.fetchFor(clientId, validate);
|
|
53
|
+
}
|
|
54
|
+
async fetchFor(clientId, validate) {
|
|
55
|
+
// Never rejects: neither core nor a host installs an unhandledRejection
|
|
56
|
+
// handler, and a waiter that stopped listening must not take the process down.
|
|
57
|
+
let settle;
|
|
58
|
+
this.inFlight.set(clientId, new Promise((r) => (settle = r)));
|
|
59
|
+
let out = { error: 'E_CIMD_INVALID' };
|
|
60
|
+
try {
|
|
61
|
+
const doc = await this.deps.fetch(clientId);
|
|
62
|
+
if (validate(doc)) {
|
|
63
|
+
this.store(clientId, doc);
|
|
64
|
+
out = { doc, fresh: true };
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
this.entries.delete(clientId);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
out = { error: e instanceof SsrfBlockedError ? 'E_CIMD_SSRF_BLOCKED' : 'E_CIMD_INVALID' };
|
|
72
|
+
// A document that went bad is never served stale. An issuer that is down
|
|
73
|
+
// or overloaded says nothing about the document, so the last good one
|
|
74
|
+
// stays and answers this fetch's requests too (stale-if-error; the error
|
|
75
|
+
// itself is never stored).
|
|
76
|
+
const stale = this.entries.get(clientId);
|
|
77
|
+
if (!saysNothingAboutDocument(e))
|
|
78
|
+
this.entries.delete(clientId);
|
|
79
|
+
else if (stale && this.deps.now() - stale.at < CIMD_MAX_STALE_MS)
|
|
80
|
+
out = { doc: stale.doc, fresh: false };
|
|
81
|
+
// #13: the detail goes to the log only; the caller gets a generic answer.
|
|
82
|
+
this.deps.log(`CIMD fetch failed for ${logSafe(clientId, 128)}: ${logSafe(e instanceof Error ? e.message : 'non-Error rejection', 200)}`);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
this.inFlight.delete(clientId);
|
|
86
|
+
settle(out);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
store(clientId, doc) {
|
|
91
|
+
this.entries.delete(clientId);
|
|
92
|
+
while (this.entries.size >= CIMD_CACHE_MAX) {
|
|
93
|
+
const oldest = this.entries.keys().next().value;
|
|
94
|
+
if (oldest === undefined)
|
|
95
|
+
break;
|
|
96
|
+
this.entries.delete(oldest);
|
|
97
|
+
}
|
|
98
|
+
this.entries.set(clientId, { doc, at: this.deps.now() });
|
|
99
|
+
}
|
|
100
|
+
// Over-cap requests arrive at whatever rate the caller likes.
|
|
101
|
+
noteBusy() {
|
|
102
|
+
const now = this.deps.now();
|
|
103
|
+
if (now >= this.busyLoggedAt && now - this.busyLoggedAt < BUSY_LOG_EVERY_MS) {
|
|
104
|
+
this.busySuppressed++;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const suppressed = this.busySuppressed;
|
|
108
|
+
this.busyLoggedAt = now;
|
|
109
|
+
this.busySuppressed = 0;
|
|
110
|
+
this.deps.log(`CIMD busy: in-flight cap reached${suppressed > 0 ? ` (n=${suppressed} suppressed)` : ''}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function saysNothingAboutDocument(e) {
|
|
114
|
+
if (isTransientFetchError(e))
|
|
115
|
+
return true;
|
|
116
|
+
return e instanceof CimdHttpError && (e.status === 408 || e.status === 429 || e.status >= 500);
|
|
117
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
1
|
import { OAuth2Client } from 'googleapis-common';
|
|
2
|
-
import type { Account } from './accounts.js';
|
|
3
|
-
|
|
2
|
+
import type { Account, AccountSet } from './accounts.js';
|
|
3
|
+
import type { TokenData } from './types.js';
|
|
4
|
+
export interface GetClientDeps {
|
|
5
|
+
/** Live account view — read at call time, never a captured snapshot. */
|
|
6
|
+
accounts: () => AccountSet;
|
|
7
|
+
readToken: (alias: string) => TokenData | null;
|
|
8
|
+
updateToken: (alias: string, updates: object) => void;
|
|
9
|
+
/** Cross-process staleness probe (single-owner global registry only). */
|
|
10
|
+
refreshIfStale?: () => void;
|
|
11
|
+
}
|
|
12
|
+
/** Test-only visibility into the dedupe map. */
|
|
13
|
+
export declare function __inflightSizeForTest(): number;
|
|
14
|
+
/**
|
|
15
|
+
* Per-context client factory: `contextKey` namespaces the single-flight map
|
|
16
|
+
* (one lane per tenant), `deps` carry that context's account view and token
|
|
17
|
+
* custody. The free core builds exactly one, bound to the global registry.
|
|
18
|
+
*/
|
|
19
|
+
export declare function makeGetClient(contextKey: string, deps: GetClientDeps): (account: Account) => Promise<OAuth2Client>;
|
|
20
|
+
/** Injection surface for curated tool files: absent = the single-owner
|
|
21
|
+
* getClient below (today's behavior); a tenant context passes its own. */
|
|
22
|
+
export interface CuratedToolDeps {
|
|
23
|
+
getClientFn?: typeof getClient;
|
|
24
|
+
/** Whether tools may read or write the server host's disk (local uploads,
|
|
25
|
+
* downloads, attachments). Absent = true; the host belongs to its operator,
|
|
26
|
+
* so a non-owner context passes false. */
|
|
27
|
+
localFiles?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/** The single-owner client, bound to the global registry + token store. The
|
|
30
|
+
* operator surfaces that never carry a tenant (api-probe, executor and
|
|
31
|
+
* escape-hatch `deps.getClientFn ?? getClient` defaults) resolve through
|
|
32
|
+
* this. */
|
|
33
|
+
export declare const getClient: (account: Account) => Promise<OAuth2Client>;
|
package/dist/client.js
CHANGED
|
Binary file
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import type { ServerTarget } from './http-transport.js';
|
|
3
|
+
import { ToolRegistry, type DiscoveryMode } from './registry.js';
|
|
4
|
+
import { type IdentityContext } from './identity.js';
|
|
5
|
+
import type { Metrics } from './usage-metrics.js';
|
|
6
|
+
export declare function buildRegistry(server: McpServer, ctx: IdentityContext, mode?: DiscoveryMode, metrics?: Metrics | null): ToolRegistry;
|
|
7
|
+
/** The per-request hooks bound to one registry: argument shapes, unknown-
|
|
8
|
+
* argument screening and the account a validation envelope names. Both
|
|
9
|
+
* transports and every resolved server target build them here, so a mistyped
|
|
10
|
+
* argument behaves the same wherever it arrives. */
|
|
11
|
+
export declare function requestHooksFor(registry: ToolRegistry, ctx: Pick<IdentityContext, 'accounts'>, metrics?: Metrics | null, env?: NodeJS.ProcessEnv): Omit<ServerTarget, 'server'>;
|
package/dist/compose.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// S1.12: registry composition, extracted from index.ts so the tenancy module
|
|
2
|
+
// (and tests) can build registries without touching the CLI entrypoint. This
|
|
3
|
+
// module must stay free of import-time side effects of its own: no argv
|
|
4
|
+
// parsing, no process.exit, no prints (accounts.js module-load resolution is
|
|
5
|
+
// the one inherited, pre-existing exception on the import chain).
|
|
6
|
+
import { GENERATED_SERVICES } from './tools/generated/index.js';
|
|
7
|
+
import { GENERATED_GATES, SERVICES, servicesAwaitingRestart, unknownToolMessage } from './services.js';
|
|
8
|
+
import { argNormalizationEnabled } from './arg-normalize.js';
|
|
9
|
+
import { unknownArgMode } from './arg-strict.js';
|
|
10
|
+
import { ToolRegistry } from './registry.js';
|
|
11
|
+
import { registerDiscoverTools } from './discover.js';
|
|
12
|
+
import { registerEscapeTools } from './tools/google-api.js';
|
|
13
|
+
import { accountHealthDepsFor, registerAccountTools } from './tools/accounts-tool.js';
|
|
14
|
+
import { registerDiagnoseTool } from './doctor.js';
|
|
15
|
+
import { registerAccountWizardTools } from './tools/account-wizard.js';
|
|
16
|
+
import { getToolsets, toolsetEnabled } from './toolsets.js';
|
|
17
|
+
import { isOwnerContext } from './identity.js';
|
|
18
|
+
import { resolveScopesForAccount } from './auth.js';
|
|
19
|
+
export function buildRegistry(server, ctx, mode, metrics = null) {
|
|
20
|
+
const policy = ctx.policy;
|
|
21
|
+
// The registry's account view IS the context's: fan-out expansion,
|
|
22
|
+
// selector validation and default-account injection all follow this
|
|
23
|
+
// context, not the process global (identical for the free core's one ctx).
|
|
24
|
+
const registry = new ToolRegistry(server, policy, mode, metrics, () => ctx.accounts);
|
|
25
|
+
// Every handler resolves its client, token reads and scope hints through
|
|
26
|
+
// this context; ctx.accounts is read per call, never snapshotted here, so
|
|
27
|
+
// the owner's live getter stays live.
|
|
28
|
+
const owner = isOwnerContext(ctx);
|
|
29
|
+
const clientDeps = { getClientFn: ctx.getClient, localFiles: owner };
|
|
30
|
+
const execDeps = {
|
|
31
|
+
getClientFn: ctx.getClient,
|
|
32
|
+
scopeDeps: {
|
|
33
|
+
readTokenFn: ctx.tokenStore.readToken,
|
|
34
|
+
profileFn: (alias) => resolveScopesForAccount(alias, ctx.accounts),
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const toolsets = getToolsets();
|
|
38
|
+
if (toolsets !== 'all') {
|
|
39
|
+
const known = new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)]);
|
|
40
|
+
for (const requested of toolsets) {
|
|
41
|
+
if (!known.has(requested)) {
|
|
42
|
+
process.stderr.write(`GOOGLE_TOOLSETS: unknown service "${requested}" ignored\n`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const svc of SERVICES) {
|
|
47
|
+
if (!toolsetEnabled(toolsets, svc.name))
|
|
48
|
+
continue;
|
|
49
|
+
if (svc.enabled && !svc.enabled(ctx.accounts)) {
|
|
50
|
+
if (toolsets !== 'all') {
|
|
51
|
+
const hint = svc.name === 'admin' ? 'set admin on an account/profile (or GOOGLE_ADMIN_ACCOUNTS)' : `add "${svc.name}" to an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES)`;
|
|
52
|
+
process.stderr.write(`GOOGLE_TOOLSETS: "${svc.name}" requested but not enabled — ${hint}\n`);
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
svc.register(registry, clientDeps);
|
|
57
|
+
}
|
|
58
|
+
for (const gen of GENERATED_SERVICES) {
|
|
59
|
+
if (!toolsetEnabled(toolsets, gen.name))
|
|
60
|
+
continue;
|
|
61
|
+
const curated = SERVICES.find((s) => s.name === gen.name);
|
|
62
|
+
const gate = curated?.enabled ?? GENERATED_GATES[gen.name]?.enabled;
|
|
63
|
+
if (gate && !gate(ctx.accounts)) {
|
|
64
|
+
if (!curated && toolsets !== 'all') {
|
|
65
|
+
process.stderr.write(`GOOGLE_TOOLSETS: "${gen.name}" requested but not enabled — ${GENERATED_GATES[gen.name].hint}\n`);
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
gen.register(registry, execDeps);
|
|
70
|
+
}
|
|
71
|
+
if (registry.services().length === 0) {
|
|
72
|
+
const known = [...new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)])].sort();
|
|
73
|
+
throw new Error(`GOOGLE_TOOLSETS="${process.env.GOOGLE_TOOLSETS ?? ''}" selected no enabled services. ` +
|
|
74
|
+
`Known services: ${known.join(', ')}. ` +
|
|
75
|
+
`Note: optional services need their bundle in an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES); admin needs an admin account/profile.`);
|
|
76
|
+
}
|
|
77
|
+
registerDiscoverTools(registry, policy);
|
|
78
|
+
registerEscapeTools(registry, policy, execDeps);
|
|
79
|
+
registerAccountTools(registry, accountHealthDepsFor(ctx.tokenStore));
|
|
80
|
+
registerDiagnoseTool(registry, ctx);
|
|
81
|
+
// The wizard edits the owner's config.json and token store; any other
|
|
82
|
+
// context has neither.
|
|
83
|
+
if (owner)
|
|
84
|
+
registerAccountWizardTools(registry, server, () => servicesAwaitingRestart(registry));
|
|
85
|
+
return registry;
|
|
86
|
+
}
|
|
87
|
+
/** The per-request hooks bound to one registry: argument shapes, unknown-
|
|
88
|
+
* argument screening and the account a validation envelope names. Both
|
|
89
|
+
* transports and every resolved server target build them here, so a mistyped
|
|
90
|
+
* argument behaves the same wherever it arrives. */
|
|
91
|
+
export function requestHooksFor(registry, ctx, metrics = null, env = process.env) {
|
|
92
|
+
const mode = unknownArgMode(env);
|
|
93
|
+
return {
|
|
94
|
+
argShapeFor: argNormalizationEnabled(env) ? (tool) => registry.argShape(tool) : undefined,
|
|
95
|
+
strictArgs: mode === 'off'
|
|
96
|
+
? undefined
|
|
97
|
+
: {
|
|
98
|
+
mode,
|
|
99
|
+
declaredFor: (tool) => registry.declaredKeys(tool),
|
|
100
|
+
siblingsFor: (tool, keys) => registry.siblingSpellings(tool, keys),
|
|
101
|
+
unknownTool: (name) => unknownToolMessage(registry, name),
|
|
102
|
+
onDrop: metrics ? (tool, keys) => metrics.recordArgDrop(tool, keys) : undefined,
|
|
103
|
+
},
|
|
104
|
+
validationEnvelope: {
|
|
105
|
+
isKnownTool: (name) => registry.hasTool(name),
|
|
106
|
+
defaultAccount: () => ctx.accounts.defaultAccount,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
package/dist/config-file.d.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
export declare const CONFIG_VERSION = 1;
|
|
2
2
|
export declare function configDir(env?: NodeJS.ProcessEnv): string;
|
|
3
3
|
export declare function configFilePath(env?: NodeJS.ProcessEnv): string;
|
|
4
|
+
export declare function tenantsDir(env?: NodeJS.ProcessEnv): string;
|
|
5
|
+
export declare function tenantConfigFilePath(tenantId: string, env?: NodeJS.ProcessEnv): string;
|
|
6
|
+
export declare function tenantTokenDir(tenantId: string, env?: NodeJS.ProcessEnv): string;
|
|
7
|
+
/** Create and permission-harden the tenant directory chain. mkdirSync's
|
|
8
|
+
* recursive mode only stamps the DEEPEST newly-created dir, so every level
|
|
9
|
+
* gets an explicit chmod: the shared tenants/ root (created by whichever
|
|
10
|
+
* tenant provisions first) must never stay group-readable. */
|
|
11
|
+
export declare function ensureTenantDirs(tenantId: string, env?: NodeJS.ProcessEnv): {
|
|
12
|
+
configFile: string;
|
|
13
|
+
tokenDir: string;
|
|
14
|
+
};
|
|
4
15
|
export declare const ALIAS_RE: RegExp;
|
|
16
|
+
export declare const RESERVED_ALIASES: string[];
|
|
17
|
+
export declare function isReservedAlias(name: string): boolean;
|
|
5
18
|
export interface ConfigFile {
|
|
6
19
|
version: number;
|
|
7
20
|
accounts?: Record<string, {
|
package/dist/config-file.js
CHANGED
|
@@ -10,6 +10,39 @@ export function configDir(env = process.env) {
|
|
|
10
10
|
export function configFilePath(env = process.env) {
|
|
11
11
|
return path.join(configDir(env), 'config.json');
|
|
12
12
|
}
|
|
13
|
+
export function tenantsDir(env = process.env) {
|
|
14
|
+
return path.join(configDir(env), 'tenants');
|
|
15
|
+
}
|
|
16
|
+
// A tenant id becomes a filesystem path segment exactly like an account
|
|
17
|
+
// alias, so it rides the same traversal guard. 'owner' is the single-owner
|
|
18
|
+
// context's subject and client lane, so no tenant may take it.
|
|
19
|
+
function assertTenantId(tenantId) {
|
|
20
|
+
if (!ALIAS_RE.test(tenantId) || isReservedAlias(tenantId) || tenantId === 'owner') {
|
|
21
|
+
throw new Error(`E_TENANT_ID_INVALID: invalid tenant id "${tenantId}".`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function tenantConfigFilePath(tenantId, env = process.env) {
|
|
25
|
+
assertTenantId(tenantId);
|
|
26
|
+
return path.join(tenantsDir(env), tenantId, 'config.json');
|
|
27
|
+
}
|
|
28
|
+
export function tenantTokenDir(tenantId, env = process.env) {
|
|
29
|
+
assertTenantId(tenantId);
|
|
30
|
+
return path.join(tenantsDir(env), tenantId, 'tokens');
|
|
31
|
+
}
|
|
32
|
+
/** Create and permission-harden the tenant directory chain. mkdirSync's
|
|
33
|
+
* recursive mode only stamps the DEEPEST newly-created dir, so every level
|
|
34
|
+
* gets an explicit chmod: the shared tenants/ root (created by whichever
|
|
35
|
+
* tenant provisions first) must never stay group-readable. */
|
|
36
|
+
export function ensureTenantDirs(tenantId, env = process.env) {
|
|
37
|
+
const tokenDir = tenantTokenDir(tenantId, env);
|
|
38
|
+
const levels = [configDir(env), tenantsDir(env), path.join(tenantsDir(env), tenantId), tokenDir];
|
|
39
|
+
for (const dir of levels) {
|
|
40
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
41
|
+
if (process.platform !== 'win32')
|
|
42
|
+
fs.chmodSync(dir, 0o700);
|
|
43
|
+
}
|
|
44
|
+
return { configFile: tenantConfigFilePath(tenantId, env), tokenDir };
|
|
45
|
+
}
|
|
13
46
|
// Alias charset is load-bearing security, not cosmetics: the alias becomes a
|
|
14
47
|
// tokenDir path segment (encPath), so this guard blocks path traversal.
|
|
15
48
|
export const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
|
|
@@ -22,7 +55,10 @@ const accountEntrySchema = z.strictObject({
|
|
|
22
55
|
// masterKey, jwtKey, clientId) fails validation, structurally enforcing
|
|
23
56
|
// secrets-env-only. scopeProfiles/defaultAccount/discovery/toolsets are part
|
|
24
57
|
// of the frozen envelope; their consumers land in later slices.
|
|
25
|
-
const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
|
|
58
|
+
export const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
|
|
59
|
+
export function isReservedAlias(name) {
|
|
60
|
+
return RESERVED_ALIASES.includes(name);
|
|
61
|
+
}
|
|
26
62
|
const configSchema = z.strictObject({
|
|
27
63
|
version: z.number().int(),
|
|
28
64
|
accounts: z.record(z.string().regex(ALIAS_RE), accountEntrySchema).optional(),
|
package/dist/discover.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { describePolicy } from './write-control.js';
|
|
3
|
+
import { hints } from './hint-provider.js';
|
|
3
4
|
// The description budget is the whole point of lazy mode: a full op list per
|
|
4
5
|
// service put lazy tools/list at ~11.5k tokens (RQ2), so descriptions carry a
|
|
5
6
|
// CAPPED vocabulary and the complete catalog stays in this tool's RESULT.
|
|
@@ -91,6 +92,7 @@ export function registerDiscoverTools(registry, policy) {
|
|
|
91
92
|
}, async ({ query }) => {
|
|
92
93
|
const operations = registry.catalog(service, query);
|
|
93
94
|
registry.reveal(service);
|
|
95
|
+
const rendered = hints()?.render({ kind: 'policy_summary', policy });
|
|
94
96
|
return {
|
|
95
97
|
content: [
|
|
96
98
|
{
|
|
@@ -98,7 +100,7 @@ export function registerDiscoverTools(registry, policy) {
|
|
|
98
100
|
text: JSON.stringify({
|
|
99
101
|
service,
|
|
100
102
|
operations,
|
|
101
|
-
writeControl: describePolicy(policy),
|
|
103
|
+
writeControl: rendered?.hint ?? describePolicy(policy),
|
|
102
104
|
next: operations.length > 0
|
|
103
105
|
? 'Call the chosen tool by name; it is now listed and callable.'
|
|
104
106
|
: `No ${service} operation matches "${query}". Call again without query for the full catalog.`,
|
|
@@ -49,5 +49,8 @@ export declare function loadMethodIndex(api: string, deps?: DiscoveryDeps): Prom
|
|
|
49
49
|
export declare function clearDiscoveryMemoryCache(): void;
|
|
50
50
|
export declare function cudFromMethod(method: Pick<DiscoveryMethod, 'httpMethod' | 'id'>): 'read' | 'create' | 'update' | 'delete';
|
|
51
51
|
export declare function expandPath(template: string, pathParams: Record<string, string>): string;
|
|
52
|
+
/** Search keywords are a few words; the caps keep a caller-sized query from
|
|
53
|
+
* driving index-size x token-count substring scans on the event loop. */
|
|
54
|
+
export declare const MAX_SEARCH_QUERY = 500;
|
|
52
55
|
export declare function searchMethods(index: DiscoveryMethod[], query: string, limit?: number): DiscoveryMethod[];
|
|
53
56
|
export {};
|
package/dist/discovery-client.js
CHANGED
|
@@ -74,6 +74,21 @@ export function discoveryCacheDir(env = process.env) {
|
|
|
74
74
|
return (env.DISCOVERY_CACHE_PATH ??
|
|
75
75
|
path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'mcp-google-multi', 'discovery'));
|
|
76
76
|
}
|
|
77
|
+
// Default cache path only (an operator's DISCOVERY_CACHE_PATH may be shared,
|
|
78
|
+
// even /tmp, and keeps its mode), only when this process owns it, and best
|
|
79
|
+
// effort. Clears group/other bits and keeps setuid/setgid/sticky.
|
|
80
|
+
function tightenOwnDir(dir) {
|
|
81
|
+
if (process.platform === 'win32')
|
|
82
|
+
return;
|
|
83
|
+
try {
|
|
84
|
+
const st = fs.statSync(dir);
|
|
85
|
+
if (st.uid === process.getuid?.() && (st.mode & 0o077) !== 0)
|
|
86
|
+
fs.chmodSync(dir, st.mode & ~0o077 & 0o7777);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// the cache still works with the directory's own mode
|
|
90
|
+
}
|
|
91
|
+
}
|
|
77
92
|
// A poisoned cache file could repoint baseUrl off-Google; refuse to send the
|
|
78
93
|
// user's Bearer token anywhere but a googleapis.com host.
|
|
79
94
|
export function isGoogleApiUrl(url) {
|
|
@@ -135,6 +150,7 @@ export async function loadMethodIndex(api, deps = {}) {
|
|
|
135
150
|
throw new Error(`Unknown api "${api}". Known: ${Object.keys(SUPPORTED_APIS).join(', ')}`);
|
|
136
151
|
}
|
|
137
152
|
const fetchFn = deps.fetchFn ?? ((url) => fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }));
|
|
153
|
+
const defaultDir = deps.cacheDir === undefined && process.env.DISCOVERY_CACHE_PATH === undefined;
|
|
138
154
|
const cacheDir = deps.cacheDir ?? discoveryCacheDir();
|
|
139
155
|
const cacheFile = path.join(cacheDir, `${api}.json`);
|
|
140
156
|
const readCache = () => {
|
|
@@ -172,7 +188,9 @@ export async function loadMethodIndex(api, deps = {}) {
|
|
|
172
188
|
if (!res.ok)
|
|
173
189
|
throw new Error(`HTTP ${res.status}`);
|
|
174
190
|
doc = (await res.json());
|
|
175
|
-
fs.mkdirSync(cacheDir, { recursive: true });
|
|
191
|
+
fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
|
|
192
|
+
if (defaultDir)
|
|
193
|
+
tightenOwnDir(cacheDir);
|
|
176
194
|
fs.writeFileSync(cacheFile, JSON.stringify(doc), { mode: 0o600 });
|
|
177
195
|
break;
|
|
178
196
|
}
|
|
@@ -263,8 +281,13 @@ export function expandPath(template, pathParams) {
|
|
|
263
281
|
return segments.map(encodeURIComponent).join('/');
|
|
264
282
|
});
|
|
265
283
|
}
|
|
284
|
+
/** Search keywords are a few words; the caps keep a caller-sized query from
|
|
285
|
+
* driving index-size x token-count substring scans on the event loop. */
|
|
286
|
+
export const MAX_SEARCH_QUERY = 500;
|
|
287
|
+
const MAX_SEARCH_TOKENS = 16;
|
|
266
288
|
export function searchMethods(index, query, limit = 10) {
|
|
267
|
-
const
|
|
289
|
+
const words = query.slice(0, MAX_SEARCH_QUERY).toLowerCase().split(/\s+/).filter(Boolean);
|
|
290
|
+
const tokens = [...new Set(words)].slice(0, MAX_SEARCH_TOKENS);
|
|
268
291
|
if (tokens.length === 0)
|
|
269
292
|
return index.slice(0, limit);
|
|
270
293
|
const scored = index
|
package/dist/doctor.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ToolRegistry } from './registry.js';
|
|
2
2
|
import { getAccountSet } from './accounts.js';
|
|
3
3
|
import { type AccountHealth } from './tools/accounts-tool.js';
|
|
4
|
+
import { type IdentityContext } from './identity.js';
|
|
4
5
|
import { peekMasterKeyProvenance } from './master-key.js';
|
|
5
6
|
import { type HttpConfig } from './http-config.js';
|
|
6
7
|
export type Verdict = 'ok' | 'warn' | 'fail' | 'unknown';
|
|
@@ -58,7 +59,9 @@ export declare function apiEnableLink(api: string): string;
|
|
|
58
59
|
export declare function usageMetricsStatusLine(env: Record<string, string | undefined>): string;
|
|
59
60
|
/** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
|
|
60
61
|
export declare function overallVerdict(sections: DiagnosticSection[]): Verdict;
|
|
61
|
-
export declare function runDiagnostics(deps?: DiagnosticsDeps
|
|
62
|
+
export declare function runDiagnostics(deps?: DiagnosticsDeps, opts?: {
|
|
63
|
+
scope?: 'operator' | 'tenant';
|
|
64
|
+
}): Promise<DiagnosticsReport>;
|
|
62
65
|
/** doctor CLI (brew-doctor style): glyph-coded human report. */
|
|
63
66
|
export declare function renderDoctorText(report: DiagnosticsReport): string;
|
|
64
67
|
/** Redacted, paste-ready bug report (BR8): verdicts + provenance labels + token
|
|
@@ -66,6 +69,7 @@ export declare function renderDoctorText(report: DiagnosticsReport): string;
|
|
|
66
69
|
export declare function renderReport(report: DiagnosticsReport, version: string): string;
|
|
67
70
|
/** Exit non-zero when any section FAILs; with strict, WARN counts too. */
|
|
68
71
|
export declare function exitCodeFor(report: DiagnosticsReport, strict: boolean): number;
|
|
72
|
+
export type DiagnoseContext = Pick<IdentityContext, 'subject' | 'accounts' | 'getClient' | 'tokenStore'>;
|
|
69
73
|
/** Agent-callable structured health report (read-only). Mirrors `doctor`'s
|
|
70
74
|
* engine. Registered as a META tool, like `account_list`: it introspects this
|
|
71
75
|
* server rather than Google data, and as a normal tool it became a service of
|
|
@@ -73,7 +77,7 @@ export declare function exitCodeFor(report: DiagnosticsReport, strict: boolean):
|
|
|
73
77
|
* from already-registered services, and this runs after that), so it was
|
|
74
78
|
* advertised only once something else expanded the surface. The README sends
|
|
75
79
|
* people here when they are stuck, so it has to be findable. */
|
|
76
|
-
export declare function registerDiagnoseTool(registry: ToolRegistry): void;
|
|
80
|
+
export declare function registerDiagnoseTool(registry: ToolRegistry, ctx?: DiagnoseContext): void;
|
|
77
81
|
export interface ResetOptions {
|
|
78
82
|
account?: string;
|
|
79
83
|
regenerateKey: boolean;
|