mcp-google-multi 6.0.0-alpha.43 → 6.0.0-alpha.45
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 +6 -2
- package/dist/doctor.js +17 -7
- package/dist/index.js +8 -1
- package/dist/mcp-token.d.ts +5 -0
- package/dist/mcp-token.js +19 -0
- package/dist/tenant-purge.d.ts +26 -0
- package/dist/tenant-purge.js +67 -0
- package/dist/tools/account-wizard.d.ts +9 -0
- package/dist/tools/account-wizard.js +42 -0
- package/package.json +1 -1
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
|
@@ -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
|
}
|
|
@@ -381,6 +381,13 @@ async function main() {
|
|
|
381
381
|
// re-auth link into the AS's alias_reauth flow instead of a stdio CLI hint.
|
|
382
382
|
const { setHttpReauthBase } = await import('./reauth-hint.js');
|
|
383
383
|
setHttpReauthBase(httpCfg.publicUrl);
|
|
384
|
+
// Wizard consent over HTTP: hand out the clientless AS link instead of
|
|
385
|
+
// binding a loopback listener (single-owner flow; a tenancy host installs
|
|
386
|
+
// its own signed-mint context here).
|
|
387
|
+
const { setWizardHttpConsent } = await import('./tools/account-wizard.js');
|
|
388
|
+
setWizardHttpConsent({
|
|
389
|
+
mintConsentUrl: (alias) => `${httpCfg.publicUrl}/authorize?flow=alias_reauth&alias=${encodeURIComponent(alias)}`,
|
|
390
|
+
});
|
|
384
391
|
let httpTap;
|
|
385
392
|
if (httpMetrics) {
|
|
386
393
|
const { tapUsageMetrics } = await import('./metrics-tap.js');
|
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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -53,4 +53,13 @@ export declare function argsToAddForm(a: {
|
|
|
53
53
|
/** Scopes requested by the profile but NOT granted at consent (granular
|
|
54
54
|
* consent / unchecked bundles). Pure. */
|
|
55
55
|
export declare function scopeGrantDiff(requested: string[], grantedScope: string | undefined): string[];
|
|
56
|
+
/** HTTP-transport consent context (S1.20): set by the HTTP bootstrap. When
|
|
57
|
+
* present, runConsent NEVER binds a server-side loopback listener — the
|
|
58
|
+
* user's browser cannot reach the daemon's loopback — and instead hands out
|
|
59
|
+
* a clientless AS consent URL. The injector decides the flow (single-owner =
|
|
60
|
+
* the legacy alias_reauth link; a tenancy host mints a signed alias_add URL). */
|
|
61
|
+
export interface WizardHttpConsent {
|
|
62
|
+
mintConsentUrl: (alias: string) => Promise<string> | string;
|
|
63
|
+
}
|
|
64
|
+
export declare function setWizardHttpConsent(ctx: WizardHttpConsent | null): void;
|
|
56
65
|
export declare function registerAccountWizardTools(registry: ToolRegistry, server: McpServer): void;
|
|
@@ -136,6 +136,15 @@ function errorResult(slug, message, hint, account) {
|
|
|
136
136
|
isError: true,
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
|
+
let httpConsent = null;
|
|
140
|
+
export function setWizardHttpConsent(ctx) {
|
|
141
|
+
httpConsent = ctx;
|
|
142
|
+
}
|
|
143
|
+
function pendingText(alias, url) {
|
|
144
|
+
return (`Account "${alias}" is ready to authorize. Open this link in a browser (it belongs to whoever owns the Google account):\n${url}\n` +
|
|
145
|
+
`On completion the token is stored server-side and the account is usable immediately — no restart. ` +
|
|
146
|
+
`If the link expires, run account_reauth for a fresh one.\n${TESTING_MODE_WARNING}`);
|
|
147
|
+
}
|
|
139
148
|
/** Run Google consent for `alias` and persist the token. Opens the browser via
|
|
140
149
|
* URL-mode elicitation when the client supports it, else server-side + prints
|
|
141
150
|
* the URL. Returns the granted-scope diff for the S4 report. */
|
|
@@ -150,6 +159,29 @@ async function runConsent(server, alias) {
|
|
|
150
159
|
hint: 'Accounts sourced from GOOGLE_ACCOUNTS are not editable here. Run `mcp-google-multi migrate-config` to move them into config.json.',
|
|
151
160
|
};
|
|
152
161
|
}
|
|
162
|
+
// HTTP transport: consent completes OUT OF BAND at the AS /callback. Never
|
|
163
|
+
// bind a loopback listener here; try URL-mode elicitation as a convenience
|
|
164
|
+
// but surface the URL UP FRONT either way (not only on failure).
|
|
165
|
+
const http = httpConsent;
|
|
166
|
+
if (http) {
|
|
167
|
+
let url;
|
|
168
|
+
try {
|
|
169
|
+
url = await http.mintConsentUrl(alias);
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
return { ok: false, slug: 'internal', message: safeMessage(e) };
|
|
173
|
+
}
|
|
174
|
+
const caps = server.server.getClientCapabilities?.();
|
|
175
|
+
if (caps?.elicitation?.url !== undefined) {
|
|
176
|
+
try {
|
|
177
|
+
await server.server.elicitInput(urlElicitationParams(alias, url, randomBytes(16).toString('hex')));
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// the URL in the pending text is the recovery
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { ok: 'pending', url };
|
|
184
|
+
}
|
|
153
185
|
// Bind the ephemeral loopback listener BEFORE building the auth URL: the
|
|
154
186
|
// redirect URI needs the assigned port, and listening first means the
|
|
155
187
|
// callback can't race the browser.
|
|
@@ -267,6 +299,8 @@ export function registerAccountWizardTools(registry, server) {
|
|
|
267
299
|
invalidateAccountSet();
|
|
268
300
|
// S3 + S4: consent + validate.
|
|
269
301
|
const consent = await runConsent(server, validated.alias);
|
|
302
|
+
if (consent.ok === 'pending')
|
|
303
|
+
return textResult(pendingText(validated.alias, consent.url));
|
|
270
304
|
if (!consent.ok)
|
|
271
305
|
return errorResult(consent.slug, consent.message, consent.hint, validated.alias);
|
|
272
306
|
// B15: offer to register the server with another MCP client.
|
|
@@ -303,6 +337,8 @@ export function registerAccountWizardTools(registry, server) {
|
|
|
303
337
|
return errorResult('validation_error', `Unknown account "${alias}".`, `Pass one of the configured aliases: ${getAccountSet().aliases.join(', ')}. Use account_add to create a new one.`, alias);
|
|
304
338
|
}
|
|
305
339
|
const consent = await runConsent(server, alias);
|
|
340
|
+
if (consent.ok === 'pending')
|
|
341
|
+
return textResult(pendingText(alias, consent.url));
|
|
306
342
|
if (!consent.ok)
|
|
307
343
|
return errorResult(consent.slug, consent.message, consent.hint, alias);
|
|
308
344
|
return textResult(s4Text(alias, consent.missing));
|
|
@@ -353,6 +389,12 @@ export function registerAccountWizardTools(registry, server) {
|
|
|
353
389
|
if (client.managed === 'cli') {
|
|
354
390
|
blocks.push(`${client.label}: run\n ${instr.text}`);
|
|
355
391
|
}
|
|
392
|
+
else if (a.write && mode === 'http') {
|
|
393
|
+
// Over HTTP this tool runs on the DAEMON, whose homedir is not the
|
|
394
|
+
// caller's machine: write:true would edit the operator's own client
|
|
395
|
+
// configs (a cross-tenant hazard under tenancy). Snippet only.
|
|
396
|
+
blocks.push(`${client.label}: write:true is ignored over HTTP — this tool runs on the server, not your machine. Add to ${instr.path}\n${instr.text}`);
|
|
397
|
+
}
|
|
356
398
|
else if (a.write) {
|
|
357
399
|
const res = applyFileEntry(client, name, entry);
|
|
358
400
|
blocks.push(res.ok
|
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.45",
|
|
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",
|