mcp-google-multi 6.0.0-alpha.42 → 6.0.0-alpha.44
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/auth.d.ts +3 -2
- package/dist/auth.js +6 -8
- package/dist/doctor.d.ts +6 -2
- package/dist/doctor.js +17 -7
- package/dist/index.js +3 -3
- package/dist/mcp-token.d.ts +5 -0
- package/dist/mcp-token.js +19 -0
- package/dist/registry-transfer.d.ts +5 -0
- package/dist/registry-transfer.js +25 -1
- package/dist/services.d.ts +6 -2
- package/dist/services.js +6 -6
- package/dist/tenant-purge.d.ts +26 -0
- package/dist/tenant-purge.js +67 -0
- package/package.json +1 -1
package/dist/auth.d.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
+
import type { AccountSet } from './accounts.js';
|
|
1
2
|
import { ADMIN_SCOPES } from './scope-catalog.js';
|
|
2
3
|
export declare const BASE_SCOPES: string[];
|
|
3
4
|
export declare const OPTIONAL_SCOPE_BUNDLES: Record<string, string[]>;
|
|
4
5
|
export { ADMIN_SCOPES };
|
|
5
6
|
/** Union of every account's resolved bundles: a service registers if ANY
|
|
6
7
|
* account can authorize it; per-account authz happens at call time (BR2). */
|
|
7
|
-
export declare function getOptionalBundles(): string[];
|
|
8
|
+
export declare function getOptionalBundles(set?: AccountSet): string[];
|
|
8
9
|
/** Aliases granted ADMIN_SCOPES: per-account admin flag (env
|
|
9
10
|
* GOOGLE_ADMIN_ACCOUNTS overrides config.json at resolve) OR the account's
|
|
10
11
|
* scope profile carrying admin (boolean or "admin" bundle) — equivalent forms. */
|
|
11
|
-
export declare function getAdminAccounts(): string[];
|
|
12
|
+
export declare function getAdminAccounts(set?: AccountSet): string[];
|
|
12
13
|
/** Scopes are fixed at consent time: changing an account's profile (or the
|
|
13
14
|
* legacy env) changes its consent set and requires re-running auth. Evaluated
|
|
14
15
|
* per account: `work` can carry admin + gmail_settings while `personal` is
|
package/dist/auth.js
CHANGED
|
@@ -55,11 +55,10 @@ function legacyGlobalProfile() {
|
|
|
55
55
|
}
|
|
56
56
|
return { bundles };
|
|
57
57
|
}
|
|
58
|
-
function profileForAccount(alias) {
|
|
58
|
+
function profileForAccount(alias, set = getAccountSet()) {
|
|
59
59
|
const legacy = legacyGlobalProfile();
|
|
60
60
|
if (legacy)
|
|
61
61
|
return legacy;
|
|
62
|
-
const set = getAccountSet();
|
|
63
62
|
const name = set.configs[alias]?.scopeProfile ?? 'base';
|
|
64
63
|
// hasOwn: a profile named like an Object.prototype member must never
|
|
65
64
|
// resolve to the inherited function.
|
|
@@ -67,14 +66,13 @@ function profileForAccount(alias) {
|
|
|
67
66
|
}
|
|
68
67
|
/** Union of every account's resolved bundles: a service registers if ANY
|
|
69
68
|
* account can authorize it; per-account authz happens at call time (BR2). */
|
|
70
|
-
export function getOptionalBundles() {
|
|
69
|
+
export function getOptionalBundles(set = getAccountSet()) {
|
|
71
70
|
const legacy = legacyGlobalProfile();
|
|
72
71
|
if (legacy)
|
|
73
72
|
return legacy.bundles.filter(b => b !== 'admin');
|
|
74
73
|
const union = new Set();
|
|
75
|
-
const set = getAccountSet();
|
|
76
74
|
for (const alias of set.aliases) {
|
|
77
|
-
for (const b of profileForAccount(alias).bundles) {
|
|
75
|
+
for (const b of profileForAccount(alias, set).bundles) {
|
|
78
76
|
if (b !== 'admin')
|
|
79
77
|
union.add(b);
|
|
80
78
|
}
|
|
@@ -84,12 +82,12 @@ export function getOptionalBundles() {
|
|
|
84
82
|
/** Aliases granted ADMIN_SCOPES: per-account admin flag (env
|
|
85
83
|
* GOOGLE_ADMIN_ACCOUNTS overrides config.json at resolve) OR the account's
|
|
86
84
|
* scope profile carrying admin (boolean or "admin" bundle) — equivalent forms. */
|
|
87
|
-
export function getAdminAccounts() {
|
|
88
|
-
const { aliases, configs } =
|
|
85
|
+
export function getAdminAccounts(set = getAccountSet()) {
|
|
86
|
+
const { aliases, configs } = set;
|
|
89
87
|
return aliases.filter((a) => {
|
|
90
88
|
if (configs[a].admin === true)
|
|
91
89
|
return true;
|
|
92
|
-
const p = profileForAccount(a);
|
|
90
|
+
const p = profileForAccount(a, set);
|
|
93
91
|
return p.admin === true || p.bundles.includes('admin');
|
|
94
92
|
});
|
|
95
93
|
}
|
package/dist/doctor.d.ts
CHANGED
|
@@ -58,7 +58,9 @@ export declare function apiEnableLink(api: string): string;
|
|
|
58
58
|
export declare function usageMetricsStatusLine(env: Record<string, string | undefined>): string;
|
|
59
59
|
/** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
|
|
60
60
|
export declare function overallVerdict(sections: DiagnosticSection[]): Verdict;
|
|
61
|
-
export declare function runDiagnostics(deps?: DiagnosticsDeps
|
|
61
|
+
export declare function runDiagnostics(deps?: DiagnosticsDeps, opts?: {
|
|
62
|
+
scope?: 'operator' | 'tenant';
|
|
63
|
+
}): Promise<DiagnosticsReport>;
|
|
62
64
|
/** doctor CLI (brew-doctor style): glyph-coded human report. */
|
|
63
65
|
export declare function renderDoctorText(report: DiagnosticsReport): string;
|
|
64
66
|
/** Redacted, paste-ready bug report (BR8): verdicts + provenance labels + token
|
|
@@ -73,7 +75,9 @@ export declare function exitCodeFor(report: DiagnosticsReport, strict: boolean):
|
|
|
73
75
|
* from already-registered services, and this runs after that), so it was
|
|
74
76
|
* advertised only once something else expanded the surface. The README sends
|
|
75
77
|
* people here when they are stuck, so it has to be findable. */
|
|
76
|
-
export declare function registerDiagnoseTool(registry: ToolRegistry
|
|
78
|
+
export declare function registerDiagnoseTool(registry: ToolRegistry, ctx?: {
|
|
79
|
+
subject: string;
|
|
80
|
+
}): void;
|
|
77
81
|
export interface ResetOptions {
|
|
78
82
|
account?: string;
|
|
79
83
|
regenerateKey: boolean;
|
package/dist/doctor.js
CHANGED
|
@@ -300,21 +300,28 @@ export function overallVerdict(sections) {
|
|
|
300
300
|
}
|
|
301
301
|
return worst;
|
|
302
302
|
}
|
|
303
|
-
export async function runDiagnostics(deps = DEFAULT_DEPS) {
|
|
303
|
+
export async function runDiagnostics(deps = DEFAULT_DEPS, opts = {}) {
|
|
304
|
+
// Tenant scope omits the OPERATOR sections: §3 (key provenance = box
|
|
305
|
+
// infrastructure) and §7 (its owner-gate lines print MCP_OWNER_EMAILS
|
|
306
|
+
// values — other people's addresses from a tenant's seat).
|
|
307
|
+
const tenantScope = opts.scope === 'tenant';
|
|
304
308
|
const sections = [];
|
|
305
309
|
sections.push(sectionRuntime(deps));
|
|
306
310
|
const set = deps.accountSet();
|
|
307
311
|
sections.push(sectionConfig(deps, set));
|
|
308
312
|
const aliases = set?.aliases ?? [];
|
|
309
|
-
|
|
313
|
+
if (!tenantScope)
|
|
314
|
+
sections.push(sectionKeys(deps, aliases));
|
|
310
315
|
if (aliases.length > 0) {
|
|
311
316
|
const [tokens, scopes] = sectionsTokensAndScopes(deps, aliases);
|
|
312
317
|
sections.push(tokens, scopes);
|
|
313
318
|
sections.push(await sectionApiEnablement(deps, aliases));
|
|
314
319
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
320
|
+
if (!tenantScope) {
|
|
321
|
+
const http = await sectionHttp(deps, aliases);
|
|
322
|
+
if (http)
|
|
323
|
+
sections.push(http);
|
|
324
|
+
}
|
|
318
325
|
return { verdict: overallVerdict(sections), sections };
|
|
319
326
|
}
|
|
320
327
|
// --- skins -----------------------------------------------------------------
|
|
@@ -364,14 +371,17 @@ export function exitCodeFor(report, strict) {
|
|
|
364
371
|
* from already-registered services, and this runs after that), so it was
|
|
365
372
|
* advertised only once something else expanded the surface. The README sends
|
|
366
373
|
* people here when they are stuck, so it has to be findable. */
|
|
367
|
-
export function registerDiagnoseTool(registry) {
|
|
374
|
+
export function registerDiagnoseTool(registry, ctx) {
|
|
375
|
+
// A non-owner context gets the tenant-scoped report; the free core's single
|
|
376
|
+
// 'owner' context keeps today's full operator report.
|
|
377
|
+
const scope = ctx && ctx.subject !== 'owner' ? 'tenant' : 'operator';
|
|
368
378
|
registry.registerMeta('diagnose', {
|
|
369
379
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
370
380
|
description: 'Health report for this server: runtime, config, keys, per-account token status, scope grants, and API enablement. Read-only; returns copy-pasteable fixes for anything wrong. Call this to diagnose auth/config failures.',
|
|
371
381
|
inputSchema: {},
|
|
372
382
|
}, async () => {
|
|
373
383
|
try {
|
|
374
|
-
const result = await runDiagnostics();
|
|
384
|
+
const result = await runDiagnostics(undefined, { scope });
|
|
375
385
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
376
386
|
}
|
|
377
387
|
catch (e) {
|
package/dist/index.js
CHANGED
|
@@ -58,7 +58,7 @@ function buildRegistry(server, ctx, mode, metrics = null) {
|
|
|
58
58
|
for (const svc of SERVICES) {
|
|
59
59
|
if (!toolsetEnabled(toolsets, svc.name))
|
|
60
60
|
continue;
|
|
61
|
-
if (svc.enabled && !svc.enabled()) {
|
|
61
|
+
if (svc.enabled && !svc.enabled(ctx.accounts)) {
|
|
62
62
|
if (toolsets !== 'all') {
|
|
63
63
|
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)`;
|
|
64
64
|
process.stderr.write(`GOOGLE_TOOLSETS: "${svc.name}" requested but not enabled — ${hint}\n`);
|
|
@@ -72,7 +72,7 @@ function buildRegistry(server, ctx, mode, metrics = null) {
|
|
|
72
72
|
continue;
|
|
73
73
|
const curated = SERVICES.find((s) => s.name === gen.name);
|
|
74
74
|
const gate = curated?.enabled ?? GENERATED_GATES[gen.name]?.enabled;
|
|
75
|
-
if (gate && !gate()) {
|
|
75
|
+
if (gate && !gate(ctx.accounts)) {
|
|
76
76
|
if (!curated && toolsets !== 'all') {
|
|
77
77
|
process.stderr.write(`GOOGLE_TOOLSETS: "${gen.name}" requested but not enabled — ${GENERATED_GATES[gen.name].hint}\n`);
|
|
78
78
|
}
|
|
@@ -89,7 +89,7 @@ function buildRegistry(server, ctx, mode, metrics = null) {
|
|
|
89
89
|
registerDiscoverTools(registry, policy);
|
|
90
90
|
registerEscapeTools(registry, policy);
|
|
91
91
|
registerAccountTools(registry);
|
|
92
|
-
registerDiagnoseTool(registry);
|
|
92
|
+
registerDiagnoseTool(registry, ctx);
|
|
93
93
|
registerAccountWizardTools(registry, server);
|
|
94
94
|
return registry;
|
|
95
95
|
}
|
package/dist/mcp-token.d.ts
CHANGED
|
@@ -89,4 +89,9 @@ export declare class RefreshStore {
|
|
|
89
89
|
token: string;
|
|
90
90
|
sub: string;
|
|
91
91
|
} | null;
|
|
92
|
+
/** Drop every ACTIVE record minted under `sub` (linear scan under the store
|
|
93
|
+
* lock). Spent entries carry no sub and stay: with their families gone, a
|
|
94
|
+
* reuse finds nothing to revoke — inert by construction. Returns the number
|
|
95
|
+
* of active tokens dropped. */
|
|
96
|
+
purgeTenant(sub: string): number;
|
|
92
97
|
}
|
package/dist/mcp-token.js
CHANGED
|
@@ -175,4 +175,23 @@ export class RefreshStore {
|
|
|
175
175
|
return { token: next, sub: rec.sub };
|
|
176
176
|
});
|
|
177
177
|
}
|
|
178
|
+
/** Drop every ACTIVE record minted under `sub` (linear scan under the store
|
|
179
|
+
* lock). Spent entries carry no sub and stay: with their families gone, a
|
|
180
|
+
* reuse finds nothing to revoke — inert by construction. Returns the number
|
|
181
|
+
* of active tokens dropped. */
|
|
182
|
+
purgeTenant(sub) {
|
|
183
|
+
return withFileLock(this.path, () => {
|
|
184
|
+
const data = this.load();
|
|
185
|
+
let dropped = 0;
|
|
186
|
+
for (const [t, r] of Object.entries(data.active)) {
|
|
187
|
+
if (r.sub === sub) {
|
|
188
|
+
delete data.active[t];
|
|
189
|
+
dropped += 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (dropped > 0)
|
|
193
|
+
this.save(data);
|
|
194
|
+
return dropped;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
178
197
|
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** D11 (refuse-only for v1): export/import operate on the FLAT single-owner
|
|
2
|
+
* store. A tenant-namespaced layout means the bundle would either miss every
|
|
3
|
+
* tenant's tokens or exfiltrate all of them in one file — refuse instead.
|
|
4
|
+
* Returns the offending dir, or null when the store is flat. */
|
|
5
|
+
export declare function refuseIfMultiTenant(dir?: string): string | null;
|
|
1
6
|
export interface TransferBundle {
|
|
2
7
|
manifest: {
|
|
3
8
|
v: 1;
|
|
@@ -9,8 +9,22 @@ 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 { ALIAS_RE, configFilePath, CONFIG_VERSION } from './config-file.js';
|
|
12
|
+
import { ALIAS_RE, configFilePath, CONFIG_VERSION, tenantsDir } from './config-file.js';
|
|
13
13
|
import { getTokenDir } from './accounts.js';
|
|
14
|
+
/** D11 (refuse-only for v1): export/import operate on the FLAT single-owner
|
|
15
|
+
* store. A tenant-namespaced layout means the bundle would either miss every
|
|
16
|
+
* tenant's tokens or exfiltrate all of them in one file — refuse instead.
|
|
17
|
+
* Returns the offending dir, or null when the store is flat. */
|
|
18
|
+
export function refuseIfMultiTenant(dir = tenantsDir()) {
|
|
19
|
+
try {
|
|
20
|
+
if (readdirSync(dir).length > 0)
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// absent or unreadable tenants/ = flat store
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
14
28
|
function resolveDeps(deps) {
|
|
15
29
|
return {
|
|
16
30
|
configPath: deps.configPath ?? configFilePath(),
|
|
@@ -152,6 +166,11 @@ async function resolvePassphrase(prompt) {
|
|
|
152
166
|
}
|
|
153
167
|
}
|
|
154
168
|
export async function runExportCli(argv) {
|
|
169
|
+
const mtDir = refuseIfMultiTenant();
|
|
170
|
+
if (mtDir) {
|
|
171
|
+
console.error(`E_MULTI_TENANT_EXPORT_REFUSED: tenant-namespaced token stores exist under ${mtDir}; account export only operates on a flat single-owner store.`);
|
|
172
|
+
return 2;
|
|
173
|
+
}
|
|
155
174
|
const out = argFlag(argv, '--out');
|
|
156
175
|
if (!out) {
|
|
157
176
|
console.error('Usage: mcp-google-multi account export --out <bundle.enc>');
|
|
@@ -169,6 +188,11 @@ export async function runExportCli(argv) {
|
|
|
169
188
|
return 0;
|
|
170
189
|
}
|
|
171
190
|
export async function runImportCli(argv) {
|
|
191
|
+
const mtDir = refuseIfMultiTenant();
|
|
192
|
+
if (mtDir) {
|
|
193
|
+
console.error(`E_MULTI_TENANT_EXPORT_REFUSED: tenant-namespaced token stores exist under ${mtDir}; account import only operates on a flat single-owner store.`);
|
|
194
|
+
return 2;
|
|
195
|
+
}
|
|
172
196
|
const file = argv[argv.indexOf('import') + 1];
|
|
173
197
|
if (!file || file.startsWith('--')) {
|
|
174
198
|
console.error('Usage: mcp-google-multi account import <bundle.enc> [--replace]');
|
package/dist/services.d.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
import type { AccountSet } from './accounts.js';
|
|
1
2
|
import type { ToolRegistry } from './registry.js';
|
|
2
3
|
export interface ServiceEntry {
|
|
3
4
|
name: string;
|
|
4
5
|
register: (registry: ToolRegistry) => void;
|
|
5
|
-
|
|
6
|
+
/** Absent = always on. Gates evaluate against the PASSED account set (the
|
|
7
|
+
* calling tenant's view); omitted set = the global registry, today's
|
|
8
|
+
* behavior. */
|
|
9
|
+
enabled?: (set?: AccountSet) => boolean;
|
|
6
10
|
}
|
|
7
11
|
export declare const SERVICES: ServiceEntry[];
|
|
8
12
|
export declare const GENERATED_GATES: Record<string, {
|
|
9
|
-
enabled: () => boolean;
|
|
13
|
+
enabled: (set?: AccountSet) => boolean;
|
|
10
14
|
hint: string;
|
|
11
15
|
}>;
|
|
12
16
|
/**
|
package/dist/services.js
CHANGED
|
@@ -25,16 +25,16 @@ export const SERVICES = [
|
|
|
25
25
|
{ name: 'searchconsole', register: registerSearchConsoleTools },
|
|
26
26
|
{ name: 'tasks', register: registerTasksTools },
|
|
27
27
|
{ name: 'meet', register: registerMeetTools },
|
|
28
|
-
{ name: 'slides', register: registerSlidesTools, enabled: () => new Set(getOptionalBundles()).has('slides') },
|
|
29
|
-
{ name: 'forms', register: registerFormsTools, enabled: () => new Set(getOptionalBundles()).has('forms') },
|
|
30
|
-
{ name: 'chat', register: registerChatTools, enabled: () => new Set(getOptionalBundles()).has('chat') },
|
|
31
|
-
{ name: 'analytics', register: registerAnalyticsTools, enabled: () => { const b = new Set(getOptionalBundles()); return b.has('analytics') || b.has('analytics_write'); } },
|
|
32
|
-
{ name: 'admin', register: registerAdminTools, enabled: () => getAdminAccounts().length > 0 },
|
|
28
|
+
{ name: 'slides', register: registerSlidesTools, enabled: (set) => new Set(getOptionalBundles(set)).has('slides') },
|
|
29
|
+
{ name: 'forms', register: registerFormsTools, enabled: (set) => new Set(getOptionalBundles(set)).has('forms') },
|
|
30
|
+
{ name: 'chat', register: registerChatTools, enabled: (set) => new Set(getOptionalBundles(set)).has('chat') },
|
|
31
|
+
{ name: 'analytics', register: registerAnalyticsTools, enabled: (set) => { const b = new Set(getOptionalBundles(set)); return b.has('analytics') || b.has('analytics_write'); } },
|
|
32
|
+
{ name: 'admin', register: registerAdminTools, enabled: (set) => getAdminAccounts(set).length > 0 },
|
|
33
33
|
];
|
|
34
34
|
// Generated-only services with opt-in scopes; admin/forms/chat/analytics reuse their curated gate in buildRegistry,
|
|
35
35
|
// and workspaceevents is deliberately absent — no dedicated scope (subscriptions use resource scopes).
|
|
36
36
|
const bundleGate = (name) => ({
|
|
37
|
-
enabled: () => new Set(getOptionalBundles()).has(name),
|
|
37
|
+
enabled: (set) => new Set(getOptionalBundles(set)).has(name),
|
|
38
38
|
hint: `add "${name}" to an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES)`,
|
|
39
39
|
});
|
|
40
40
|
export const GENERATED_GATES = {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { TokenData } from './types.js';
|
|
2
|
+
/** Best-effort Google-side revocation (RFC 7009 endpoint). Returns whether
|
|
3
|
+
* Google acknowledged; NEVER throws — a network failure must not block the
|
|
4
|
+
* local purge (the local copy is the thing we control). */
|
|
5
|
+
export declare function revokeGoogleToken(tokenData: {
|
|
6
|
+
refresh_token?: string;
|
|
7
|
+
access_token?: string;
|
|
8
|
+
}, fetchFn?: typeof fetch): Promise<boolean>;
|
|
9
|
+
export interface TenantTokenPurge {
|
|
10
|
+
/** Aliases whose .enc files were removed. */
|
|
11
|
+
removed: string[];
|
|
12
|
+
/** How many tokens Google acknowledged revoking (best-effort). */
|
|
13
|
+
revoked: number;
|
|
14
|
+
}
|
|
15
|
+
/** Remove every `.enc` under the tenant's token dir, optionally revoking each
|
|
16
|
+
* at Google first. Decrypt failures still purge the file — an unreadable
|
|
17
|
+
* token is exactly the thing to delete. Only THIS tenant's dir is touched. */
|
|
18
|
+
export declare function purgeTenantTokens(tenantId: string, opts?: {
|
|
19
|
+
env?: NodeJS.ProcessEnv;
|
|
20
|
+
revoke?: (t: TokenData) => Promise<boolean>;
|
|
21
|
+
resolveKey?: () => string;
|
|
22
|
+
}): Promise<TenantTokenPurge>;
|
|
23
|
+
/** Remove the whole tenants/<id> directory (config + tokens). The id is
|
|
24
|
+
* validated by the path helper, so a traversal-shaped id throws before any
|
|
25
|
+
* filesystem access. */
|
|
26
|
+
export declare function removeTenantDir(tenantId: string, env?: NodeJS.ProcessEnv): void;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// S1.21: tenant offboarding primitives. Core owns the reusable token-purge
|
|
2
|
+
// legs (generically useful, tenant-id-agnostic); the tenant_delete
|
|
3
|
+
// ORCHESTRATION (what to purge, in what order, from which admin surface)
|
|
4
|
+
// lives with the tenancy module that calls these.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { tenantConfigFilePath, tenantTokenDir } from './config-file.js';
|
|
8
|
+
import { createTokenStore } from './token-store.js';
|
|
9
|
+
/** Best-effort Google-side revocation (RFC 7009 endpoint). Returns whether
|
|
10
|
+
* Google acknowledged; NEVER throws — a network failure must not block the
|
|
11
|
+
* local purge (the local copy is the thing we control). */
|
|
12
|
+
export async function revokeGoogleToken(tokenData, fetchFn = fetch) {
|
|
13
|
+
const token = tokenData.refresh_token ?? tokenData.access_token;
|
|
14
|
+
if (!token)
|
|
15
|
+
return false;
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetchFn('https://oauth2.googleapis.com/revoke', {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
20
|
+
body: new URLSearchParams({ token }).toString(),
|
|
21
|
+
});
|
|
22
|
+
return res.ok;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Remove every `.enc` under the tenant's token dir, optionally revoking each
|
|
29
|
+
* at Google first. Decrypt failures still purge the file — an unreadable
|
|
30
|
+
* token is exactly the thing to delete. Only THIS tenant's dir is touched. */
|
|
31
|
+
export async function purgeTenantTokens(tenantId, opts = {}) {
|
|
32
|
+
const dir = tenantTokenDir(tenantId, opts.env);
|
|
33
|
+
const store = createTokenStore(dir, { tenantId, resolveKey: opts.resolveKey });
|
|
34
|
+
const removed = [];
|
|
35
|
+
let revoked = 0;
|
|
36
|
+
let names;
|
|
37
|
+
try {
|
|
38
|
+
names = fs.readdirSync(dir);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return { removed, revoked }; // no token dir = nothing to purge
|
|
42
|
+
}
|
|
43
|
+
for (const name of names) {
|
|
44
|
+
if (!name.endsWith('.enc'))
|
|
45
|
+
continue;
|
|
46
|
+
const alias = name.slice(0, -'.enc'.length);
|
|
47
|
+
if (opts.revoke) {
|
|
48
|
+
try {
|
|
49
|
+
const token = store.readToken(alias);
|
|
50
|
+
if (token && (await opts.revoke(token)))
|
|
51
|
+
revoked += 1;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// undecryptable or invalid-alias filename: still remove the file
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
fs.rmSync(path.join(dir, name), { force: true });
|
|
58
|
+
removed.push(alias);
|
|
59
|
+
}
|
|
60
|
+
return { removed, revoked };
|
|
61
|
+
}
|
|
62
|
+
/** Remove the whole tenants/<id> directory (config + tokens). The id is
|
|
63
|
+
* validated by the path helper, so a traversal-shaped id throws before any
|
|
64
|
+
* filesystem access. */
|
|
65
|
+
export function removeTenantDir(tenantId, env) {
|
|
66
|
+
fs.rmSync(path.dirname(tenantConfigFilePath(tenantId, env)), { recursive: true, force: true });
|
|
67
|
+
}
|
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.44",
|
|
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",
|