mcp-google-multi 6.0.0-alpha.34 → 6.0.0-alpha.36
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/registry.js +2 -2
- package/dist/tools/account-wizard.js +7 -7
- package/dist/tools/drive.js +3 -0
- package/dist/tools/google-api.js +4 -1
- package/dist/write-control.d.ts +26 -0
- package/dist/write-control.js +88 -10
- package/package.json +1 -1
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
|
|
@@ -14,7 +14,7 @@ import { detectClients, buildServerEntry, renderInstruction, applyFileEntry, res
|
|
|
14
14
|
// running server can add an account without the deployer editing env + running
|
|
15
15
|
// a CLI. Google consent reuses the loopback flow (leg C); the HTTP-transport
|
|
16
16
|
// consent path (${BASE}/authorize) is owned by the OAuth AS and lands with that
|
|
17
|
-
// cluster
|
|
17
|
+
// cluster. account_add over http is gated behind it.
|
|
18
18
|
const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
|
|
19
19
|
// The common optional bundles get their own checkbox (MCP elicitation has no
|
|
20
20
|
// multi-select, so a boolean per bundle IS the checklist); the long tail stays
|
|
@@ -214,7 +214,7 @@ async function runConsent(server, alias) {
|
|
|
214
214
|
function s4Text(alias, missing) {
|
|
215
215
|
const outcome = missing.length === 0
|
|
216
216
|
? `✔ "${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)
|
|
217
|
+
: `⚠ "${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
218
|
return `${outcome}\n${TESTING_MODE_WARNING}`;
|
|
219
219
|
}
|
|
220
220
|
const REQUIRES_INTERACTION = { 'anthropic/requiresUserInteraction': true };
|
|
@@ -226,7 +226,7 @@ export function registerAccountWizardTools(registry, server) {
|
|
|
226
226
|
registerMeta('account_add', {
|
|
227
227
|
_meta: REQUIRES_INTERACTION,
|
|
228
228
|
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
|
|
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. No file editing or restart needed. Requires GOOGLE_CLIENT_ID/SECRET (run the `setup` prompt first if missing).',
|
|
230
230
|
inputSchema: {
|
|
231
231
|
alias: z.string().optional().describe('Account alias (letters, digits, _ or -). Pass with email to add directly, skipping the form.'),
|
|
232
232
|
account: z.string().optional().describe('Alias for the new account (same as `alias`; every other tool spells it `account`)'),
|
|
@@ -358,16 +358,16 @@ export function registerAccountWizardTools(registry, server) {
|
|
|
358
358
|
for (const client of targets) {
|
|
359
359
|
const instr = renderInstruction(client, name, entry);
|
|
360
360
|
if (client.managed === 'cli') {
|
|
361
|
-
blocks.push(`${client.label}
|
|
361
|
+
blocks.push(`${client.label}: run\n ${instr.text}`);
|
|
362
362
|
}
|
|
363
363
|
else if (a.write) {
|
|
364
364
|
const res = applyFileEntry(client, name, entry);
|
|
365
365
|
blocks.push(res.ok
|
|
366
|
-
? `${client.label}
|
|
367
|
-
: `${client.label}
|
|
366
|
+
? `${client.label}: ${res.action} "${name}" in ${res.path}${res.backup ? ` (backup ${res.backup})` : ''}`
|
|
367
|
+
: `${client.label}: ${res.message}\n${res.snippet ?? ''}`);
|
|
368
368
|
}
|
|
369
369
|
else {
|
|
370
|
-
blocks.push(`${client.label}
|
|
370
|
+
blocks.push(`${client.label}: add to ${instr.path}\n${instr.text}`);
|
|
371
371
|
}
|
|
372
372
|
}
|
|
373
373
|
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/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,7 +39,29 @@ 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;
|
|
48
|
+
export type DenyReason = {
|
|
49
|
+
rule: 'read_only';
|
|
50
|
+
} | {
|
|
51
|
+
rule: 'deny_glob';
|
|
52
|
+
pattern: string;
|
|
53
|
+
} | {
|
|
54
|
+
rule: 'privileged';
|
|
55
|
+
profile: Profile;
|
|
56
|
+
kind: PrivilegeKind;
|
|
57
|
+
} | {
|
|
58
|
+
rule: 'profile';
|
|
59
|
+
profile: Profile;
|
|
60
|
+
};
|
|
61
|
+
/** WHICH rule refused the call. `isAllowed` has a precedence order, so exactly
|
|
62
|
+
* one rule decides; naming the others sends the caller to a setting that
|
|
63
|
+
* cannot change the outcome. */
|
|
64
|
+
export declare function denyReason(tool: ToolRef, policy: Policy): DenyReason | undefined;
|
|
39
65
|
export declare function writeDisabledResult(tool: ToolRef, policy: Policy, account?: string): {
|
|
40
66
|
content: {
|
|
41
67
|
type: "text";
|
package/dist/write-control.js
CHANGED
|
@@ -45,24 +45,51 @@ function globToRegExp(glob) {
|
|
|
45
45
|
const escaped = glob.trim().replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
46
46
|
return new RegExp(`^${escaped}$`);
|
|
47
47
|
}
|
|
48
|
+
function opOf(tool) {
|
|
49
|
+
return tool.name.includes('_') ? tool.name.slice(tool.name.indexOf('_') + 1) : tool.name;
|
|
50
|
+
}
|
|
48
51
|
function candidates(tool) {
|
|
49
|
-
|
|
50
|
-
return [`${tool.service}:${tool.cud}`, `${tool.service}:${op}`];
|
|
52
|
+
return [`${tool.service}:${tool.cud}`, `${tool.service}:${opOf(tool)}`];
|
|
51
53
|
}
|
|
52
|
-
function
|
|
54
|
+
function firstMatch(tool, globs) {
|
|
53
55
|
if (globs.length === 0)
|
|
54
|
-
return
|
|
56
|
+
return undefined;
|
|
55
57
|
const cands = candidates(tool);
|
|
56
|
-
return globs.
|
|
58
|
+
return globs.find((g) => {
|
|
57
59
|
const re = globToRegExp(g);
|
|
58
60
|
return cands.some((c) => re.test(c));
|
|
59
61
|
});
|
|
60
62
|
}
|
|
61
|
-
function
|
|
63
|
+
function matchesAny(tool, globs) {
|
|
64
|
+
return firstMatch(tool, globs) !== undefined;
|
|
65
|
+
}
|
|
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) {
|
|
62
86
|
if (profile === 'full-writes')
|
|
63
87
|
return true;
|
|
64
|
-
if (profile === 'safe-writes')
|
|
65
|
-
|
|
88
|
+
if (profile === 'safe-writes') {
|
|
89
|
+
if (privilegeOf(tool) !== undefined)
|
|
90
|
+
return false;
|
|
91
|
+
return tool.cud === 'create' || tool.cud === 'update';
|
|
92
|
+
}
|
|
66
93
|
return false;
|
|
67
94
|
}
|
|
68
95
|
export function isAllowed(tool, policy) {
|
|
@@ -74,13 +101,64 @@ export function isAllowed(tool, policy) {
|
|
|
74
101
|
return false;
|
|
75
102
|
if (matchesAny(tool, policy.allow))
|
|
76
103
|
return true;
|
|
77
|
-
return profileAllows(policy.profile, tool
|
|
104
|
+
return profileAllows(policy.profile, tool);
|
|
105
|
+
}
|
|
106
|
+
/** Which profiles would permit this tool at all. */
|
|
107
|
+
function profilesAllowing(tool) {
|
|
108
|
+
return PROFILES.filter((p) => profileAllows(p, tool));
|
|
109
|
+
}
|
|
110
|
+
/** WHICH rule refused the call. `isAllowed` has a precedence order, so exactly
|
|
111
|
+
* one rule decides; naming the others sends the caller to a setting that
|
|
112
|
+
* cannot change the outcome. */
|
|
113
|
+
export function denyReason(tool, policy) {
|
|
114
|
+
if (tool.cud === 'read')
|
|
115
|
+
return undefined;
|
|
116
|
+
if (policy.readOnly)
|
|
117
|
+
return { rule: 'read_only' };
|
|
118
|
+
const pattern = firstMatch(tool, policy.deny);
|
|
119
|
+
if (pattern !== undefined)
|
|
120
|
+
return { rule: 'deny_glob', pattern };
|
|
121
|
+
if (matchesAny(tool, policy.allow))
|
|
122
|
+
return undefined;
|
|
123
|
+
if (profileAllows(policy.profile, tool))
|
|
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
|
+
}
|
|
131
|
+
return { rule: 'profile', profile: policy.profile };
|
|
132
|
+
}
|
|
133
|
+
function denyHint(tool, reason) {
|
|
134
|
+
if (reason.rule === 'read_only') {
|
|
135
|
+
return 'GOOGLE_READ_ONLY=true refuses every write, ahead of the profile and the allow-list, so neither of those can enable this. Unset GOOGLE_READ_ONLY to allow writes.';
|
|
136
|
+
}
|
|
137
|
+
if (reason.rule === 'deny_glob') {
|
|
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.`;
|
|
139
|
+
}
|
|
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);
|
|
147
|
+
const profiles = usable.length > 0
|
|
148
|
+
? `Use GOOGLE_PROFILE=${usable.join(' or ')}`
|
|
149
|
+
: 'No profile permits this operation';
|
|
150
|
+
// The allow-list vocabulary is service:cud or service:op, never the tool
|
|
151
|
+
// name, so suggesting the name would hand back a pattern that never matches.
|
|
152
|
+
return `Profile "${reason.profile}" does not permit ${tool.cud}. ${profiles}, or allow just this one with GOOGLE_WRITE_ALLOW="${tool.service}:${opOf(tool)}".`;
|
|
78
153
|
}
|
|
79
154
|
export function writeDisabledResult(tool, policy, account) {
|
|
155
|
+
// Recomputed rather than passed in: every call site has already run
|
|
156
|
+
// isAllowed, and a hint that disagrees with the verdict is worse than none.
|
|
157
|
+
const reason = denyReason(tool, policy) ?? { rule: 'profile', profile: policy.profile };
|
|
80
158
|
const envelope = {
|
|
81
159
|
error: 'write_disabled',
|
|
82
160
|
message: `"${tool.name}" (${tool.cud}) is disabled by the current write-control policy (profile: ${policy.profile}${policy.readOnly ? ', GOOGLE_READ_ONLY=true' : ''}).`,
|
|
83
|
-
hint:
|
|
161
|
+
hint: denyHint(tool, reason),
|
|
84
162
|
retriable: false,
|
|
85
163
|
...(account ? { account } : {}),
|
|
86
164
|
};
|
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.36",
|
|
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",
|