mcp-google-multi 5.4.1-alpha.2 → 6.0.0-alpha.2
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/README.md +21 -16
- package/dist/accounts.d.ts +44 -5
- package/dist/accounts.js +267 -25
- package/dist/auth.d.ts +11 -4
- package/dist/auth.js +85 -78
- package/dist/client-config.d.ts +90 -0
- package/dist/client-config.js +282 -0
- package/dist/client.js +10 -4
- package/dist/config-file.d.ts +38 -0
- package/dist/config-file.js +117 -0
- package/dist/discover.js +57 -3
- package/dist/doctor.d.ts +86 -0
- package/dist/doctor.js +384 -0
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +52 -0
- package/dist/executor.d.ts +2 -0
- package/dist/executor.js +2 -1
- package/dist/fanout.js +8 -5
- package/dist/fs-atomic.d.ts +3 -0
- package/dist/fs-atomic.js +123 -0
- package/dist/http-config.d.ts +35 -0
- package/dist/http-config.js +113 -0
- package/dist/http-transport.d.ts +59 -0
- package/dist/http-transport.js +269 -0
- package/dist/identity.d.ts +24 -0
- package/dist/identity.js +19 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +199 -18
- package/dist/master-key.d.ts +53 -0
- package/dist/master-key.js +242 -0
- package/dist/mcp-token.d.ts +81 -0
- package/dist/mcp-token.js +176 -0
- package/dist/migrate-config.d.ts +7 -0
- package/dist/migrate-config.js +103 -0
- package/dist/migrate-tokens.js +4 -3
- package/dist/net-tuning.d.ts +8 -0
- package/dist/net-tuning.js +25 -0
- package/dist/oauth-as.d.ts +52 -0
- package/dist/oauth-as.js +457 -0
- package/dist/oauth-consent.d.ts +28 -0
- package/dist/oauth-consent.js +109 -0
- package/dist/open-url.d.ts +1 -0
- package/dist/open-url.js +30 -0
- package/dist/reauth-hint.d.ts +2 -0
- package/dist/reauth-hint.js +15 -0
- package/dist/registry-transfer.d.ts +41 -0
- package/dist/registry-transfer.js +219 -0
- package/dist/registry.d.ts +27 -1
- package/dist/registry.js +117 -6
- package/dist/scope-catalog.d.ts +19 -0
- package/dist/scope-catalog.js +182 -0
- package/dist/scope-observability.d.ts +78 -0
- package/dist/scope-observability.js +192 -0
- package/dist/services.js +1 -1
- package/dist/setup-prompt.d.ts +9 -0
- package/dist/setup-prompt.js +59 -0
- package/dist/ssrf-guard.d.ts +48 -0
- package/dist/ssrf-guard.js +242 -0
- package/dist/token-store.js +23 -125
- package/dist/tools/_errors.d.ts +8 -2
- package/dist/tools/_errors.js +97 -6
- package/dist/tools/account-wizard.d.ts +33 -0
- package/dist/tools/account-wizard.js +284 -0
- package/dist/tools/accounts-tool.d.ts +8 -5
- package/dist/tools/accounts-tool.js +38 -15
- package/dist/tools/admin.js +2 -2
- package/dist/tools/calendar.js +2 -2
- package/dist/tools/chat.js +2 -2
- package/dist/tools/contacts.d.ts +40 -0
- package/dist/tools/contacts.js +135 -2
- package/dist/tools/docs.d.ts +3 -0
- package/dist/tools/docs.js +60 -9
- package/dist/tools/drive.d.ts +3 -0
- package/dist/tools/drive.js +60 -8
- package/dist/tools/forms.js +4 -2
- package/dist/tools/generated/_shared.d.ts +1 -3
- package/dist/tools/generated/_shared.js +3 -3
- package/dist/tools/generated/admin.js +175 -132
- package/dist/tools/generated/appsmarket.js +6 -2
- package/dist/tools/generated/calendar.js +45 -27
- package/dist/tools/generated/chat.js +81 -47
- package/dist/tools/generated/classroom.js +131 -104
- package/dist/tools/generated/cloudidentity.js +73 -61
- package/dist/tools/generated/cloudsearch.js +59 -49
- package/dist/tools/generated/contacts.js +22 -14
- package/dist/tools/generated/drive.js +51 -35
- package/dist/tools/generated/driveactivity.js +5 -1
- package/dist/tools/generated/drivelabels.js +30 -25
- package/dist/tools/generated/forms.js +7 -3
- package/dist/tools/generated/gmail.js +73 -58
- package/dist/tools/generated/groupsmigration.js +5 -1
- package/dist/tools/generated/groupssettings.js +7 -3
- package/dist/tools/generated/keep.js +12 -7
- package/dist/tools/generated/licensing.js +11 -7
- package/dist/tools/generated/meet.js +20 -13
- package/dist/tools/generated/postmaster.js +8 -4
- package/dist/tools/generated/reseller.js +22 -17
- package/dist/tools/generated/script.js +26 -16
- package/dist/tools/generated/sheets.js +11 -7
- package/dist/tools/generated/tasks.js +6 -2
- package/dist/tools/generated/vault.js +38 -33
- package/dist/tools/generated/workspaceevents.js +12 -7
- package/dist/tools/gmail-mime.d.ts +38 -8
- package/dist/tools/gmail-mime.js +75 -71
- package/dist/tools/gmail.d.ts +31 -0
- package/dist/tools/gmail.js +483 -99
- package/dist/tools/google-api.js +7 -4
- package/dist/tools/meet.js +2 -2
- package/dist/tools/searchconsole.js +2 -2
- package/dist/tools/sheets.js +6 -2
- package/dist/tools/slides.js +4 -2
- package/dist/tools/tasks.js +2 -2
- package/dist/types.d.ts +1 -1
- package/dist/write-control.d.ts +24 -1
- package/dist/write-control.js +26 -1
- package/package.json +26 -7
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// The curated bundle catalog: ONE source of truth consumed by the wizard
|
|
2
|
+
// picker, scope observability (#114) hints, and docs. Keys are a frozen
|
|
3
|
+
// public API (config.json references them by name) — renames go through
|
|
4
|
+
// BUNDLE_ALIASES, never in place.
|
|
5
|
+
export const ADMIN_SCOPES = [
|
|
6
|
+
'https://www.googleapis.com/auth/admin.reports.audit.readonly',
|
|
7
|
+
'https://www.googleapis.com/auth/admin.directory.user',
|
|
8
|
+
'https://www.googleapis.com/auth/admin.directory.group.readonly',
|
|
9
|
+
'https://www.googleapis.com/auth/admin.directory.group.member.readonly',
|
|
10
|
+
];
|
|
11
|
+
export const BUNDLE_CATALOG = {
|
|
12
|
+
slides: {
|
|
13
|
+
scopes: ['https://www.googleapis.com/auth/presentations'],
|
|
14
|
+
description: 'Create and edit Google Slides presentations.',
|
|
15
|
+
risk: 'low',
|
|
16
|
+
},
|
|
17
|
+
forms: {
|
|
18
|
+
scopes: [
|
|
19
|
+
'https://www.googleapis.com/auth/forms.body',
|
|
20
|
+
'https://www.googleapis.com/auth/forms.responses.readonly',
|
|
21
|
+
],
|
|
22
|
+
description: 'Build Google Forms and read their responses.',
|
|
23
|
+
risk: 'medium',
|
|
24
|
+
},
|
|
25
|
+
chat: {
|
|
26
|
+
scopes: [
|
|
27
|
+
'https://www.googleapis.com/auth/chat.spaces',
|
|
28
|
+
'https://www.googleapis.com/auth/chat.messages',
|
|
29
|
+
'https://www.googleapis.com/auth/chat.messages.create',
|
|
30
|
+
],
|
|
31
|
+
description: 'Read and send Google Chat messages and manage spaces (Workspace accounts).',
|
|
32
|
+
risk: 'medium',
|
|
33
|
+
},
|
|
34
|
+
// These extend the always-on gmail service: users.settings.* writes require
|
|
35
|
+
// them (reads already work via gmail.modify); sharing is split out as riskier.
|
|
36
|
+
gmail_settings: {
|
|
37
|
+
scopes: ['https://www.googleapis.com/auth/gmail.settings.basic'],
|
|
38
|
+
description: 'Change mailbox settings: filters, labels, vacation responder.',
|
|
39
|
+
risk: 'medium',
|
|
40
|
+
},
|
|
41
|
+
gmail_settings_sharing: {
|
|
42
|
+
scopes: ['https://www.googleapis.com/auth/gmail.settings.sharing'],
|
|
43
|
+
description: 'Manage forwarding addresses and delegates — can route mail out of the mailbox.',
|
|
44
|
+
risk: 'high',
|
|
45
|
+
},
|
|
46
|
+
classroom: {
|
|
47
|
+
scopes: [
|
|
48
|
+
'https://www.googleapis.com/auth/classroom.courses',
|
|
49
|
+
'https://www.googleapis.com/auth/classroom.coursework.me',
|
|
50
|
+
'https://www.googleapis.com/auth/classroom.coursework.students',
|
|
51
|
+
'https://www.googleapis.com/auth/classroom.courseworkmaterials',
|
|
52
|
+
'https://www.googleapis.com/auth/classroom.rosters',
|
|
53
|
+
'https://www.googleapis.com/auth/classroom.announcements',
|
|
54
|
+
'https://www.googleapis.com/auth/classroom.topics',
|
|
55
|
+
],
|
|
56
|
+
description: 'Manage Classroom courses, coursework, rosters and announcements.',
|
|
57
|
+
risk: 'medium',
|
|
58
|
+
},
|
|
59
|
+
cloudidentity: {
|
|
60
|
+
scopes: [
|
|
61
|
+
'https://www.googleapis.com/auth/cloud-identity.groups',
|
|
62
|
+
'https://www.googleapis.com/auth/cloud-identity.devices',
|
|
63
|
+
],
|
|
64
|
+
description: 'Manage Cloud Identity groups and devices across the directory.',
|
|
65
|
+
risk: 'high',
|
|
66
|
+
},
|
|
67
|
+
cloudsearch: {
|
|
68
|
+
scopes: ['https://www.googleapis.com/auth/cloud_search'],
|
|
69
|
+
description: 'Query Google Cloud Search across Workspace content.',
|
|
70
|
+
risk: 'medium',
|
|
71
|
+
},
|
|
72
|
+
vault: {
|
|
73
|
+
scopes: ['https://www.googleapis.com/auth/ediscovery'],
|
|
74
|
+
description: 'Google Vault eDiscovery: matters, holds and exports over the whole domain.',
|
|
75
|
+
risk: 'high',
|
|
76
|
+
workspaceOnly: true,
|
|
77
|
+
},
|
|
78
|
+
keep: {
|
|
79
|
+
scopes: ['https://www.googleapis.com/auth/keep'],
|
|
80
|
+
description: 'Read and edit Google Keep notes.',
|
|
81
|
+
risk: 'low',
|
|
82
|
+
},
|
|
83
|
+
driveactivity: {
|
|
84
|
+
scopes: ['https://www.googleapis.com/auth/drive.activity.readonly'],
|
|
85
|
+
description: 'Read the Drive activity feed (who did what, when).',
|
|
86
|
+
risk: 'low',
|
|
87
|
+
},
|
|
88
|
+
drivelabels: {
|
|
89
|
+
scopes: [
|
|
90
|
+
'https://www.googleapis.com/auth/drive.labels',
|
|
91
|
+
'https://www.googleapis.com/auth/drive.admin.labels',
|
|
92
|
+
],
|
|
93
|
+
description: 'Manage Drive labels, including admin label taxonomy.',
|
|
94
|
+
risk: 'medium',
|
|
95
|
+
},
|
|
96
|
+
script: {
|
|
97
|
+
scopes: [
|
|
98
|
+
'https://www.googleapis.com/auth/script.projects',
|
|
99
|
+
'https://www.googleapis.com/auth/script.deployments',
|
|
100
|
+
'https://www.googleapis.com/auth/script.processes',
|
|
101
|
+
'https://www.googleapis.com/auth/script.metrics',
|
|
102
|
+
],
|
|
103
|
+
description: 'Manage Apps Script projects and deployments.',
|
|
104
|
+
risk: 'medium',
|
|
105
|
+
},
|
|
106
|
+
postmaster: {
|
|
107
|
+
scopes: ['https://www.googleapis.com/auth/postmaster.readonly'],
|
|
108
|
+
description: 'Read Gmail Postmaster Tools deliverability data.',
|
|
109
|
+
risk: 'low',
|
|
110
|
+
},
|
|
111
|
+
groupssettings: {
|
|
112
|
+
scopes: ['https://www.googleapis.com/auth/apps.groups.settings'],
|
|
113
|
+
description: 'Change Google Groups settings for the domain.',
|
|
114
|
+
risk: 'medium',
|
|
115
|
+
},
|
|
116
|
+
groupsmigration: {
|
|
117
|
+
scopes: ['https://www.googleapis.com/auth/apps.groups.migration'],
|
|
118
|
+
description: 'Migrate archived messages into Google Groups.',
|
|
119
|
+
risk: 'high',
|
|
120
|
+
},
|
|
121
|
+
licensing: {
|
|
122
|
+
scopes: ['https://www.googleapis.com/auth/apps.licensing'],
|
|
123
|
+
description: 'Assign and revoke Workspace license seats.',
|
|
124
|
+
risk: 'high',
|
|
125
|
+
},
|
|
126
|
+
reseller: {
|
|
127
|
+
scopes: ['https://www.googleapis.com/auth/apps.order'],
|
|
128
|
+
description: 'Manage reseller customer subscriptions and orders.',
|
|
129
|
+
risk: 'high',
|
|
130
|
+
},
|
|
131
|
+
appsmarket: {
|
|
132
|
+
scopes: ['https://www.googleapis.com/auth/appsmarketplace.license'],
|
|
133
|
+
description: 'Read Marketplace app license assignments.',
|
|
134
|
+
risk: 'high',
|
|
135
|
+
},
|
|
136
|
+
admin: {
|
|
137
|
+
scopes: ADMIN_SCOPES,
|
|
138
|
+
description: 'Workspace admin: user directory management and audit reports.',
|
|
139
|
+
risk: 'high',
|
|
140
|
+
workspaceOnly: true,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
/** One-way soft-rename map (UD5): empty at 6.0.0; the mechanism ships now so a
|
|
144
|
+
* future rename is a deprecation warning, never a breaking config change. */
|
|
145
|
+
export const BUNDLE_ALIASES = {};
|
|
146
|
+
const warnedAliases = new Set();
|
|
147
|
+
export function resolveBundleAliases(bundles) {
|
|
148
|
+
return bundles.map((name) => {
|
|
149
|
+
const target = BUNDLE_ALIASES[name];
|
|
150
|
+
if (!target)
|
|
151
|
+
return name;
|
|
152
|
+
if (!warnedAliases.has(name)) {
|
|
153
|
+
warnedAliases.add(name);
|
|
154
|
+
process.stderr.write(`WARN: bundle "${name}" is deprecated; use "${target}".\n`);
|
|
155
|
+
}
|
|
156
|
+
return target;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** Closest catalog key for E_UNKNOWN_BUNDLE remediation (edit distance <= 2). */
|
|
160
|
+
export function closestBundle(name) {
|
|
161
|
+
let best;
|
|
162
|
+
let bestDist = 3;
|
|
163
|
+
for (const key of Object.keys(BUNDLE_CATALOG)) {
|
|
164
|
+
const d = editDistance(name.toLowerCase(), key);
|
|
165
|
+
if (d < bestDist) {
|
|
166
|
+
bestDist = d;
|
|
167
|
+
best = key;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return best;
|
|
171
|
+
}
|
|
172
|
+
function editDistance(a, b) {
|
|
173
|
+
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
174
|
+
for (let j = 1; j <= b.length; j++)
|
|
175
|
+
dp[0][j] = j;
|
|
176
|
+
for (let i = 1; i <= a.length; i++) {
|
|
177
|
+
for (let j = 1; j <= b.length; j++) {
|
|
178
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return dp[a.length][b.length];
|
|
182
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { resolveScopesForAccount } from './auth.js';
|
|
2
|
+
import { readToken } from './token-store.js';
|
|
3
|
+
export type ScopeState = 'callable' | 'requestable_not_granted' | 'not_requestable';
|
|
4
|
+
export type NotRequestableReason = 'add_bundle' | 'account_type' | 'unknown_scope';
|
|
5
|
+
export interface ScopeClassification {
|
|
6
|
+
state: ScopeState;
|
|
7
|
+
reason?: NotRequestableReason;
|
|
8
|
+
bundle?: string;
|
|
9
|
+
alsoIn?: string[];
|
|
10
|
+
}
|
|
11
|
+
export declare function classifyScope(scope: string, granted: ReadonlySet<string>, profile: ReadonlySet<string>): ScopeClassification;
|
|
12
|
+
export interface MethodScopeClassification {
|
|
13
|
+
state: ScopeState;
|
|
14
|
+
/** The alternative that achieved the best state — hint and classification
|
|
15
|
+
* always refer to this SAME scope. */
|
|
16
|
+
scope: string;
|
|
17
|
+
classification: ScopeClassification;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Discovery `method.scopes` is an ANY-OF list: holding any ONE listed scope
|
|
21
|
+
* authorizes the call. The method's state is therefore the BEST state among
|
|
22
|
+
* the alternatives (a catalog-unknown alternative like chat.bot must never
|
|
23
|
+
* mask a requestable one), and the remediation targets the cheapest fix.
|
|
24
|
+
*/
|
|
25
|
+
export declare function classifyMethodScopes(alternatives: readonly string[], granted: ReadonlySet<string>, profile: ReadonlySet<string>): MethodScopeClassification | null;
|
|
26
|
+
/**
|
|
27
|
+
* gh-CLI-style copy-pasteable remediation. Never promises a delta-only consent
|
|
28
|
+
* screen (Google re-lists everything at re-consent).
|
|
29
|
+
*/
|
|
30
|
+
export declare function scopeHint(scope: string, c: ScopeClassification, alias: string): {
|
|
31
|
+
hint: string;
|
|
32
|
+
retriable: boolean;
|
|
33
|
+
};
|
|
34
|
+
export interface AccountScopeSets {
|
|
35
|
+
granted: Set<string>;
|
|
36
|
+
profile: Set<string>;
|
|
37
|
+
}
|
|
38
|
+
/** Best-effort per-account sets; null when the token/profile is unreadable
|
|
39
|
+
* (decrypt error, missing token) — callers fall back to generic hints. */
|
|
40
|
+
export declare function accountScopeSets(alias: string, deps?: {
|
|
41
|
+
readTokenFn?: typeof readToken;
|
|
42
|
+
profileFn?: typeof resolveScopesForAccount;
|
|
43
|
+
}): AccountScopeSets | null;
|
|
44
|
+
/**
|
|
45
|
+
* Runtime enrichment for a scope 403 on a method whose required scopes are
|
|
46
|
+
* known (escape hatch: runtime Discovery; generated tools: baked). The
|
|
47
|
+
* account_type refinement lives here: a 403 on an admin scope that IS in the
|
|
48
|
+
* profile means Google refuses it for this account type (personal Gmail),
|
|
49
|
+
* so re-auth would loop — report the dead end instead.
|
|
50
|
+
*/
|
|
51
|
+
export declare function scopeHintForMethod(required: readonly string[], alias: string, deps?: Parameters<typeof accountScopeSets>[1]): {
|
|
52
|
+
hint: string;
|
|
53
|
+
retriable: boolean;
|
|
54
|
+
} | null;
|
|
55
|
+
export interface ScopesReport {
|
|
56
|
+
configured: number;
|
|
57
|
+
granted: number;
|
|
58
|
+
callable: string[];
|
|
59
|
+
requestable: string[];
|
|
60
|
+
notRequestable: {
|
|
61
|
+
addBundle: {
|
|
62
|
+
scope: string;
|
|
63
|
+
bundle: string;
|
|
64
|
+
}[];
|
|
65
|
+
/** Runtime-signal bucket: static classification cannot prove account
|
|
66
|
+
* type; doctor (B9) fills it from observed persistent admin 403s. */
|
|
67
|
+
accountType: string[];
|
|
68
|
+
unknown: string[];
|
|
69
|
+
};
|
|
70
|
+
missing: string[];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The account_list / doctor block. Universe = profile ∪ granted ∪ the scopes
|
|
74
|
+
* required by REGISTERED tools ("registered but not requestable" is the
|
|
75
|
+
* original #114 ask).
|
|
76
|
+
*/
|
|
77
|
+
export declare function buildScopesReport(granted: ReadonlySet<string>, profile: ReadonlySet<string>, registeredScopes?: Iterable<string>): ScopesReport;
|
|
78
|
+
export declare function clearScopeIndexForTest(): void;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { ADMIN_SCOPES, BUNDLE_CATALOG } from './scope-catalog.js';
|
|
2
|
+
import { BASE_SCOPES, resolveScopesForAccount } from './auth.js';
|
|
3
|
+
import { readToken } from './token-store.js';
|
|
4
|
+
// Reverse index scope -> bundles offering it, narrowest bundle first (fewest
|
|
5
|
+
// scopes) so the remediation names the smallest grant. BASE_SCOPES map to the
|
|
6
|
+
// pseudo-bundle "base" (outside a profile only via includesBase:false).
|
|
7
|
+
let reverseIndex = null;
|
|
8
|
+
function scopeIndex() {
|
|
9
|
+
if (reverseIndex)
|
|
10
|
+
return reverseIndex;
|
|
11
|
+
const idx = new Map();
|
|
12
|
+
const add = (scope, bundle) => {
|
|
13
|
+
const list = idx.get(scope) ?? [];
|
|
14
|
+
list.push(bundle);
|
|
15
|
+
idx.set(scope, list);
|
|
16
|
+
};
|
|
17
|
+
for (const [name, entry] of Object.entries(BUNDLE_CATALOG)) {
|
|
18
|
+
for (const scope of entry.scopes)
|
|
19
|
+
add(scope, name);
|
|
20
|
+
}
|
|
21
|
+
for (const scope of BASE_SCOPES)
|
|
22
|
+
add(scope, 'base');
|
|
23
|
+
const size = (b) => (b === 'base' ? BASE_SCOPES.length : BUNDLE_CATALOG[b].scopes.length);
|
|
24
|
+
for (const list of idx.values())
|
|
25
|
+
list.sort((a, b) => size(a) - size(b) || a.localeCompare(b));
|
|
26
|
+
reverseIndex = idx;
|
|
27
|
+
return idx;
|
|
28
|
+
}
|
|
29
|
+
export function classifyScope(scope, granted, profile) {
|
|
30
|
+
if (granted.has(scope))
|
|
31
|
+
return { state: 'callable' };
|
|
32
|
+
if (profile.has(scope))
|
|
33
|
+
return { state: 'requestable_not_granted' };
|
|
34
|
+
const bundles = scopeIndex().get(scope);
|
|
35
|
+
if (bundles && bundles.length > 0) {
|
|
36
|
+
return {
|
|
37
|
+
state: 'not_requestable',
|
|
38
|
+
reason: 'add_bundle',
|
|
39
|
+
bundle: bundles[0],
|
|
40
|
+
...(bundles.length > 1 ? { alsoIn: bundles.slice(1) } : {}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return { state: 'not_requestable', reason: 'unknown_scope' };
|
|
44
|
+
}
|
|
45
|
+
const STATE_ORDER = {
|
|
46
|
+
callable: 0,
|
|
47
|
+
requestable_not_granted: 1,
|
|
48
|
+
not_requestable: 2,
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Discovery `method.scopes` is an ANY-OF list: holding any ONE listed scope
|
|
52
|
+
* authorizes the call. The method's state is therefore the BEST state among
|
|
53
|
+
* the alternatives (a catalog-unknown alternative like chat.bot must never
|
|
54
|
+
* mask a requestable one), and the remediation targets the cheapest fix.
|
|
55
|
+
*/
|
|
56
|
+
export function classifyMethodScopes(alternatives, granted, profile) {
|
|
57
|
+
let best = null;
|
|
58
|
+
for (const scope of alternatives) {
|
|
59
|
+
const c = classifyScope(scope, granted, profile);
|
|
60
|
+
if (!best || STATE_ORDER[c.state] < STATE_ORDER[best.classification.state]) {
|
|
61
|
+
best = { state: c.state, scope, classification: c };
|
|
62
|
+
}
|
|
63
|
+
if (best.state === 'callable')
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
return best;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* gh-CLI-style copy-pasteable remediation. Never promises a delta-only consent
|
|
70
|
+
* screen (Google re-lists everything at re-consent).
|
|
71
|
+
*/
|
|
72
|
+
export function scopeHint(scope, c, alias) {
|
|
73
|
+
switch (c.state) {
|
|
74
|
+
case 'requestable_not_granted':
|
|
75
|
+
return {
|
|
76
|
+
hint: `Scope ${scope} is in ${alias}'s profile but not granted (you unchecked it at consent). ` +
|
|
77
|
+
`Re-grant: npx mcp-google-multi auth --account ${alias}`,
|
|
78
|
+
retriable: true,
|
|
79
|
+
};
|
|
80
|
+
case 'not_requestable':
|
|
81
|
+
if (c.reason === 'add_bundle') {
|
|
82
|
+
// "base" is a pseudo-bundle (BASE_SCOPES excluded by includesBase:
|
|
83
|
+
// false), NOT a catalog key — telling users to add it to bundles[]
|
|
84
|
+
// would fail boot with E_UNKNOWN_BUNDLE.
|
|
85
|
+
if (c.bundle === 'base') {
|
|
86
|
+
return {
|
|
87
|
+
hint: `Scope ${scope} is a base scope excluded by "includesBase": false in ${alias}'s profile. ` +
|
|
88
|
+
`Set includesBase back to true (or remove it) in config.json, then npx mcp-google-multi auth --account ${alias}`,
|
|
89
|
+
retriable: false,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
hint: `Scope ${scope} needs the "${c.bundle}" bundle, which is not in ${alias}'s profile. ` +
|
|
94
|
+
`Add it: set ${alias}'s profile bundles to include "${c.bundle}" in config.json, then npx mcp-google-multi auth --account ${alias}`,
|
|
95
|
+
retriable: false,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (c.reason === 'account_type') {
|
|
99
|
+
return {
|
|
100
|
+
hint: `Scope ${scope} requires a Google Workspace account; ${alias} is a personal account and will 403. Use a Workspace alias. Do not retry.`,
|
|
101
|
+
retriable: false,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
hint: `Scope ${scope} is not offered by this server (no bundle grants it). Not requestable. Do not retry.`,
|
|
106
|
+
retriable: false,
|
|
107
|
+
};
|
|
108
|
+
default:
|
|
109
|
+
return { hint: '', retriable: true };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Best-effort per-account sets; null when the token/profile is unreadable
|
|
113
|
+
* (decrypt error, missing token) — callers fall back to generic hints. */
|
|
114
|
+
export function accountScopeSets(alias, deps = {}) {
|
|
115
|
+
try {
|
|
116
|
+
const token = (deps.readTokenFn ?? readToken)(alias);
|
|
117
|
+
const granted = new Set(typeof token?.scope === 'string' ? token.scope.split(' ').filter(Boolean) : []);
|
|
118
|
+
const profile = new Set((deps.profileFn ?? resolveScopesForAccount)(alias));
|
|
119
|
+
return { granted, profile };
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Runtime enrichment for a scope 403 on a method whose required scopes are
|
|
127
|
+
* known (escape hatch: runtime Discovery; generated tools: baked). The
|
|
128
|
+
* account_type refinement lives here: a 403 on an admin scope that IS in the
|
|
129
|
+
* profile means Google refuses it for this account type (personal Gmail),
|
|
130
|
+
* so re-auth would loop — report the dead end instead.
|
|
131
|
+
*/
|
|
132
|
+
export function scopeHintForMethod(required, alias, deps = {}) {
|
|
133
|
+
const sets = accountScopeSets(alias, deps);
|
|
134
|
+
if (!sets || required.length === 0)
|
|
135
|
+
return null;
|
|
136
|
+
const best = classifyMethodScopes(required, sets.granted, sets.profile);
|
|
137
|
+
if (!best || best.state === 'callable')
|
|
138
|
+
return null; // 403 despite grants: not a scope-classification problem
|
|
139
|
+
const base = scopeHint(best.scope, best.classification, alias);
|
|
140
|
+
// Hedged, never fabricated: an in-profile admin scope that 403s is EITHER a
|
|
141
|
+
// Workspace account needing re-auth OR a personal account that can never be
|
|
142
|
+
// granted admin — the inputs cannot distinguish, so say both.
|
|
143
|
+
if (best.state === 'requestable_not_granted' && ADMIN_SCOPES.includes(best.scope)) {
|
|
144
|
+
return {
|
|
145
|
+
hint: `${base.hint} Note: personal (non-Workspace) accounts can never grant admin scopes — if ${alias} is a personal Gmail account, use a Workspace alias instead.`,
|
|
146
|
+
retriable: true,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return base;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The account_list / doctor block. Universe = profile ∪ granted ∪ the scopes
|
|
153
|
+
* required by REGISTERED tools ("registered but not requestable" is the
|
|
154
|
+
* original #114 ask).
|
|
155
|
+
*/
|
|
156
|
+
export function buildScopesReport(granted, profile, registeredScopes = []) {
|
|
157
|
+
const callable = [];
|
|
158
|
+
const requestable = [];
|
|
159
|
+
const addBundle = [];
|
|
160
|
+
const accountType = [];
|
|
161
|
+
const unknown = [];
|
|
162
|
+
const universe = new Set([...profile, ...granted, ...registeredScopes]);
|
|
163
|
+
for (const scope of [...universe].sort()) {
|
|
164
|
+
const c = classifyScope(scope, granted, profile);
|
|
165
|
+
// Spec-exact: callable = granted ∩ profile (a granted scope the profile
|
|
166
|
+
// dropped is not reported as callable capacity).
|
|
167
|
+
if (c.state === 'callable') {
|
|
168
|
+
if (profile.has(scope))
|
|
169
|
+
callable.push(scope);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (c.state === 'requestable_not_granted')
|
|
173
|
+
requestable.push(scope);
|
|
174
|
+
else if (c.reason === 'add_bundle')
|
|
175
|
+
addBundle.push({ scope, bundle: c.bundle });
|
|
176
|
+
else if (c.reason === 'account_type')
|
|
177
|
+
accountType.push(scope);
|
|
178
|
+
else
|
|
179
|
+
unknown.push(scope);
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
configured: profile.size,
|
|
183
|
+
granted: granted.size,
|
|
184
|
+
callable,
|
|
185
|
+
requestable,
|
|
186
|
+
notRequestable: { addBundle, accountType, unknown },
|
|
187
|
+
missing: [...requestable, ...addBundle.map((e) => e.scope), ...accountType, ...unknown],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
export function clearScopeIndexForTest() {
|
|
191
|
+
reverseIndex = null;
|
|
192
|
+
}
|
package/dist/services.js
CHANGED
|
@@ -31,7 +31,7 @@ export const SERVICES = [
|
|
|
31
31
|
// and workspaceevents is deliberately absent — no dedicated scope (subscriptions use resource scopes).
|
|
32
32
|
const bundleGate = (name) => ({
|
|
33
33
|
enabled: () => new Set(getOptionalBundles()).has(name),
|
|
34
|
-
hint: `add "${name}" to GOOGLE_OPTIONAL_SCOPES`,
|
|
34
|
+
hint: `add "${name}" to an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES)`,
|
|
35
35
|
});
|
|
36
36
|
export const GENERATED_GATES = {
|
|
37
37
|
appsmarket: bundleGate('appsmarket'),
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
export interface SetupOptions {
|
|
3
|
+
/** Public base URL when serving over HTTP; used for the Web client redirect. */
|
|
4
|
+
publicUrl?: string;
|
|
5
|
+
}
|
|
6
|
+
/** Pure builder (unit-testable): the full guided walkthrough as Markdown. */
|
|
7
|
+
export declare function buildSetupText(opts?: SetupOptions): string;
|
|
8
|
+
/** Register the `setup` prompt on the MCP server. */
|
|
9
|
+
export declare function registerSetupPrompt(server: McpServer): void;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/** Pure builder (unit-testable): the full guided walkthrough as Markdown. */
|
|
3
|
+
export function buildSetupText(opts = {}) {
|
|
4
|
+
const base = opts.publicUrl?.replace(/\/+$/, '');
|
|
5
|
+
const clientStep = base
|
|
6
|
+
? `Create an OAuth client of type **Web application** and add the redirect URI \`${base}/callback\`.`
|
|
7
|
+
: 'Create an OAuth client of type **Desktop app** (for the default stdio transport). If you serve over HTTP instead, create a **Web application** client with redirect `${MCP_PUBLIC_URL}/callback`.';
|
|
8
|
+
return [
|
|
9
|
+
'# Set up mcp-google-multi (Google Cloud Console prelude)',
|
|
10
|
+
'',
|
|
11
|
+
'This server uses **your own** Google OAuth client (BYO). Steps 1–5 below are one-time Google Cloud Console setup that **has no creation API** — Google provides no way to script consent screens or OAuth clients, so this guided walkthrough with deep links is the honest ceiling. Do them once in the browser; the server automates everything after.',
|
|
12
|
+
'',
|
|
13
|
+
'## 1. Create or pick a Google Cloud project',
|
|
14
|
+
'Open https://console.cloud.google.com/projectcreate and create a project (or pick an existing one). Note its project id.',
|
|
15
|
+
'',
|
|
16
|
+
'## 2. Enable the APIs your bundles need',
|
|
17
|
+
'Open https://console.cloud.google.com/apis/library and enable each Workspace API you will use (Gmail, Drive, Calendar, Sheets, Docs, People, …). Each API also has a direct link: `https://console.cloud.google.com/apis/library/<api>.googleapis.com` (e.g. `gmail.googleapis.com`). Later, `diagnose` / `doctor` will flag any API you missed with its exact enable link.',
|
|
18
|
+
'',
|
|
19
|
+
'## 3. Configure the OAuth consent screen',
|
|
20
|
+
'Open https://console.cloud.google.com/auth/audience — set **User type: External**, and under **Test users** add yourself (the owner) and anyone else who will authenticate.',
|
|
21
|
+
'',
|
|
22
|
+
'> **Expect an "unverified app" screen.** Because this is your own self-hosted client (not a Google-verified public app), the consent flow shows *"Google hasn\'t verified this app."* That is normal and safe for a client you created. Click **Advanced → Go to <your app> (unsafe)** to continue — you are trusting your own client. It is not a real security warning for your own setup.',
|
|
23
|
+
'',
|
|
24
|
+
'## 4. Set Publishing status to In production (avoid the 7-day trap)',
|
|
25
|
+
'On the same https://console.cloud.google.com/auth/audience page, set **Publishing status: In production**. While an app is in **Testing**, Google expires refresh tokens after **7 days**, so you would have to re-authenticate every week. Publishing to production removes that expiry. (No verification is required for personal use with your own test users.)',
|
|
26
|
+
'',
|
|
27
|
+
`## 5. Create an OAuth client`,
|
|
28
|
+
`Open https://console.cloud.google.com/auth/clients. ${clientStep} Copy the generated **Client ID** and **Client secret**.`,
|
|
29
|
+
'',
|
|
30
|
+
'## 6. Give the server your client credentials',
|
|
31
|
+
'Put them in real environment variables or in `~/.config/mcp-google-multi/.env` (never in `config.json`):',
|
|
32
|
+
'',
|
|
33
|
+
'```',
|
|
34
|
+
'GOOGLE_CLIENT_ID=<your client id>',
|
|
35
|
+
'GOOGLE_CLIENT_SECRET=<your client secret>',
|
|
36
|
+
'```',
|
|
37
|
+
'',
|
|
38
|
+
'## 7. Add your first account',
|
|
39
|
+
'Run the `account_add` flow (or `npx mcp-google-multi auth --account <alias>`) and complete the browser consent. The server then validates the granted scopes live and stores the encrypted token.',
|
|
40
|
+
'',
|
|
41
|
+
'---',
|
|
42
|
+
'When you are done, run `doctor` (or call `diagnose`) — it checks runtime, config, keys, tokens, scopes, and API enablement, and prints a copy-pasteable fix for anything still wrong.',
|
|
43
|
+
].join('\n');
|
|
44
|
+
}
|
|
45
|
+
/** Register the `setup` prompt on the MCP server. */
|
|
46
|
+
export function registerSetupPrompt(server) {
|
|
47
|
+
server.registerPrompt('setup', {
|
|
48
|
+
title: 'Set up mcp-google-multi',
|
|
49
|
+
description: 'Guided Google Cloud Console prelude: project, APIs, consent screen, OAuth client, and credentials. The one-time browser setup that has no API.',
|
|
50
|
+
argsSchema: { publicUrl: z.string().optional().describe('Public base URL when serving over HTTP (for the Web OAuth client redirect). Omit for stdio.') },
|
|
51
|
+
}, (args) => ({
|
|
52
|
+
messages: [
|
|
53
|
+
{
|
|
54
|
+
role: 'assistant',
|
|
55
|
+
content: { type: 'text', text: buildSetupText({ publicUrl: args?.publicUrl }) },
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export declare class SsrfBlockedError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
/** True if an IPv4 literal is in a blocked (private/link-local/loopback/etc) range. */
|
|
5
|
+
export declare function isBlockedIPv4(ip: string): boolean;
|
|
6
|
+
/** True if an IPv6 literal is blocked. */
|
|
7
|
+
export declare function isBlockedIPv6(ip: string): boolean;
|
|
8
|
+
export declare function isBlockedIp(ip: string): boolean;
|
|
9
|
+
export interface SsrfDeps {
|
|
10
|
+
/** Injectable resolver (defaults to dns.lookup all-addresses) for tests. */
|
|
11
|
+
resolveAll?: (host: string) => Promise<string[]>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Assert a URL is safe to fetch server-side: HTTPS scheme, and every address
|
|
15
|
+
* the host resolves to is public. Throws SsrfBlockedError otherwise, and
|
|
16
|
+
* returns the resolved IPs.
|
|
17
|
+
*
|
|
18
|
+
* NOTE: this checks the host's addresses but does NOT pin them — the caller
|
|
19
|
+
* fetches by hostname, so undici re-resolves at connect time (a check-time vs
|
|
20
|
+
* connect-time TOCTOU / short-TTL rebind window). We deliberately do not pin
|
|
21
|
+
* (CDN IPs rotate). The real anti-SSRF control is the caller-side issuer
|
|
22
|
+
* allowlist (oauth-as validateClient rejects any non-allowlisted host BEFORE
|
|
23
|
+
* fetching), so an attacker can't steer the fetch at an arbitrary hostname;
|
|
24
|
+
* this public-range check is defense-in-depth on top of that.
|
|
25
|
+
*/
|
|
26
|
+
export declare function assertPublicHttpsUrl(rawUrl: string, deps?: SsrfDeps): Promise<{
|
|
27
|
+
url: URL;
|
|
28
|
+
addresses: string[];
|
|
29
|
+
}>;
|
|
30
|
+
export interface CimdFetchOptions {
|
|
31
|
+
maxBytes?: number;
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
maxRedirects?: number;
|
|
34
|
+
/** Transient-network-error retries per hop (default 2 → up to 3 attempts). */
|
|
35
|
+
retries?: number;
|
|
36
|
+
/** Base backoff between retries in ms; grows linearly per attempt (default 200). */
|
|
37
|
+
retryBackoffMs?: number;
|
|
38
|
+
fetchImpl?: typeof fetch;
|
|
39
|
+
/** Injectable sleep for tests (default real setTimeout). */
|
|
40
|
+
sleepImpl?: (ms: number) => Promise<void>;
|
|
41
|
+
ssrf?: SsrfDeps;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* SSRF-guarded fetch of a CIMD client-metadata document (JSON). Every hop
|
|
45
|
+
* (including redirects) is HTTPS + public-IP checked; response is size- and
|
|
46
|
+
* time-capped. Returns the parsed JSON object.
|
|
47
|
+
*/
|
|
48
|
+
export declare function fetchCimdDocument(rawUrl: string, opts?: CimdFetchOptions): Promise<Record<string, unknown>>;
|