mcp-google-multi 6.0.0-alpha.16 → 6.0.0-alpha.18
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 +2 -0
- package/dist/auth.js +15 -72
- package/dist/client.js +3 -1
- package/dist/oauth-consent.d.ts +25 -10
- package/dist/oauth-consent.js +85 -39
- package/dist/tools/account-wizard.d.ts +10 -0
- package/dist/tools/account-wizard.js +52 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,8 @@ New to all this? It's written for someone who just installed Claude Code and has
|
|
|
20
20
|
npm install -g mcp-google-multi
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
Claude Desktop user? You can skip npm entirely: download the `mcp-google-multi.mcpb` bundle from the [latest release](https://github.com/bakissation/mcp-google-multi/releases/latest), double-click it (or drag it into Claude Desktop → Settings → Extensions), and fill in the values from step 2 when prompted.
|
|
24
|
+
|
|
23
25
|
2. **Make your Google key** (the one manual part, a few minutes, because Google has no way to script it). Follow the step-by-step [Google Cloud setup](./docs/google-cloud-setup.md), or just ask Claude Code: *"walk me through creating a Google OAuth Desktop client for mcp-google-multi."* You finish with two values, a **Client ID** and a **Client Secret**. It's free and private to you.
|
|
24
26
|
|
|
25
27
|
3. **Put them in a file.** In the folder you'll run from, make a file named `.env` and paste this, filling in your values:
|
package/dist/auth.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import { OAuth2Client } from 'googleapis-common';
|
|
2
|
-
import http from 'node:http';
|
|
3
|
-
import { URL } from 'node:url';
|
|
4
1
|
import { randomBytes } from 'node:crypto';
|
|
5
2
|
import { openUrl } from './open-url.js';
|
|
6
3
|
import { ACCOUNTS, getAccountSet } from './accounts.js';
|
|
7
4
|
import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
|
|
8
5
|
import { resolveMasterKey } from './master-key.js';
|
|
9
6
|
import { writeToken } from './token-store.js';
|
|
7
|
+
import { buildConsentClient, openLoopbackConsent, TESTING_MODE_WARNING } from './oauth-consent.js';
|
|
10
8
|
// Personal (non-Workspace) accounts 403 on admin scopes; ADMIN_SCOPES stays per-account opt-in, never granted by default.
|
|
11
9
|
export const BASE_SCOPES = [
|
|
12
10
|
'https://www.googleapis.com/auth/gmail.modify',
|
|
@@ -134,7 +132,11 @@ export async function runAuthFlow(args) {
|
|
|
134
132
|
// Auto-provisions on a fresh install (env > keychain > file > generate);
|
|
135
133
|
// resolves eagerly so a provisioning failure surfaces before the browser opens.
|
|
136
134
|
resolveMasterKey();
|
|
137
|
-
|
|
135
|
+
// Shared ephemeral-port loopback flow (oauth-consent.ts): listen first, then
|
|
136
|
+
// build the auth URL from the assigned redirect. CLI gets a patient timeout;
|
|
137
|
+
// errors propagate to main()'s fatal handler like every other CLI failure.
|
|
138
|
+
const loop = await openLoopbackConsent({ timeoutMs: 10 * 60_000 });
|
|
139
|
+
const oauth2Client = buildConsentClient(loop.redirect);
|
|
138
140
|
// CSRF protection for the OAuth callback (RFC 6749 §10.12).
|
|
139
141
|
const expectedState = randomBytes(32).toString('hex');
|
|
140
142
|
const authorizeUrl = oauth2Client.generateAuthUrl({
|
|
@@ -149,72 +151,13 @@ export async function runAuthFlow(args) {
|
|
|
149
151
|
if (getAdminAccounts().includes(alias)) {
|
|
150
152
|
console.log(' ⚠ Admin scopes included — this account will be granted Workspace admin access.');
|
|
151
153
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
162
|
-
res.end(`Authorization denied: ${error}`);
|
|
163
|
-
server.close();
|
|
164
|
-
server.closeAllConnections();
|
|
165
|
-
reject(new Error(`Authorization denied: ${error}`));
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
const code = qs.get('code');
|
|
169
|
-
if (!code) {
|
|
170
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
171
|
-
res.end('No authorization code received.');
|
|
172
|
-
server.close();
|
|
173
|
-
server.closeAllConnections();
|
|
174
|
-
reject(new Error('No authorization code received'));
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
const returnedState = qs.get('state');
|
|
178
|
-
if (returnedState !== expectedState) {
|
|
179
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
180
|
-
res.end('State mismatch — possible CSRF attempt. Aborting.');
|
|
181
|
-
server.close();
|
|
182
|
-
server.closeAllConnections();
|
|
183
|
-
reject(new Error('OAuth state token mismatch'));
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
const { tokens } = await oauth2Client.getToken(code);
|
|
187
|
-
writeToken(alias, tokens);
|
|
188
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
189
|
-
res.end('<h2>Authentication successful!</h2><p>You can close this tab.</p>');
|
|
190
|
-
server.close();
|
|
191
|
-
server.closeAllConnections();
|
|
192
|
-
console.log(`Token saved (encrypted) for ${alias}.`);
|
|
193
|
-
console.log('Next: authenticate your other aliases, then verify with: mcp-google-multi config check');
|
|
194
|
-
resolve();
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
catch (e) {
|
|
198
|
-
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
199
|
-
res.end('Internal error during authentication.');
|
|
200
|
-
server.close();
|
|
201
|
-
server.closeAllConnections();
|
|
202
|
-
reject(e);
|
|
203
|
-
}
|
|
204
|
-
})
|
|
205
|
-
// Bind to loopback only — never expose the OAuth callback to the local network.
|
|
206
|
-
.listen(4242, '127.0.0.1', () => {
|
|
207
|
-
// Always print the URL first: the browser launch is best-effort and
|
|
208
|
-
// silently does nothing on headless/SSH sessions.
|
|
209
|
-
console.log(`Opening your browser to authorize "${alias}". If nothing opens, visit:\n${authorizeUrl}`);
|
|
210
|
-
openUrl(authorizeUrl);
|
|
211
|
-
});
|
|
212
|
-
server.on('error', (err) => {
|
|
213
|
-
if (err.code === 'EADDRINUSE') {
|
|
214
|
-
console.error('Port 4242 is already in use. Close the process using it and retry.');
|
|
215
|
-
process.exit(1);
|
|
216
|
-
}
|
|
217
|
-
reject(err);
|
|
218
|
-
});
|
|
219
|
-
});
|
|
154
|
+
// Always print the URL first: the browser launch is best-effort and
|
|
155
|
+
// silently does nothing on headless/SSH sessions.
|
|
156
|
+
console.log(`Opening your browser to authorize "${alias}". If nothing opens, visit:\n${authorizeUrl}`);
|
|
157
|
+
openUrl(authorizeUrl);
|
|
158
|
+
const tokens = await loop.finish(oauth2Client, expectedState);
|
|
159
|
+
writeToken(alias, tokens);
|
|
160
|
+
console.log(`Token saved (encrypted) for ${alias}.`);
|
|
161
|
+
console.log(TESTING_MODE_WARNING);
|
|
162
|
+
console.log('Next: authenticate your other aliases, then verify with: mcp-google-multi config check');
|
|
220
163
|
}
|
package/dist/client.js
CHANGED
|
@@ -14,7 +14,9 @@ export async function getClient(account) {
|
|
|
14
14
|
throw new Error('GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set. ' +
|
|
15
15
|
'Check that .env exists in the project root or pass them as env vars.');
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
// Redirect URI is unused on the refresh-token grant; consent flows bind an
|
|
18
|
+
// ephemeral loopback port at auth time (oauth-consent.ts).
|
|
19
|
+
const oauth2Client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost/oauth2callback');
|
|
18
20
|
const tokenData = readToken(account);
|
|
19
21
|
if (!tokenData) {
|
|
20
22
|
throw new Error(`No token found for account "${account}" (${config.email}). ${reauthHint(account)}`);
|
package/dist/oauth-consent.d.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { OAuth2Client } from 'googleapis-common';
|
|
2
|
-
export declare const LOOPBACK_PORT = 4242;
|
|
3
|
-
export declare const LOOPBACK_REDIRECT = "http://localhost:4242/oauth2callback";
|
|
4
2
|
/** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
|
|
5
3
|
export declare class ClientCredentialsMissingError extends Error {
|
|
6
4
|
constructor();
|
|
@@ -14,15 +12,32 @@ export declare class ConsentTimeoutError extends Error {
|
|
|
14
12
|
export declare class ConsentDeniedError extends Error {
|
|
15
13
|
constructor(reason: string);
|
|
16
14
|
}
|
|
15
|
+
export declare const TESTING_MODE_WARNING: string;
|
|
17
16
|
export declare function hasClientCredentials(): boolean;
|
|
18
|
-
/** Build the loopback OAuth2 client from env credentials (throws if unset). */
|
|
19
|
-
export declare function buildConsentClient(): OAuth2Client;
|
|
20
17
|
/**
|
|
21
|
-
*
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
24
|
-
* times out so a stalled consent can't wedge a tool call.
|
|
18
|
+
* Build the loopback OAuth2 client from env credentials (throws if unset).
|
|
19
|
+
* `redirect` comes from openLoopbackConsent(): the port is only known once the
|
|
20
|
+
* listener is bound.
|
|
25
21
|
*/
|
|
26
|
-
export declare function
|
|
22
|
+
export declare function buildConsentClient(redirect: string): OAuth2Client;
|
|
23
|
+
export interface LoopbackConsent {
|
|
24
|
+
/** `http://localhost:<ephemeral port>/oauth2callback` — build the auth URL from this. */
|
|
25
|
+
redirect: string;
|
|
26
|
+
/**
|
|
27
|
+
* Await the OAuth redirect, validate the CSRF `state` (RFC 6749 §10.12), and
|
|
28
|
+
* exchange the code. Returns the token set for the caller to persist.
|
|
29
|
+
*/
|
|
30
|
+
finish(client: OAuth2Client, expectedState: string): Promise<Record<string, unknown>>;
|
|
31
|
+
/**
|
|
32
|
+
* Abandon the consent: shuts the listener down WITHOUT settling finish(), so
|
|
33
|
+
* an abandoned flow can never surface as an unhandled rejection later.
|
|
34
|
+
*/
|
|
35
|
+
close(): void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Bind the loopback listener first (ephemeral port), so the redirect URI is
|
|
39
|
+
* known before the auth URL is built and the callback can't race the browser.
|
|
40
|
+
*/
|
|
41
|
+
export declare function openLoopbackConsent(opts?: {
|
|
27
42
|
timeoutMs?: number;
|
|
28
|
-
}): Promise<
|
|
43
|
+
}): Promise<LoopbackConsent>;
|
package/dist/oauth-consent.js
CHANGED
|
@@ -2,13 +2,11 @@ import { OAuth2Client } from 'googleapis-common';
|
|
|
2
2
|
import http from 'node:http';
|
|
3
3
|
import { URL } from 'node:url';
|
|
4
4
|
// Shared loopback OAuth consent (leg C of cc-auth), used by the account wizard
|
|
5
|
-
// (B7)
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
export const LOOPBACK_PORT = 4242;
|
|
11
|
-
export const LOOPBACK_REDIRECT = `http://localhost:${LOOPBACK_PORT}/oauth2callback`;
|
|
5
|
+
// (B7) and the `auth --account` CLI. Live-server-safe: typed errors instead of
|
|
6
|
+
// process.exit, and a timeout so a never-completed consent can't wedge a tool
|
|
7
|
+
// call forever. The listener binds an EPHEMERAL loopback port (RFC 8252 §7.3;
|
|
8
|
+
// Google Desktop clients accept any http://localhost:<port> redirect), so a
|
|
9
|
+
// second local process on a fixed port can never break auth.
|
|
12
10
|
/** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
|
|
13
11
|
export class ClientCredentialsMissingError extends Error {
|
|
14
12
|
constructor() {
|
|
@@ -17,7 +15,7 @@ export class ClientCredentialsMissingError extends Error {
|
|
|
17
15
|
}
|
|
18
16
|
export class LoopbackPortInUseError extends Error {
|
|
19
17
|
constructor() {
|
|
20
|
-
super(
|
|
18
|
+
super('E_LOOPBACK_PORT_IN_USE: the loopback consent listener could not bind a port; retry.');
|
|
21
19
|
}
|
|
22
20
|
}
|
|
23
21
|
export class ConsentTimeoutError extends Error {
|
|
@@ -30,80 +28,128 @@ export class ConsentDeniedError extends Error {
|
|
|
30
28
|
super(`E_CONSENT_DENIED: ${reason}`);
|
|
31
29
|
}
|
|
32
30
|
}
|
|
31
|
+
// Surfaced after every successful consent: the expiry is invisible until the
|
|
32
|
+
// token dies a week later as reauth_required, so the moment of success is the
|
|
33
|
+
// one place the warning is guaranteed to be seen. Full walkthrough in
|
|
34
|
+
// docs/google-cloud-setup.md.
|
|
35
|
+
export const TESTING_MODE_WARNING = 'Heads-up: while your OAuth client\'s Publishing status is "Testing", Google expires refresh tokens after 7 days (weekly re-auth for every account). ' +
|
|
36
|
+
'When your setup works, set it to "In production" at https://console.cloud.google.com/auth/audience. ' +
|
|
37
|
+
'No verification review is needed for personal use; see docs/google-cloud-setup.md.';
|
|
33
38
|
export function hasClientCredentials() {
|
|
34
39
|
return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
|
35
40
|
}
|
|
36
|
-
/**
|
|
37
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Build the loopback OAuth2 client from env credentials (throws if unset).
|
|
43
|
+
* `redirect` comes from openLoopbackConsent(): the port is only known once the
|
|
44
|
+
* listener is bound.
|
|
45
|
+
*/
|
|
46
|
+
export function buildConsentClient(redirect) {
|
|
38
47
|
if (!hasClientCredentials())
|
|
39
48
|
throw new ClientCredentialsMissingError();
|
|
40
|
-
return new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET,
|
|
49
|
+
return new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, redirect);
|
|
41
50
|
}
|
|
42
51
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* the caller to persist. Never process.exit's (safe inside a live server) and
|
|
46
|
-
* times out so a stalled consent can't wedge a tool call.
|
|
52
|
+
* Bind the loopback listener first (ephemeral port), so the redirect URI is
|
|
53
|
+
* known before the auth URL is built and the callback can't race the browser.
|
|
47
54
|
*/
|
|
48
|
-
export function
|
|
55
|
+
export function openLoopbackConsent(opts = {}) {
|
|
49
56
|
const timeoutMs = opts.timeoutMs ?? 5 * 60_000;
|
|
50
|
-
return new Promise((
|
|
57
|
+
return new Promise((resolveOpen, rejectOpen) => {
|
|
58
|
+
let redirect = '';
|
|
51
59
|
let timer;
|
|
60
|
+
let closed = false;
|
|
61
|
+
let pending;
|
|
62
|
+
const shutdown = () => {
|
|
63
|
+
if (closed)
|
|
64
|
+
return;
|
|
65
|
+
closed = true;
|
|
66
|
+
if (timer)
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
server.close();
|
|
69
|
+
server.closeAllConnections();
|
|
70
|
+
};
|
|
71
|
+
const fail = (err) => {
|
|
72
|
+
const p = pending;
|
|
73
|
+
pending = undefined;
|
|
74
|
+
shutdown();
|
|
75
|
+
p?.reject(err);
|
|
76
|
+
};
|
|
52
77
|
const server = http.createServer(async (req, res) => {
|
|
53
78
|
if (!req.url || !req.url.startsWith('/oauth2callback')) {
|
|
54
79
|
res.writeHead(404).end();
|
|
55
80
|
return;
|
|
56
81
|
}
|
|
82
|
+
if (!pending) {
|
|
83
|
+
// Only reachable if something other than our own browser launch hit
|
|
84
|
+
// the port before finish() armed the exchange; nothing to do with it.
|
|
85
|
+
res.writeHead(503).end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const p = pending;
|
|
57
89
|
const done = (code, body) => {
|
|
58
90
|
res.writeHead(code, { 'Content-Type': 'text/html' });
|
|
59
91
|
res.end(body);
|
|
60
|
-
if (timer)
|
|
61
|
-
clearTimeout(timer);
|
|
62
|
-
server.close();
|
|
63
|
-
server.closeAllConnections();
|
|
64
92
|
};
|
|
65
93
|
try {
|
|
66
|
-
const qs = new URL(req.url,
|
|
94
|
+
const qs = new URL(req.url, redirect).searchParams;
|
|
67
95
|
const error = qs.get('error');
|
|
68
96
|
if (error) {
|
|
69
97
|
done(400, `<p>Authorization denied: ${error}</p>`);
|
|
70
|
-
|
|
98
|
+
fail(new ConsentDeniedError(error));
|
|
71
99
|
return;
|
|
72
100
|
}
|
|
73
101
|
const returnedState = qs.get('state');
|
|
74
|
-
if (returnedState !== expectedState) {
|
|
75
|
-
done(400, '<p>State mismatch
|
|
76
|
-
|
|
102
|
+
if (returnedState !== p.expectedState) {
|
|
103
|
+
done(400, '<p>State mismatch: possible CSRF attempt. Aborting.</p>');
|
|
104
|
+
fail(new Error('E_OAUTH_STATE_MISMATCH: OAuth state token mismatch'));
|
|
77
105
|
return;
|
|
78
106
|
}
|
|
79
107
|
const code = qs.get('code');
|
|
80
108
|
if (!code) {
|
|
81
109
|
done(400, '<p>No authorization code received.</p>');
|
|
82
|
-
|
|
110
|
+
fail(new ConsentDeniedError('no authorization code received'));
|
|
83
111
|
return;
|
|
84
112
|
}
|
|
85
|
-
const { tokens } = await client.getToken(code);
|
|
113
|
+
const { tokens } = await p.client.getToken(code);
|
|
86
114
|
done(200, '<h2>Authentication successful!</h2><p>You can close this tab.</p>');
|
|
87
|
-
|
|
115
|
+
pending = undefined;
|
|
116
|
+
shutdown();
|
|
117
|
+
p.resolve(tokens);
|
|
88
118
|
}
|
|
89
119
|
catch (e) {
|
|
90
120
|
done(500, '<p>Internal error during authentication.</p>');
|
|
91
|
-
|
|
121
|
+
fail(e);
|
|
92
122
|
}
|
|
93
123
|
});
|
|
94
124
|
server.on('error', (err) => {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
125
|
+
const mapped = err.code === 'EADDRINUSE' ? new LoopbackPortInUseError() : err;
|
|
126
|
+
if (pending) {
|
|
127
|
+
fail(mapped);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
shutdown();
|
|
131
|
+
rejectOpen(mapped);
|
|
132
|
+
}
|
|
98
133
|
});
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
reject(new ConsentTimeoutError());
|
|
104
|
-
}, timeoutMs);
|
|
134
|
+
// Bind to loopback only — never expose the OAuth callback to the local network.
|
|
135
|
+
server.listen(0, '127.0.0.1', () => {
|
|
136
|
+
redirect = `http://localhost:${server.address().port}/oauth2callback`;
|
|
137
|
+
timer = setTimeout(() => fail(new ConsentTimeoutError()), timeoutMs);
|
|
105
138
|
// unref so a pending consent never keeps the process alive on its own.
|
|
106
139
|
timer.unref?.();
|
|
140
|
+
resolveOpen({
|
|
141
|
+
redirect,
|
|
142
|
+
finish(client, expectedState) {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
if (closed) {
|
|
145
|
+
reject(new ConsentTimeoutError());
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
pending = { client, expectedState, resolve, reject };
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
close: shutdown,
|
|
152
|
+
});
|
|
107
153
|
});
|
|
108
154
|
});
|
|
109
155
|
}
|
|
@@ -27,6 +27,16 @@ export type AddValidation = {
|
|
|
27
27
|
/** Validate the collected form against the alias rules, dup check, and the
|
|
28
28
|
* bundle catalog. Pure (no I/O) for unit testing. */
|
|
29
29
|
export declare function validateAddForm(input: Partial<AddForm>, existingAliases: string[]): AddValidation;
|
|
30
|
+
/** Map direct tool arguments onto the elicitation form shape: the argument-
|
|
31
|
+
* mode fallback for clients without form elicitation. All bundle picks travel
|
|
32
|
+
* through otherBundles, which validateAddForm resolves and validates. Pure. */
|
|
33
|
+
export declare function argsToAddForm(a: {
|
|
34
|
+
alias?: string;
|
|
35
|
+
email?: string;
|
|
36
|
+
bundles?: string;
|
|
37
|
+
allBundles?: boolean;
|
|
38
|
+
admin?: boolean;
|
|
39
|
+
}): Partial<AddForm>;
|
|
30
40
|
/** Scopes requested by the profile but NOT granted at consent (granular
|
|
31
41
|
* consent / unchecked bundles). Pure. */
|
|
32
42
|
export declare function scopeGrantDiff(requested: string[], grantedScope: string | undefined): string[];
|
|
@@ -5,7 +5,8 @@ import { writeToken } from '../token-store.js';
|
|
|
5
5
|
import { resolveScopesForAccount } from '../auth.js';
|
|
6
6
|
import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from '../scope-catalog.js';
|
|
7
7
|
import { openUrl } from '../open-url.js';
|
|
8
|
-
import {
|
|
8
|
+
import { coerceBoolean } from './_coerce.js';
|
|
9
|
+
import { buildConsentClient, openLoopbackConsent, hasClientCredentials, TESTING_MODE_WARNING, } from '../oauth-consent.js';
|
|
9
10
|
import { detectClients, buildServerEntry, renderInstruction, applyFileEntry, resolveMode, DEFAULT_SERVER_NAME, } from '../client-config.js';
|
|
10
11
|
// B7: the elicitation-driven account_add / account_reauth wizard. It rebuilds
|
|
11
12
|
// interactive account management on the mutable config.json registry so a
|
|
@@ -70,6 +71,18 @@ export function validateAddForm(input, existingAliases) {
|
|
|
70
71
|
}
|
|
71
72
|
return { ok: true, alias, email, bundles, admin: input.admin === true };
|
|
72
73
|
}
|
|
74
|
+
/** Map direct tool arguments onto the elicitation form shape: the argument-
|
|
75
|
+
* mode fallback for clients without form elicitation. All bundle picks travel
|
|
76
|
+
* through otherBundles, which validateAddForm resolves and validates. Pure. */
|
|
77
|
+
export function argsToAddForm(a) {
|
|
78
|
+
return {
|
|
79
|
+
alias: a.alias ?? '',
|
|
80
|
+
email: a.email ?? '',
|
|
81
|
+
allBundles: a.allBundles === true,
|
|
82
|
+
otherBundles: a.bundles ?? '',
|
|
83
|
+
admin: a.admin === true,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
73
86
|
/** Scopes requested by the profile but NOT granted at consent (granular
|
|
74
87
|
* consent / unchecked bundles). Pure. */
|
|
75
88
|
export function scopeGrantDiff(requested, grantedScope) {
|
|
@@ -103,19 +116,22 @@ async function runConsent(server, alias) {
|
|
|
103
116
|
const cfg = getAccountSet().configs[alias];
|
|
104
117
|
if (!cfg)
|
|
105
118
|
return { ok: false, text: `E_VALIDATION: account "${alias}" is not in the live registry (env-sourced accounts are not editable here).` };
|
|
106
|
-
|
|
119
|
+
// Bind the ephemeral loopback listener BEFORE building the auth URL: the
|
|
120
|
+
// redirect URI needs the assigned port, and listening first means the
|
|
121
|
+
// callback can't race the browser.
|
|
122
|
+
const loop = await openLoopbackConsent();
|
|
123
|
+
const client = buildConsentClient(loop.redirect);
|
|
107
124
|
const expectedState = randomBytes(32).toString('hex');
|
|
108
125
|
const scopes = resolveScopesForAccount(alias);
|
|
109
126
|
const url = client.generateAuthUrl({ access_type: 'offline', prompt: 'consent', scope: scopes, login_hint: cfg.email, state: expectedState });
|
|
110
|
-
|
|
111
|
-
// redirect. Any startup error (e.g. port in use) surfaces synchronously.
|
|
112
|
-
const consent = awaitLoopbackConsent(client, expectedState);
|
|
127
|
+
const consent = loop.finish(client, expectedState);
|
|
113
128
|
const caps = server.server.getClientCapabilities?.();
|
|
114
129
|
let opened = false;
|
|
115
130
|
if (caps?.elicitation?.url) {
|
|
116
131
|
try {
|
|
117
132
|
const r = await server.server.elicitInput({ mode: 'url', message: `Authorize the "${alias}" Google account in your browser.`, url });
|
|
118
133
|
if (r.action !== 'accept') {
|
|
134
|
+
loop.close();
|
|
119
135
|
return { ok: false, text: 'confirmation_declined: consent was cancelled; the account row was kept but no token was stored (doctor will show it as "missing").' };
|
|
120
136
|
}
|
|
121
137
|
opened = true;
|
|
@@ -138,9 +154,10 @@ async function runConsent(server, alias) {
|
|
|
138
154
|
return { ok: true, missing: scopeGrantDiff(scopes, typeof tokens.scope === 'string' ? tokens.scope : undefined) };
|
|
139
155
|
}
|
|
140
156
|
function s4Text(alias, missing) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
157
|
+
const outcome = missing.length === 0
|
|
158
|
+
? `✔ "${alias}" authenticated; all requested scopes granted. It is now usable without a restart.`
|
|
159
|
+
: `⚠ "${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.`;
|
|
160
|
+
return `${outcome}\n${TESTING_MODE_WARNING}`;
|
|
144
161
|
}
|
|
145
162
|
const REQUIRES_INTERACTION = { 'anthropic/requiresUserInteraction': true };
|
|
146
163
|
export function registerAccountWizardTools(registry, server) {
|
|
@@ -151,9 +168,15 @@ export function registerAccountWizardTools(registry, server) {
|
|
|
151
168
|
registerMeta('account_add', {
|
|
152
169
|
_meta: REQUIRES_INTERACTION,
|
|
153
170
|
annotations: { openWorldHint: true },
|
|
154
|
-
description: 'Add a new Google account
|
|
155
|
-
inputSchema: {
|
|
156
|
-
|
|
171
|
+
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).',
|
|
172
|
+
inputSchema: {
|
|
173
|
+
alias: z.string().optional().describe('Account alias (letters, digits, _ or -). Pass with email to add directly, skipping the form.'),
|
|
174
|
+
email: z.string().optional().describe("The account's Google address (used as the login hint)"),
|
|
175
|
+
bundles: z.string().optional().describe('Optional scope bundles, comma-separated (e.g. "forms,chat"); blank = base scopes only'),
|
|
176
|
+
allBundles: coerceBoolean.optional().describe('Grant every optional bundle (biggest consent screen); overrides bundles'),
|
|
177
|
+
admin: coerceBoolean.optional().describe('Grant Workspace admin scopes (super-admin accounts only)'),
|
|
178
|
+
},
|
|
179
|
+
}, async (args) => {
|
|
157
180
|
try {
|
|
158
181
|
// Env-sourced registry: GOOGLE_ACCOUNTS is the exclusive source and
|
|
159
182
|
// config.json accounts are ignored, so a wizard add would be a phantom
|
|
@@ -164,16 +187,26 @@ export function registerAccountWizardTools(registry, server) {
|
|
|
164
187
|
if (!hasClientCredentials()) {
|
|
165
188
|
return textResult('E_CLIENT_CREDENTIALS_MISSING: GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET are not set. Run the `setup` prompt (/mcp__google-multi__setup) to create an OAuth client, then set them.', true);
|
|
166
189
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
190
|
+
// S1: collect the registry row. Arguments win over the form so the
|
|
191
|
+
// wizard still works in clients without form elicitation (where the
|
|
192
|
+
// interactive path used to dead-end).
|
|
193
|
+
const a = (args ?? {});
|
|
194
|
+
let input;
|
|
195
|
+
if (a.alias?.trim() || a.email?.trim()) {
|
|
196
|
+
input = argsToAddForm(a);
|
|
170
197
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
198
|
+
else {
|
|
199
|
+
const caps = server.server.getClientCapabilities?.();
|
|
200
|
+
if (!caps?.elicitation?.form) {
|
|
201
|
+
return textResult('E_NO_FORM_ELICITATION: this client does not support the interactive form. Call account_add again with arguments instead, e.g. {"alias": "work", "email": "you@example.com"} (optional: "bundles" as a comma-separated list, "allBundles": true, "admin": true).', true);
|
|
202
|
+
}
|
|
203
|
+
const form = await server.server.elicitInput({ message: 'Add a Google account', requestedSchema: addFormSchema() });
|
|
204
|
+
if (form.action !== 'accept') {
|
|
205
|
+
return textResult('confirmation_declined: no account was added.');
|
|
206
|
+
}
|
|
207
|
+
input = form.content ?? {};
|
|
175
208
|
}
|
|
176
|
-
const validated = validateAddForm(
|
|
209
|
+
const validated = validateAddForm(input, getAccountSet().aliases);
|
|
177
210
|
if (!validated.ok)
|
|
178
211
|
return textResult(`${validated.slug}: ${validated.message}`, true);
|
|
179
212
|
// S2: atomic write + make the alias callable without a restart (BR3).
|
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.18",
|
|
4
4
|
"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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|