mcp-google-multi 6.0.0-alpha.43 → 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/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): Promise<DiagnosticsReport>;
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): void;
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
- sections.push(sectionKeys(deps, aliases));
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
- const http = await sectionHttp(deps, aliases);
316
- if (http)
317
- sections.push(http);
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
@@ -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
  }
@@ -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
  }
@@ -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.43",
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",