mcp-google-multi 6.0.0-alpha.35 → 6.0.0-alpha.37
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.js +2 -2
- package/dist/config-file.d.ts +2 -0
- package/dist/config-file.js +4 -1
- package/dist/master-key.js +11 -5
- package/dist/mcp-token.d.ts +11 -5
- package/dist/mcp-token.js +8 -6
- package/dist/oauth-as.js +4 -4
- package/dist/registry-transfer.js +1 -2
- package/dist/registry.js +2 -2
- package/dist/tools/account-wizard.js +7 -14
- package/dist/tools/drive.js +3 -0
- package/dist/tools/google-api.js +4 -1
- package/dist/usage-metrics.d.ts +6 -0
- package/dist/usage-metrics.js +20 -14
- package/dist/write-control.d.ts +13 -0
- package/dist/write-control.js +43 -9
- package/package.json +1 -1
package/dist/accounts.js
CHANGED
|
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { loadEnvFiles } from './env-load.js';
|
|
5
|
-
import { CONFIG_VERSION, configDir, configFilePath, failStartup, loadConfigFile, mutateConfigFile } from './config-file.js';
|
|
5
|
+
import { ALIAS_RE, CONFIG_VERSION, configDir, configFilePath, failStartup, isReservedAlias, loadConfigFile, mutateConfigFile } from './config-file.js';
|
|
6
6
|
import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
|
|
7
7
|
const envLoad = loadEnvFiles();
|
|
8
8
|
const defaultTokenDir = path.join(configDir(), 'tokens');
|
|
@@ -40,7 +40,7 @@ function parseEnvAccounts(raw, adminAliases) {
|
|
|
40
40
|
}
|
|
41
41
|
// Restrict alias to a safe charset so it can't escape `tokenDir` via path traversal
|
|
42
42
|
// (e.g. "../../etc/passwd:foo@bar.com" in .env).
|
|
43
|
-
if (
|
|
43
|
+
if (!ALIAS_RE.test(alias) || isReservedAlias(alias)) {
|
|
44
44
|
throw new Error(`Invalid alias "${alias}". Allowed characters: letters, digits, underscore, hyphen.`);
|
|
45
45
|
}
|
|
46
46
|
if (aliases.includes(alias)) {
|
package/dist/config-file.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ 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
4
|
export declare const ALIAS_RE: RegExp;
|
|
5
|
+
export declare const RESERVED_ALIASES: string[];
|
|
6
|
+
export declare function isReservedAlias(name: string): boolean;
|
|
5
7
|
export interface ConfigFile {
|
|
6
8
|
version: number;
|
|
7
9
|
accounts?: Record<string, {
|
package/dist/config-file.js
CHANGED
|
@@ -22,7 +22,10 @@ const accountEntrySchema = z.strictObject({
|
|
|
22
22
|
// masterKey, jwtKey, clientId) fails validation, structurally enforcing
|
|
23
23
|
// secrets-env-only. scopeProfiles/defaultAccount/discovery/toolsets are part
|
|
24
24
|
// of the frozen envelope; their consumers land in later slices.
|
|
25
|
-
const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
|
|
25
|
+
export const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
|
|
26
|
+
export function isReservedAlias(name) {
|
|
27
|
+
return RESERVED_ALIASES.includes(name);
|
|
28
|
+
}
|
|
26
29
|
const configSchema = z.strictObject({
|
|
27
30
|
version: z.number().int(),
|
|
28
31
|
accounts: z.record(z.string().regex(ALIAS_RE), accountEntrySchema).optional(),
|
package/dist/master-key.js
CHANGED
|
@@ -38,17 +38,23 @@ let keychainFactory = systemKeychain;
|
|
|
38
38
|
export function __setKeychainFactoryForTest(fn) {
|
|
39
39
|
keychainFactory = fn ?? systemKeychain;
|
|
40
40
|
}
|
|
41
|
-
|
|
42
|
-
// (alias removed from the registry, or a partial GOOGLE_ACCOUNTS override in
|
|
43
|
-
// one client) must still stop a fresh key from being minted.
|
|
44
|
-
function anyTokenFileExists() {
|
|
41
|
+
function anyEncFileUnder(dir) {
|
|
45
42
|
try {
|
|
46
|
-
return fs.readdirSync(
|
|
43
|
+
return fs.readdirSync(dir, { recursive: true }).some((f) => String(f).endsWith('.enc'));
|
|
47
44
|
}
|
|
48
45
|
catch {
|
|
49
46
|
return false;
|
|
50
47
|
}
|
|
51
48
|
}
|
|
49
|
+
// Glob the token dir instead of iterating registered aliases: an orphan .enc
|
|
50
|
+
// (alias removed from the registry, or a partial GOOGLE_ACCOUNTS override in
|
|
51
|
+
// one client) must still stop a fresh key from being minted. The walk is
|
|
52
|
+
// recursive and also covers configDir()/tenants (the tenant-namespaced token
|
|
53
|
+
// root): a flat scan returns false the moment tokens nest, and minting a
|
|
54
|
+
// fresh MASTER_KEY over existing .enc files bricks them irreversibly.
|
|
55
|
+
function anyTokenFileExists() {
|
|
56
|
+
return anyEncFileUnder(getTokenDir()) || anyEncFileUnder(path.join(configDir(), 'tenants'));
|
|
57
|
+
}
|
|
52
58
|
function readKeyFile(filePath) {
|
|
53
59
|
try {
|
|
54
60
|
const raw = fs.readFileSync(filePath, 'utf8').trim();
|
package/dist/mcp-token.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface AccessTokenParams {
|
|
|
9
9
|
secret: Uint8Array;
|
|
10
10
|
ttlSec?: number;
|
|
11
11
|
iat: number;
|
|
12
|
+
sub: string;
|
|
12
13
|
}
|
|
13
14
|
export declare function signAccessToken(p: AccessTokenParams): Promise<string>;
|
|
14
15
|
export interface AccessClaims {
|
|
@@ -31,7 +32,7 @@ export interface CodePayload {
|
|
|
31
32
|
redirect_uri: string;
|
|
32
33
|
code_challenge: string;
|
|
33
34
|
resource: string;
|
|
34
|
-
sub:
|
|
35
|
+
sub: string;
|
|
35
36
|
}
|
|
36
37
|
export declare function signState(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
|
|
37
38
|
export declare function verifyState(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
|
|
@@ -57,7 +58,7 @@ export declare class ReplayGuard {
|
|
|
57
58
|
private evictExpired;
|
|
58
59
|
}
|
|
59
60
|
export interface RefreshRecord {
|
|
60
|
-
sub:
|
|
61
|
+
sub: string;
|
|
61
62
|
issuedAt: number;
|
|
62
63
|
family: string;
|
|
63
64
|
}
|
|
@@ -74,8 +75,13 @@ export declare class RefreshStore {
|
|
|
74
75
|
constructor(path: string, masterKey: string);
|
|
75
76
|
private load;
|
|
76
77
|
private save;
|
|
77
|
-
issue(nowMs: number, family?: string): string;
|
|
78
|
+
issue(nowMs: number, sub: string, family?: string): string;
|
|
78
79
|
/** Rotate a presented refresh token; null if unknown OR if the presented
|
|
79
|
-
* token was already rotated away (reuse => the family is revoked).
|
|
80
|
-
|
|
80
|
+
* token was already rotated away (reuse => the family is revoked). The
|
|
81
|
+
* record's sub is copied forward and returned so the caller can mint the
|
|
82
|
+
* matching access token without trusting anything client-supplied. */
|
|
83
|
+
rotate(oldToken: string, nowMs: number): {
|
|
84
|
+
token: string;
|
|
85
|
+
sub: string;
|
|
86
|
+
} | null;
|
|
81
87
|
}
|
package/dist/mcp-token.js
CHANGED
|
@@ -24,7 +24,7 @@ export async function signAccessToken(p) {
|
|
|
24
24
|
return new SignJWT({ scope: 'mcp:use', purpose: 'mcp_access' })
|
|
25
25
|
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
26
26
|
.setIssuer(p.base)
|
|
27
|
-
.setSubject(
|
|
27
|
+
.setSubject(p.sub)
|
|
28
28
|
.setAudience(`${p.base}/mcp`)
|
|
29
29
|
.setIssuedAt(p.iat)
|
|
30
30
|
.setExpirationTime(p.iat + ttl)
|
|
@@ -139,17 +139,19 @@ export class RefreshStore {
|
|
|
139
139
|
}
|
|
140
140
|
atomicWriteFileSync(this.path, encryptToken(data, this.masterKey), 0o600);
|
|
141
141
|
}
|
|
142
|
-
issue(nowMs, family) {
|
|
142
|
+
issue(nowMs, sub, family) {
|
|
143
143
|
return withFileLock(this.path, () => {
|
|
144
144
|
const token = randomBytes(32).toString('base64url');
|
|
145
145
|
const data = this.load();
|
|
146
|
-
data.active[token] = { sub
|
|
146
|
+
data.active[token] = { sub, issuedAt: nowMs, family: family ?? randomBytes(12).toString('hex') };
|
|
147
147
|
this.save(data);
|
|
148
148
|
return token;
|
|
149
149
|
});
|
|
150
150
|
}
|
|
151
151
|
/** Rotate a presented refresh token; null if unknown OR if the presented
|
|
152
|
-
* token was already rotated away (reuse => the family is revoked).
|
|
152
|
+
* token was already rotated away (reuse => the family is revoked). The
|
|
153
|
+
* record's sub is copied forward and returned so the caller can mint the
|
|
154
|
+
* matching access token without trusting anything client-supplied. */
|
|
153
155
|
rotate(oldToken, nowMs) {
|
|
154
156
|
return withFileLock(this.path, () => {
|
|
155
157
|
const data = this.load();
|
|
@@ -168,9 +170,9 @@ export class RefreshStore {
|
|
|
168
170
|
delete data.active[oldToken];
|
|
169
171
|
data.spent[oldToken] = rec.family;
|
|
170
172
|
const next = randomBytes(32).toString('base64url');
|
|
171
|
-
data.active[next] = { sub:
|
|
173
|
+
data.active[next] = { sub: rec.sub, issuedAt: nowMs, family: rec.family };
|
|
172
174
|
this.save(data);
|
|
173
|
-
return next;
|
|
175
|
+
return { token: next, sub: rec.sub };
|
|
174
176
|
});
|
|
175
177
|
}
|
|
176
178
|
}
|
package/dist/oauth-as.js
CHANGED
|
@@ -385,8 +385,8 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
385
385
|
if (!form.code_verifier || pkceS256(form.code_verifier) !== code.code_challenge) {
|
|
386
386
|
return json(res, 400, { error: 'invalid_grant', message: 'PKCE verification failed' });
|
|
387
387
|
}
|
|
388
|
-
const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl });
|
|
389
|
-
const refreshToken = refresh.issue(now());
|
|
388
|
+
const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl, sub: 'owner' });
|
|
389
|
+
const refreshToken = refresh.issue(now(), 'owner');
|
|
390
390
|
log('token issued grant=authorization_code');
|
|
391
391
|
return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: refreshToken, scope: 'mcp:use' });
|
|
392
392
|
}
|
|
@@ -394,9 +394,9 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
394
394
|
const next = refresh.rotate(form.refresh_token ?? '', now());
|
|
395
395
|
if (!next)
|
|
396
396
|
return json(res, 400, { error: 'invalid_grant', message: 'unknown or rotated refresh token' });
|
|
397
|
-
const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl });
|
|
397
|
+
const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl, sub: 'owner' });
|
|
398
398
|
log('token issued grant=refresh_token');
|
|
399
|
-
return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: next, scope: 'mcp:use' });
|
|
399
|
+
return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: next.token, scope: 'mcp:use' });
|
|
400
400
|
}
|
|
401
401
|
return json(res, 400, { error: 'unsupported_grant_type', message: 'authorization_code or refresh_token only' });
|
|
402
402
|
};
|
|
@@ -9,9 +9,8 @@ import * as readline from 'node:readline';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { encryptToken, decryptToken } from './token-store.js';
|
|
11
11
|
import { atomicWriteFileSync, atomicWriteWithLock } from './fs-atomic.js';
|
|
12
|
-
import { configFilePath, CONFIG_VERSION } from './config-file.js';
|
|
12
|
+
import { ALIAS_RE, configFilePath, CONFIG_VERSION } from './config-file.js';
|
|
13
13
|
import { getTokenDir } from './accounts.js';
|
|
14
|
-
const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
|
|
15
14
|
function resolveDeps(deps) {
|
|
16
15
|
return {
|
|
17
16
|
configPath: deps.configPath ?? configFilePath(),
|
package/dist/registry.js
CHANGED
|
@@ -168,9 +168,9 @@ export class ToolRegistry {
|
|
|
168
168
|
});
|
|
169
169
|
const guarded = cud === 'read'
|
|
170
170
|
? baseHandler
|
|
171
|
-
: (...args) => isAllowed({ name, service, cud }, policy)
|
|
171
|
+
: (...args) => isAllowed({ name, service, cud, scopes: config.requiredScopes }, policy)
|
|
172
172
|
? baseHandler(...args)
|
|
173
|
-
: writeDisabledResult({ name, service, cud }, policy,
|
|
173
|
+
: writeDisabledResult({ name, service, cud, scopes: config.requiredScopes }, policy,
|
|
174
174
|
// Concrete by here: default-account injection wraps this.
|
|
175
175
|
args[0]?.account);
|
|
176
176
|
// A2: the ONE default-account injection site — outside the CUD gate and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { getAccountSet, invalidateAccountSet } from '../accounts.js';
|
|
3
|
-
import { mutateConfigFile } from '../config-file.js';
|
|
3
|
+
import { ALIAS_RE, mutateConfigFile } from '../config-file.js';
|
|
4
4
|
import { writeToken } from '../token-store.js';
|
|
5
5
|
import { resolveScopesForAccount } from '../auth.js';
|
|
6
6
|
import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from '../scope-catalog.js';
|
|
@@ -9,13 +9,6 @@ import { coerceBoolean } from './_coerce.js';
|
|
|
9
9
|
import { safeMessage, stringifyEnvelope } from './_errors.js';
|
|
10
10
|
import { buildConsentClient, openLoopbackConsent, hasClientCredentials, TESTING_MODE_WARNING, } from '../oauth-consent.js';
|
|
11
11
|
import { detectClients, buildServerEntry, renderInstruction, applyFileEntry, resolveMode, DEFAULT_SERVER_NAME, } from '../client-config.js';
|
|
12
|
-
// B7: the elicitation-driven account_add / account_reauth wizard. It rebuilds
|
|
13
|
-
// interactive account management on the mutable config.json registry so a
|
|
14
|
-
// running server can add an account without the deployer editing env + running
|
|
15
|
-
// a CLI. Google consent reuses the loopback flow (leg C); the HTTP-transport
|
|
16
|
-
// consent path (${BASE}/authorize) is owned by the OAuth AS and lands with that
|
|
17
|
-
// cluster — account_add over http is gated behind it.
|
|
18
|
-
const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
|
|
19
12
|
// The common optional bundles get their own checkbox (MCP elicitation has no
|
|
20
13
|
// multi-select, so a boolean per bundle IS the checklist); the long tail stays
|
|
21
14
|
// a comma-separated field for power users, and `allBundles` grants every one.
|
|
@@ -214,7 +207,7 @@ async function runConsent(server, alias) {
|
|
|
214
207
|
function s4Text(alias, missing) {
|
|
215
208
|
const outcome = missing.length === 0
|
|
216
209
|
? `✔ "${alias}" authenticated; all requested scopes granted. It is now usable without a restart.`
|
|
217
|
-
: `⚠ "${alias}" authenticated, but ${missing.length} requested scope(s) were NOT granted (E_SCOPE_NOT_GRANTED)
|
|
210
|
+
: `⚠ "${alias}" authenticated, but ${missing.length} requested scope(s) were NOT granted (E_SCOPE_NOT_GRANTED). You may have unchecked some on the consent screen. Re-run account_reauth to grant them. The account is usable for the granted scopes.`;
|
|
218
211
|
return `${outcome}\n${TESTING_MODE_WARNING}`;
|
|
219
212
|
}
|
|
220
213
|
const REQUIRES_INTERACTION = { 'anthropic/requiresUserInteraction': true };
|
|
@@ -226,7 +219,7 @@ export function registerAccountWizardTools(registry, server) {
|
|
|
226
219
|
registerMeta('account_add', {
|
|
227
220
|
_meta: REQUIRES_INTERACTION,
|
|
228
221
|
annotations: { openWorldHint: true },
|
|
229
|
-
description: 'Add a new Google account: pass alias + email directly (plus optional bundles/allBundles/admin), or pass nothing for an interactive form where the client supports elicitation. Writes the registry and runs Google consent in the browser
|
|
222
|
+
description: 'Add a new Google account: pass alias + email directly (plus optional bundles/allBundles/admin), or pass nothing for an interactive form where the client supports elicitation. Writes the registry and runs Google consent in the browser. No file editing or restart needed. Requires GOOGLE_CLIENT_ID/SECRET (run the `setup` prompt first if missing).',
|
|
230
223
|
inputSchema: {
|
|
231
224
|
alias: z.string().optional().describe('Account alias (letters, digits, _ or -). Pass with email to add directly, skipping the form.'),
|
|
232
225
|
account: z.string().optional().describe('Alias for the new account (same as `alias`; every other tool spells it `account`)'),
|
|
@@ -358,16 +351,16 @@ export function registerAccountWizardTools(registry, server) {
|
|
|
358
351
|
for (const client of targets) {
|
|
359
352
|
const instr = renderInstruction(client, name, entry);
|
|
360
353
|
if (client.managed === 'cli') {
|
|
361
|
-
blocks.push(`${client.label}
|
|
354
|
+
blocks.push(`${client.label}: run\n ${instr.text}`);
|
|
362
355
|
}
|
|
363
356
|
else if (a.write) {
|
|
364
357
|
const res = applyFileEntry(client, name, entry);
|
|
365
358
|
blocks.push(res.ok
|
|
366
|
-
? `${client.label}
|
|
367
|
-
: `${client.label}
|
|
359
|
+
? `${client.label}: ${res.action} "${name}" in ${res.path}${res.backup ? ` (backup ${res.backup})` : ''}`
|
|
360
|
+
: `${client.label}: ${res.message}\n${res.snippet ?? ''}`);
|
|
368
361
|
}
|
|
369
362
|
else {
|
|
370
|
-
blocks.push(`${client.label}
|
|
363
|
+
blocks.push(`${client.label}: add to ${instr.path}\n${instr.text}`);
|
|
371
364
|
}
|
|
372
365
|
}
|
|
373
366
|
if (targets.length === 0) {
|
package/dist/tools/drive.js
CHANGED
|
@@ -255,6 +255,7 @@ export function registerDriveTools(server) {
|
|
|
255
255
|
webViewLink,
|
|
256
256
|
}, null, 2),
|
|
257
257
|
}],
|
|
258
|
+
isError: true,
|
|
258
259
|
};
|
|
259
260
|
}
|
|
260
261
|
const fileSize = parseInt(size ?? '0', 10);
|
|
@@ -274,6 +275,7 @@ export function registerDriveTools(server) {
|
|
|
274
275
|
webViewLink,
|
|
275
276
|
}, null, 2),
|
|
276
277
|
}],
|
|
278
|
+
isError: true,
|
|
277
279
|
};
|
|
278
280
|
}
|
|
279
281
|
if (mimeType && isTextualMime(mimeType)) {
|
|
@@ -295,6 +297,7 @@ export function registerDriveTools(server) {
|
|
|
295
297
|
webViewLink,
|
|
296
298
|
}, null, 2),
|
|
297
299
|
}],
|
|
300
|
+
isError: true,
|
|
298
301
|
};
|
|
299
302
|
}
|
|
300
303
|
catch (error) {
|
package/dist/tools/google-api.js
CHANGED
|
@@ -191,7 +191,10 @@ export function registerEscapeTools(registry, policy, deps = {}) {
|
|
|
191
191
|
const cud = cudFromMethod(method);
|
|
192
192
|
const policyService = serviceForAlias(apiKey);
|
|
193
193
|
const lastSegment = method.id.split('.').pop() ?? method.id;
|
|
194
|
-
|
|
194
|
+
// The escape hatch self-gates, so it needs the same scope signal the
|
|
195
|
+
// registry wrapper gets; without it a privileged method would slip past
|
|
196
|
+
// safe-writes here while its generated twin is refused.
|
|
197
|
+
const toolRef = { name: `${policyService}_${lastSegment}`, service: policyService, cud, scopes: method.scopes };
|
|
195
198
|
if (cud !== 'read' && !isAllowed(toolRef, policy)) {
|
|
196
199
|
return writeDisabledResult(toolRef, policy, account);
|
|
197
200
|
}
|
package/dist/usage-metrics.d.ts
CHANGED
|
@@ -86,6 +86,11 @@ export interface InitOptions {
|
|
|
86
86
|
now?: () => number;
|
|
87
87
|
monotonic?: () => number;
|
|
88
88
|
log?: (line: string) => void;
|
|
89
|
+
/** Default true. The retry and bigram chains correlate CONSECUTIVE calls,
|
|
90
|
+
* which is only meaningful when one caller owns the whole process; a host
|
|
91
|
+
* serving several callers sets false so their sequences never cross-link.
|
|
92
|
+
* DayAgg shape is unchanged (retries/bigrams stay empty maps). */
|
|
93
|
+
sequencingEnabled?: boolean;
|
|
89
94
|
}
|
|
90
95
|
/** Deep-add b into a for the DayAgg shape (numbers add, maps union). */
|
|
91
96
|
export declare function mergeDay(a: DayAgg, b: DayAgg): DayAgg;
|
|
@@ -108,6 +113,7 @@ export declare class Metrics {
|
|
|
108
113
|
private dirty;
|
|
109
114
|
private failures;
|
|
110
115
|
private disabled;
|
|
116
|
+
private readonly sequencing;
|
|
111
117
|
constructor(opts: InitOptions);
|
|
112
118
|
private day;
|
|
113
119
|
private initStorage;
|
package/dist/usage-metrics.js
CHANGED
|
@@ -221,7 +221,9 @@ export class Metrics {
|
|
|
221
221
|
dirty = false;
|
|
222
222
|
failures = 0;
|
|
223
223
|
disabled = false;
|
|
224
|
+
sequencing;
|
|
224
225
|
constructor(opts) {
|
|
226
|
+
this.sequencing = opts.sequencingEnabled !== false;
|
|
225
227
|
this.dir = opts.dir ?? path.join(stateDir(opts.env), METRICS_DIR_NAME);
|
|
226
228
|
this.aggDir = path.join(this.dir, 'agg');
|
|
227
229
|
this.eventsFile = path.join(this.dir, 'events.jsonl');
|
|
@@ -357,22 +359,26 @@ export class Metrics {
|
|
|
357
359
|
f.calls += 1;
|
|
358
360
|
addInto(f, fan);
|
|
359
361
|
}
|
|
360
|
-
// retry self-correction chain (spec section 1): in-memory only.
|
|
362
|
+
// retry self-correction chain (spec section 1): in-memory only. Both
|
|
363
|
+
// chains link consecutive calls, so they only run when one caller owns
|
|
364
|
+
// the process (sequencingEnabled).
|
|
361
365
|
const nowM = this.mono();
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
366
|
+
if (this.sequencing) {
|
|
367
|
+
const prev = this.lastError.get(entry.name);
|
|
368
|
+
if (prev && nowM - prev.at <= RETRY_WINDOW_MS) {
|
|
369
|
+
const rr = (this.deltas.retries[prev.slug] ??= { hintedOk: 0, hintedFail: 0, unhintedOk: 0, unhintedFail: 0 });
|
|
370
|
+
const key = `${prev.hinted ? 'hinted' : 'unhinted'}${ok ? 'Ok' : 'Fail'}`;
|
|
371
|
+
rr[key] += 1;
|
|
372
|
+
this.lastError.delete(entry.name);
|
|
373
|
+
}
|
|
374
|
+
if (!ok && slug)
|
|
375
|
+
this.lastError.set(entry.name, { slug, hinted: hinted === true, at: nowM });
|
|
376
|
+
// bigrams: ordered pairs within the process, 5-minute gap breaks the chain.
|
|
377
|
+
if (this.lastTool && nowM - this.lastTool.at <= BIGRAM_GAP_MS) {
|
|
378
|
+
addInto(this.deltas.bigrams, `${this.lastTool.name}>${entry.name}`);
|
|
379
|
+
}
|
|
380
|
+
this.lastTool = { name: entry.name, at: nowM };
|
|
374
381
|
}
|
|
375
|
-
this.lastTool = { name: entry.name, at: nowM };
|
|
376
382
|
const event = {
|
|
377
383
|
ts: new Date(this.now()).toISOString().slice(0, 16) + ':00Z',
|
|
378
384
|
boot: this.bootId,
|
package/dist/write-control.d.ts
CHANGED
|
@@ -22,6 +22,10 @@ interface ToolRef {
|
|
|
22
22
|
name: string;
|
|
23
23
|
service: string;
|
|
24
24
|
cud: Cud;
|
|
25
|
+
/** The method's required OAuth scopes, when the caller knows them. Absent
|
|
26
|
+
* means "unknown", which is treated as not privileged: a missing scope list
|
|
27
|
+
* must not silently widen the gate OR silently break an ordinary write. */
|
|
28
|
+
scopes?: readonly string[];
|
|
25
29
|
}
|
|
26
30
|
/** The irreversible set (frozen, cc-write-control): real sends leave the
|
|
27
31
|
* user's (or org's) identity unrecallably; permanent deletes bypass Trash.
|
|
@@ -35,12 +39,21 @@ export declare const IRREVERSIBLE_TOOLS: Set<string>;
|
|
|
35
39
|
export declare function resolvePolicy(env?: NodeJS.ProcessEnv, opts?: {
|
|
36
40
|
transport?: Transport;
|
|
37
41
|
}): Policy;
|
|
42
|
+
export type PrivilegeKind = 'push_registration' | 'privileged_scope';
|
|
43
|
+
/** Why this write counts as privileged, or undefined if it does not.
|
|
44
|
+
* Derived, not a hand-kept name list: the tool surface is regenerated from
|
|
45
|
+
* Discovery, and a list of names would drift out of date silently. */
|
|
46
|
+
export declare function privilegeOf(tool: ToolRef): PrivilegeKind | undefined;
|
|
38
47
|
export declare function isAllowed(tool: ToolRef, policy: Policy): boolean;
|
|
39
48
|
export type DenyReason = {
|
|
40
49
|
rule: 'read_only';
|
|
41
50
|
} | {
|
|
42
51
|
rule: 'deny_glob';
|
|
43
52
|
pattern: string;
|
|
53
|
+
} | {
|
|
54
|
+
rule: 'privileged';
|
|
55
|
+
profile: Profile;
|
|
56
|
+
kind: PrivilegeKind;
|
|
44
57
|
} | {
|
|
45
58
|
rule: 'profile';
|
|
46
59
|
profile: Profile;
|
package/dist/write-control.js
CHANGED
|
@@ -63,11 +63,33 @@ function firstMatch(tool, globs) {
|
|
|
63
63
|
function matchesAny(tool, globs) {
|
|
64
64
|
return firstMatch(tool, globs) !== undefined;
|
|
65
65
|
}
|
|
66
|
-
|
|
66
|
+
/** Scopes that authorize acting on the whole organization, on legal holds, on
|
|
67
|
+
* billing, or on deployed code. A write behind one of these is not a "safe"
|
|
68
|
+
* write in any useful sense of the word, whatever its HTTP verb. */
|
|
69
|
+
const PRIVILEGED_SCOPE_RE = /\/auth\/(admin\.directory|admin\.datatransfer|cloud-identity|apps\.licensing|apps\.order|apps\.groups\.settings|apps\.groups\.migration|ediscovery|script\.projects|script\.deployments)/;
|
|
70
|
+
/** Registering a push channel hands this account's activity to an external URL.
|
|
71
|
+
* It is shaped like a create and behaves like an export, so the verb hides it. */
|
|
72
|
+
const PUSH_REGISTRATION_RE = /(_watch|_subscriptions_create|_subscriptions_reactivate)$/;
|
|
73
|
+
/** Why this write counts as privileged, or undefined if it does not.
|
|
74
|
+
* Derived, not a hand-kept name list: the tool surface is regenerated from
|
|
75
|
+
* Discovery, and a list of names would drift out of date silently. */
|
|
76
|
+
export function privilegeOf(tool) {
|
|
77
|
+
if (PUSH_REGISTRATION_RE.test(tool.name))
|
|
78
|
+
return 'push_registration';
|
|
79
|
+
// `some`, not `every`: the scope list is an any-of, so a tool that CAN be
|
|
80
|
+
// authorized by a privileged scope is treated as privileged.
|
|
81
|
+
if (tool.scopes?.some((sc) => PRIVILEGED_SCOPE_RE.test(sc)))
|
|
82
|
+
return 'privileged_scope';
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
function profileAllows(profile, tool) {
|
|
67
86
|
if (profile === 'full-writes')
|
|
68
87
|
return true;
|
|
69
|
-
if (profile === 'safe-writes')
|
|
70
|
-
|
|
88
|
+
if (profile === 'safe-writes') {
|
|
89
|
+
if (privilegeOf(tool) !== undefined)
|
|
90
|
+
return false;
|
|
91
|
+
return tool.cud === 'create' || tool.cud === 'update';
|
|
92
|
+
}
|
|
71
93
|
return false;
|
|
72
94
|
}
|
|
73
95
|
export function isAllowed(tool, policy) {
|
|
@@ -79,11 +101,11 @@ export function isAllowed(tool, policy) {
|
|
|
79
101
|
return false;
|
|
80
102
|
if (matchesAny(tool, policy.allow))
|
|
81
103
|
return true;
|
|
82
|
-
return profileAllows(policy.profile, tool
|
|
104
|
+
return profileAllows(policy.profile, tool);
|
|
83
105
|
}
|
|
84
|
-
/** Which profiles would permit this
|
|
85
|
-
function profilesAllowing(
|
|
86
|
-
return PROFILES.filter((p) => profileAllows(p,
|
|
106
|
+
/** Which profiles would permit this tool at all. */
|
|
107
|
+
function profilesAllowing(tool) {
|
|
108
|
+
return PROFILES.filter((p) => profileAllows(p, tool));
|
|
87
109
|
}
|
|
88
110
|
/** WHICH rule refused the call. `isAllowed` has a precedence order, so exactly
|
|
89
111
|
* one rule decides; naming the others sends the caller to a setting that
|
|
@@ -98,8 +120,14 @@ export function denyReason(tool, policy) {
|
|
|
98
120
|
return { rule: 'deny_glob', pattern };
|
|
99
121
|
if (matchesAny(tool, policy.allow))
|
|
100
122
|
return undefined;
|
|
101
|
-
if (profileAllows(policy.profile, tool
|
|
123
|
+
if (profileAllows(policy.profile, tool))
|
|
102
124
|
return undefined;
|
|
125
|
+
const kind = privilegeOf(tool);
|
|
126
|
+
// Distinguished from a plain profile refusal: "delete is not a safe write"
|
|
127
|
+
// and "this is an org-wide operation" need different answers.
|
|
128
|
+
if (kind !== undefined && policy.profile === 'safe-writes') {
|
|
129
|
+
return { rule: 'privileged', profile: policy.profile, kind };
|
|
130
|
+
}
|
|
103
131
|
return { rule: 'profile', profile: policy.profile };
|
|
104
132
|
}
|
|
105
133
|
function denyHint(tool, reason) {
|
|
@@ -109,7 +137,13 @@ function denyHint(tool, reason) {
|
|
|
109
137
|
if (reason.rule === 'deny_glob') {
|
|
110
138
|
return `GOOGLE_WRITE_DENY pattern "${reason.pattern}" matches this tool, and deny is checked before the allow-list and the profile, so neither can override it. Remove or narrow that pattern.`;
|
|
111
139
|
}
|
|
112
|
-
|
|
140
|
+
if (reason.rule === 'privileged') {
|
|
141
|
+
const what = reason.kind === 'push_registration'
|
|
142
|
+
? 'registers a push channel, which sends this account\'s activity to an external URL'
|
|
143
|
+
: 'needs a scope that acts on the whole organization, on legal holds, on billing, or on deployed code';
|
|
144
|
+
return `"${tool.name}" ${what}, so "${reason.profile}" refuses it whatever its verb. Use GOOGLE_PROFILE=full-writes, or allow just this one with GOOGLE_WRITE_ALLOW="${tool.service}:${opOf(tool)}".`;
|
|
145
|
+
}
|
|
146
|
+
const usable = profilesAllowing(tool).filter((p) => p !== reason.profile);
|
|
113
147
|
const profiles = usable.length > 0
|
|
114
148
|
? `Use GOOGLE_PROFILE=${usable.join(' or ')}`
|
|
115
149
|
: 'No profile permits this operation';
|
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.37",
|
|
4
4
|
"mcpName": "io.github.bakissation/mcp-google-multi",
|
|
5
5
|
"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.",
|
|
6
6
|
"type": "module",
|