mcp-google-multi 6.0.0-alpha.57 → 6.0.0-alpha.59
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/fs-atomic.d.ts +11 -0
- package/dist/fs-atomic.js +219 -28
- package/dist/index.js +17 -10
- package/dist/mcp-token.d.ts +12 -0
- package/dist/mcp-token.js +25 -1
- package/dist/oauth-as.d.ts +36 -7
- package/dist/oauth-as.js +61 -17
- package/dist/reauth-hint.d.ts +1 -1
- package/dist/reauth-hint.js +8 -8
- package/package.json +1 -1
package/dist/fs-atomic.d.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/** Which process instance holds a lock: PID namespace id, start time in
|
|
2
|
+
* clock ticks since boot, and a boot id prefix. A part this process cannot
|
|
3
|
+
* read is `host` or `0` (all three without /proc). */
|
|
4
|
+
interface LockIdentity {
|
|
5
|
+
ns: string;
|
|
6
|
+
start: string;
|
|
7
|
+
boot: string;
|
|
8
|
+
}
|
|
9
|
+
/** Test hook: act as a process with this identity (undefined restores the real one). */
|
|
10
|
+
export declare function __setLockIdentityForTest(identity?: LockIdentity): void;
|
|
1
11
|
export declare function withFileLock<T>(absPath: string, fn: () => T, label?: string): T;
|
|
2
12
|
export declare function atomicWriteFileSync(absPath: string, contents: string, mode?: number): void;
|
|
3
13
|
export declare function atomicWriteWithLock(absPath: string, contents: string, mode?: number): void;
|
|
14
|
+
export {};
|
package/dist/fs-atomic.js
CHANGED
|
@@ -5,23 +5,216 @@ const LOCK_TIMEOUT_MS = 5_000;
|
|
|
5
5
|
const LOCK_RETRY_MS = 10;
|
|
6
6
|
const RENAME_ATTEMPTS = 5;
|
|
7
7
|
const RENAME_RETRY_MS = 20;
|
|
8
|
+
// A holder this process cannot identify (another PID namespace, another boot,
|
|
9
|
+
// or no /proc) is dead only once its lock is this old.
|
|
10
|
+
const LOCK_LEASE_MS = 60_000;
|
|
11
|
+
// Breaking a lock takes microseconds, so an older breaker file was left by a crash.
|
|
12
|
+
const BREAKER_STALE_MS = 2_000;
|
|
13
|
+
const PROCESS_START_MS = performance.timeOrigin;
|
|
14
|
+
const OWNER_NAME = /^(\d+)\.(\d+|host)\.(\d+)\.([0-9a-f]{8}|0)\.[0-9a-f]{12}\.owner$/;
|
|
15
|
+
function startOf(pid) {
|
|
16
|
+
try {
|
|
17
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
18
|
+
const start = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19];
|
|
19
|
+
return /^\d+$/.test(start ?? '') ? start : undefined;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function selfIdentity() {
|
|
26
|
+
const identity = { ns: 'host', start: '0', boot: '0' };
|
|
27
|
+
try {
|
|
28
|
+
identity.ns = /^pid:\[(\d+)\]$/.exec(fs.readlinkSync('/proc/self/ns/pid'))?.[1] ?? 'host';
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// no /proc
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const boot = fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').replace(/[^0-9a-f]/g, '').slice(0, 8);
|
|
35
|
+
if (boot.length === 8)
|
|
36
|
+
identity.boot = boot;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// no /proc
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
// A /proc mounted for another PID namespace describes other processes,
|
|
43
|
+
// so no pid can be looked up in it.
|
|
44
|
+
if (fs.readlinkSync('/proc/self') === String(process.pid))
|
|
45
|
+
identity.start = startOf('self') ?? '0';
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// no /proc
|
|
49
|
+
}
|
|
50
|
+
return identity;
|
|
51
|
+
}
|
|
52
|
+
let SELF = selfIdentity();
|
|
53
|
+
/** Test hook: act as a process with this identity (undefined restores the real one). */
|
|
54
|
+
export function __setLockIdentityForTest(identity) {
|
|
55
|
+
SELF = identity ?? selfIdentity();
|
|
56
|
+
}
|
|
8
57
|
function sleep(ms) {
|
|
9
58
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
10
59
|
}
|
|
60
|
+
function sameFile(a, b) {
|
|
61
|
+
return a.ino === b.ino && a.dev === b.dev;
|
|
62
|
+
}
|
|
63
|
+
function isEnoent(error) {
|
|
64
|
+
return error.code === 'ENOENT';
|
|
65
|
+
}
|
|
66
|
+
// Undefined when the lock changed hands while it was being read.
|
|
67
|
+
function readHolder(lock) {
|
|
68
|
+
const before = fs.lstatSync(lock, { bigint: true });
|
|
69
|
+
const body = before.isFile() ? fs.readFileSync(lock, 'utf8') : '';
|
|
70
|
+
let owner;
|
|
71
|
+
if (before.isFile()) {
|
|
72
|
+
const dir = path.dirname(lock);
|
|
73
|
+
const prefix = `${path.basename(lock)}.`;
|
|
74
|
+
for (const name of fs.readdirSync(dir)) {
|
|
75
|
+
if (!name.startsWith(prefix))
|
|
76
|
+
continue;
|
|
77
|
+
const m = OWNER_NAME.exec(name.slice(prefix.length));
|
|
78
|
+
if (!m || m[1] !== body)
|
|
79
|
+
continue;
|
|
80
|
+
const ownerPath = path.join(dir, name);
|
|
81
|
+
try {
|
|
82
|
+
if (sameFile(fs.lstatSync(ownerPath, { bigint: true }), before)) {
|
|
83
|
+
owner = { path: ownerPath, ns: m[2], start: m[3], boot: m[4] };
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (!isEnoent(error))
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const after = fs.lstatSync(lock, { bigint: true });
|
|
94
|
+
if (!sameFile(before, after))
|
|
95
|
+
return undefined;
|
|
96
|
+
return { body, ino: after.ino, dev: after.dev, mtimeNs: after.mtimeNs, mtimeMs: Number(after.mtimeNs) / 1e6, owner };
|
|
97
|
+
}
|
|
98
|
+
// The owner file's random name tells two holders apart even when the second
|
|
99
|
+
// lock reuses the first one's freed inode.
|
|
100
|
+
function sameHolder(a, b) {
|
|
101
|
+
return sameFile(a, b) && a.body === b.body && a.mtimeNs === b.mtimeNs && a.owner?.path === b.owner?.path;
|
|
102
|
+
}
|
|
103
|
+
function probeDead(pid) {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, 0);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
const code = error.code;
|
|
110
|
+
// EPERM: PID exists but is not signalable (another user's process).
|
|
111
|
+
if (code === 'ESRCH')
|
|
112
|
+
return true;
|
|
113
|
+
if (code !== 'EPERM')
|
|
114
|
+
throw error;
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function holderDead(holder, now) {
|
|
119
|
+
const pid = Number(holder.body);
|
|
120
|
+
if (!(Number.isSafeInteger(pid) && pid > 0))
|
|
121
|
+
return true;
|
|
122
|
+
const leaseOver = now - holder.mtimeMs >= LOCK_LEASE_MS;
|
|
123
|
+
const owner = holder.owner;
|
|
124
|
+
// No owner file: an older writer, whose namespace is unknown.
|
|
125
|
+
if (!owner)
|
|
126
|
+
return probeDead(pid) || leaseOver;
|
|
127
|
+
if (owner.ns !== SELF.ns || owner.boot !== SELF.boot)
|
|
128
|
+
return leaseOver;
|
|
129
|
+
// Two unreadable parts compare equal without naming the same namespace or
|
|
130
|
+
// boot, so only a fully known identity is looked up by start time.
|
|
131
|
+
if (SELF.ns !== 'host' && SELF.boot !== '0' && SELF.start !== '0' && owner.start !== '0') {
|
|
132
|
+
// A live namespace id is never shared, so the recorded pid is ours to
|
|
133
|
+
// look up: a restarted container reuses both the id and the pid, but not
|
|
134
|
+
// the start time.
|
|
135
|
+
const start = startOf(pid);
|
|
136
|
+
if (start !== undefined)
|
|
137
|
+
return start !== owner.start;
|
|
138
|
+
return probeDead(pid) || leaseOver;
|
|
139
|
+
}
|
|
140
|
+
if (pid === process.pid)
|
|
141
|
+
return holder.mtimeMs < PROCESS_START_MS;
|
|
142
|
+
return probeDead(pid) || leaseOver;
|
|
143
|
+
}
|
|
144
|
+
// One waiter at a time breaks a dead lock: two breakers could otherwise both
|
|
145
|
+
// see the dead holder, and the second would unlink the first one's new lock.
|
|
146
|
+
// False when another waiter is breaking it.
|
|
147
|
+
function breakLock(lock, holder) {
|
|
148
|
+
const breaker = `${lock}.break`;
|
|
149
|
+
// A token, not the inode: a breaker created after ours was removed can
|
|
150
|
+
// get our freed inode.
|
|
151
|
+
const token = `${process.pid}.${randomBytes(6).toString('hex')}`;
|
|
152
|
+
try {
|
|
153
|
+
fs.writeFileSync(breaker, token, { mode: 0o600, flag: 'wx' });
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (error.code !== 'EEXIST')
|
|
157
|
+
throw error;
|
|
158
|
+
clearStaleBreaker(breaker);
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const current = readHolder(lock);
|
|
163
|
+
if (current && sameHolder(current, holder)) {
|
|
164
|
+
fs.rmSync(lock, { force: true });
|
|
165
|
+
if (holder.owner)
|
|
166
|
+
fs.rmSync(holder.owner.path, { force: true });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (!isEnoent(error))
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
releaseBreaker(breaker, token);
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
// A symlink, or a breaker dated the other side of a clock step, would
|
|
179
|
+
// otherwise block every break.
|
|
180
|
+
function clearStaleBreaker(breaker) {
|
|
181
|
+
try {
|
|
182
|
+
const st = fs.lstatSync(breaker);
|
|
183
|
+
if (!st.isFile() || Math.abs(Date.now() - st.mtimeMs) >= BREAKER_STALE_MS)
|
|
184
|
+
fs.rmSync(breaker, { force: true });
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (!isEnoent(error))
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function releaseBreaker(breaker, token) {
|
|
192
|
+
try {
|
|
193
|
+
if (fs.readFileSync(breaker, 'utf8') === token)
|
|
194
|
+
fs.rmSync(breaker, { force: true });
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (!isEnoent(error))
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
11
201
|
// Hardlink-based advisory lock, generalized path-keyed from the token store's
|
|
12
|
-
// alias-keyed original
|
|
13
|
-
//
|
|
202
|
+
// alias-keyed original. The lock body is the bare pid, as older readers expect;
|
|
203
|
+
// the owner file stays linked while held and names the holder's process
|
|
204
|
+
// instance. See docs/internals.md.
|
|
14
205
|
export function withFileLock(absPath, fn, label = `lock: ${absPath}`) {
|
|
15
206
|
const dir = path.dirname(absPath);
|
|
16
207
|
const lock = path.join(dir, `.${path.basename(absPath)}.lock`);
|
|
17
|
-
const ownerFile = `${lock}.${process.pid}.${randomBytes(6).toString('hex')}.owner`;
|
|
208
|
+
const ownerFile = `${lock}.${process.pid}.${SELF.ns}.${SELF.start}.${SELF.boot}.${randomBytes(6).toString('hex')}.owner`;
|
|
18
209
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
19
210
|
fs.writeFileSync(ownerFile, String(process.pid), { mode: 0o600, flag: 'wx' });
|
|
20
211
|
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
212
|
+
let linked = false;
|
|
21
213
|
try {
|
|
22
214
|
while (true) {
|
|
23
215
|
try {
|
|
24
216
|
fs.linkSync(ownerFile, lock);
|
|
217
|
+
linked = true;
|
|
25
218
|
break;
|
|
26
219
|
}
|
|
27
220
|
catch (error) {
|
|
@@ -29,33 +222,13 @@ export function withFileLock(absPath, fn, label = `lock: ${absPath}`) {
|
|
|
29
222
|
if (err.code !== 'EEXIST')
|
|
30
223
|
throw error;
|
|
31
224
|
try {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
let ownerDead = !(Number.isSafeInteger(owner) && owner > 0);
|
|
35
|
-
if (!ownerDead) {
|
|
36
|
-
try {
|
|
37
|
-
process.kill(owner, 0);
|
|
38
|
-
}
|
|
39
|
-
catch (ownerError) {
|
|
40
|
-
const code = ownerError.code;
|
|
41
|
-
// EPERM: PID exists but is not signalable (recycled by another
|
|
42
|
-
// user); treat as alive, never break a lock we cannot verify.
|
|
43
|
-
if (code === 'ESRCH')
|
|
44
|
-
ownerDead = true;
|
|
45
|
-
else if (code !== 'EPERM')
|
|
46
|
-
throw ownerError;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
if (ownerDead) {
|
|
50
|
-
if (fs.readFileSync(lock, 'utf8') === observedOwner)
|
|
51
|
-
fs.rmSync(lock, { force: true });
|
|
225
|
+
const holder = readHolder(lock);
|
|
226
|
+
if (holder && holderDead(holder, Date.now()) && breakLock(lock, holder))
|
|
52
227
|
continue;
|
|
53
|
-
}
|
|
54
228
|
}
|
|
55
229
|
catch (readError) {
|
|
56
|
-
if (readError
|
|
230
|
+
if (isEnoent(readError))
|
|
57
231
|
continue;
|
|
58
|
-
}
|
|
59
232
|
throw readError;
|
|
60
233
|
}
|
|
61
234
|
if (Date.now() >= deadline) {
|
|
@@ -66,14 +239,32 @@ export function withFileLock(absPath, fn, label = `lock: ${absPath}`) {
|
|
|
66
239
|
}
|
|
67
240
|
}
|
|
68
241
|
finally {
|
|
69
|
-
|
|
242
|
+
if (!linked)
|
|
243
|
+
fs.rmSync(ownerFile, { force: true });
|
|
70
244
|
}
|
|
245
|
+
let bound = false;
|
|
71
246
|
try {
|
|
247
|
+
// Some network and FUSE filesystems give each name of one file its own
|
|
248
|
+
// inode number; there the owner file cannot vouch for the lock.
|
|
249
|
+
bound = sameFile(fs.lstatSync(ownerFile, { bigint: true }), fs.lstatSync(lock, { bigint: true }));
|
|
72
250
|
return fn();
|
|
73
251
|
}
|
|
74
252
|
finally {
|
|
75
|
-
|
|
253
|
+
releaseLock(lock, ownerFile, bound);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// A holder whose lock was broken (its owner file removed with it) must not
|
|
257
|
+
// remove its successor's lock.
|
|
258
|
+
function releaseLock(lock, ownerFile, bound) {
|
|
259
|
+
try {
|
|
260
|
+
if (!bound || sameFile(fs.lstatSync(ownerFile, { bigint: true }), fs.lstatSync(lock, { bigint: true })))
|
|
261
|
+
fs.rmSync(lock, { force: true });
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (!isEnoent(error))
|
|
265
|
+
throw error;
|
|
76
266
|
}
|
|
267
|
+
fs.rmSync(ownerFile, { force: true });
|
|
77
268
|
}
|
|
78
269
|
export function atomicWriteFileSync(absPath, contents, mode = 0o600) {
|
|
79
270
|
const dir = path.dirname(absPath);
|
package/dist/index.js
CHANGED
|
@@ -250,9 +250,10 @@ async function main() {
|
|
|
250
250
|
const { jwtSecretFrom } = await import('./mcp-token.js');
|
|
251
251
|
const { resolveJwtKey, resolveMasterKeyForDispatch } = await import('./master-key.js');
|
|
252
252
|
const { OAuth2Client } = await import('googleapis-common');
|
|
253
|
-
const { writeToken } = await import('./token-store.js');
|
|
253
|
+
const { readToken, writeToken } = await import('./token-store.js');
|
|
254
254
|
const { resolveScopesForAccount } = await import('./auth.js');
|
|
255
255
|
const { ownerAuthUrlOptions } = await import('./oauth-consent.js');
|
|
256
|
+
const { scopeGrantDiff, setWizardHttpConsent } = await import('./tools/account-wizard.js');
|
|
256
257
|
const { configDir } = await import('./config-file.js');
|
|
257
258
|
const { getAccountSet } = await import('./accounts.js');
|
|
258
259
|
const googleClient = () => new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${httpCfg.publicUrl}/callback`);
|
|
@@ -275,19 +276,25 @@ async function main() {
|
|
|
275
276
|
return { tokens: flow === 'owner_gate' ? {} : tokens, email };
|
|
276
277
|
},
|
|
277
278
|
writeToken: (alias, tokens) => writeToken(alias, tokens),
|
|
279
|
+
missingScopes: (alias, granted) => scopeGrantDiff(resolveScopesForAccount(alias), granted),
|
|
280
|
+
hasToken: (alias) => {
|
|
281
|
+
try {
|
|
282
|
+
return readToken(alias) != null;
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return false; // undecryptable: nothing usable to keep
|
|
286
|
+
}
|
|
287
|
+
},
|
|
278
288
|
aliasEmail: (alias) => getAccountSet().configs[alias]?.email,
|
|
279
289
|
log: (l) => process.stderr.write(`[as] ${l}\n`),
|
|
280
290
|
});
|
|
281
|
-
// BV-1: over HTTP, a dead/missing per-account token surfaces a clickable
|
|
282
|
-
// re-auth link into the AS's alias_reauth flow instead of a
|
|
283
|
-
const {
|
|
284
|
-
|
|
285
|
-
// Wizard consent over HTTP: hand out the
|
|
291
|
+
// BV-1: over HTTP, a dead/missing per-account token surfaces a clickable,
|
|
292
|
+
// signed re-auth link into the AS's alias_reauth flow instead of a CLI hint.
|
|
293
|
+
const { setHttpReauthLink } = await import('./reauth-hint.js');
|
|
294
|
+
setHttpReauthLink((alias) => authServer.reauthLink(alias));
|
|
295
|
+
// Wizard consent over HTTP: hand out the same signed link instead of
|
|
286
296
|
// binding a loopback listener.
|
|
287
|
-
|
|
288
|
-
setWizardHttpConsent({
|
|
289
|
-
mintConsentUrl: (alias) => `${httpCfg.publicUrl}/authorize?flow=alias_reauth&alias=${encodeURIComponent(alias)}`,
|
|
290
|
-
});
|
|
297
|
+
setWizardHttpConsent({ mintConsentUrl: (alias) => authServer.reauthLink(alias) });
|
|
291
298
|
let httpTap;
|
|
292
299
|
if (httpMetrics) {
|
|
293
300
|
const { tapUsageMetrics } = await import('./metrics-tap.js');
|
package/dist/mcp-token.d.ts
CHANGED
|
@@ -46,6 +46,18 @@ export declare function signState(payload: StatePayload, base: string, secret: U
|
|
|
46
46
|
export declare function verifyState(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
|
|
47
47
|
jti: string;
|
|
48
48
|
}>;
|
|
49
|
+
/** A re-auth link is handed out in a tool error, so it has to survive until
|
|
50
|
+
* the user clicks it; an hour, not the 10-minute state TTL. */
|
|
51
|
+
export declare const REAUTH_LINK_TTL_SEC = 3600;
|
|
52
|
+
/** Query string of a server-issued alias_reauth link: the alias, an expiry and
|
|
53
|
+
* an HMAC over both. Synchronous, so the error hints that carry it stay so. */
|
|
54
|
+
export declare function signReauthLink(base: string, secret: Uint8Array, alias: string, nowSec: number): string;
|
|
55
|
+
/** The alias a link was issued for, or null when it is missing, forged or expired. */
|
|
56
|
+
export declare function verifyReauthLink(base: string, secret: Uint8Array, params: {
|
|
57
|
+
alias: string;
|
|
58
|
+
exp: string;
|
|
59
|
+
sig: string;
|
|
60
|
+
}, nowSec: number): string | null;
|
|
49
61
|
/** A pending-authorization artifact for the DCR consent interstitial (same
|
|
50
62
|
* shape as `state`, distinct purpose so the two can't be confused). */
|
|
51
63
|
export declare function signPending(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
|
package/dist/mcp-token.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// `state` and authorization code, the in-memory single-use replay guard, and
|
|
4
4
|
// the opaque rotated refresh-token store. Keyed by the provisioned MCP_JWT_KEY
|
|
5
5
|
// (B5), so tokens survive a restart as long as the key persists.
|
|
6
|
-
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
7
7
|
import { existsSync, readFileSync } from 'node:fs';
|
|
8
8
|
import { SignJWT, jwtVerify } from 'jose';
|
|
9
9
|
import { deriveKey, encryptToken, decryptToken } from './token-store.js';
|
|
@@ -59,6 +59,30 @@ export function signState(payload, base, secret, iat, ttlSec = STATE_TTL_DEFAULT
|
|
|
59
59
|
export async function verifyState(token, base, secret) {
|
|
60
60
|
return (await verifyArtifact(token, 'mcp_state', base, secret));
|
|
61
61
|
}
|
|
62
|
+
// --- alias_reauth link -------------------------------------------------------
|
|
63
|
+
/** A re-auth link is handed out in a tool error, so it has to survive until
|
|
64
|
+
* the user clicks it; an hour, not the 10-minute state TTL. */
|
|
65
|
+
export const REAUTH_LINK_TTL_SEC = 3600;
|
|
66
|
+
function reauthMac(base, secret, alias, exp) {
|
|
67
|
+
// The newline-separated input can never be a JWT signing input (two
|
|
68
|
+
// base64url segments and one dot), so the shared key stays unambiguous.
|
|
69
|
+
return createHmac('sha256', secret).update(`alias_reauth\n${base}\n${alias}\n${exp}`).digest();
|
|
70
|
+
}
|
|
71
|
+
/** Query string of a server-issued alias_reauth link: the alias, an expiry and
|
|
72
|
+
* an HMAC over both. Synchronous, so the error hints that carry it stay so. */
|
|
73
|
+
export function signReauthLink(base, secret, alias, nowSec) {
|
|
74
|
+
const exp = nowSec + REAUTH_LINK_TTL_SEC;
|
|
75
|
+
return `alias=${encodeURIComponent(alias)}&exp=${exp}&sig=${reauthMac(base, secret, alias, exp).toString('base64url')}`;
|
|
76
|
+
}
|
|
77
|
+
/** The alias a link was issued for, or null when it is missing, forged or expired. */
|
|
78
|
+
export function verifyReauthLink(base, secret, params, nowSec) {
|
|
79
|
+
const exp = Number(params.exp);
|
|
80
|
+
if (!params.alias || !/^\d+$/.test(params.exp) || exp < nowSec)
|
|
81
|
+
return null;
|
|
82
|
+
const want = reauthMac(base, secret, params.alias, exp);
|
|
83
|
+
const got = Buffer.from(params.sig, 'base64url');
|
|
84
|
+
return got.length === want.length && timingSafeEqual(got, want) ? params.alias : null;
|
|
85
|
+
}
|
|
62
86
|
/** A pending-authorization artifact for the DCR consent interstitial (same
|
|
63
87
|
* shape as `state`, distinct purpose so the two can't be confused). */
|
|
64
88
|
export function signPending(payload, base, secret, iat, ttlSec = STATE_TTL_DEFAULT) {
|
package/dist/oauth-as.d.ts
CHANGED
|
@@ -6,6 +6,10 @@ export declare const DCR_MAX_REDIRECT_URIS = 10;
|
|
|
6
6
|
export interface GoogleExchangeResult {
|
|
7
7
|
tokens: Record<string, unknown>;
|
|
8
8
|
email?: string;
|
|
9
|
+
/** Google's stable account id; unlike the address it is never reassigned. */
|
|
10
|
+
sub?: string;
|
|
11
|
+
/** Google Workspace hosted domain; absent for a consumer account. */
|
|
12
|
+
hd?: string;
|
|
9
13
|
}
|
|
10
14
|
export interface AuthServerConfig {
|
|
11
15
|
base: string;
|
|
@@ -16,10 +20,10 @@ export interface AuthServerConfig {
|
|
|
16
20
|
accessTtlSec?: number;
|
|
17
21
|
masterKey: string;
|
|
18
22
|
refreshStorePath: string;
|
|
19
|
-
/** Default true. The clientless
|
|
23
|
+
/** Default true. The clientless `flow=alias_reauth` link names an alias with
|
|
20
24
|
* no tenant binding, so a multi-tenant deployment sets false: with several
|
|
21
25
|
* tenants an alias name alone is ambiguous and the branch becomes a
|
|
22
|
-
* cross-tenant re-auth vector. Server-minted
|
|
26
|
+
* cross-tenant re-auth vector. Server-minted alias_add flows are unaffected. */
|
|
23
27
|
legacyAliasReauth?: boolean;
|
|
24
28
|
}
|
|
25
29
|
/** What /callback hands the alias_add binder once Google returned a verified
|
|
@@ -31,6 +35,7 @@ export interface TenantAliasBind {
|
|
|
31
35
|
bundles?: string[];
|
|
32
36
|
nonce?: string;
|
|
33
37
|
tokens: Record<string, unknown>;
|
|
38
|
+
sub?: string;
|
|
34
39
|
}
|
|
35
40
|
/** A binder's deliberate refusal (policy, not failure): /callback answers 403
|
|
36
41
|
* with this slug and message. The message reaches an unauthenticated browser. */
|
|
@@ -40,8 +45,15 @@ export interface AliasBindRefusal {
|
|
|
40
45
|
message: string;
|
|
41
46
|
};
|
|
42
47
|
}
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
export interface GoogleIdentity {
|
|
49
|
+
email?: string;
|
|
50
|
+
sub?: string;
|
|
51
|
+
hd?: string;
|
|
52
|
+
}
|
|
53
|
+
/** Only trust the id_token identity when Google marks the email verified (#8).
|
|
54
|
+
* The token comes straight from Google's token endpoint, so its claims are
|
|
55
|
+
* read unsigned. A malformed sub or hd is dropped, never passed on. */
|
|
56
|
+
export declare function verifiedIdentityFromIdToken(idToken?: string | null): GoogleIdentity | undefined;
|
|
45
57
|
export declare function verifiedEmailFromIdToken(idToken?: string | null): string | undefined;
|
|
46
58
|
export interface AuthServerDeps {
|
|
47
59
|
fetchCimd?: (clientId: string) => Promise<Record<string, unknown>>;
|
|
@@ -58,6 +70,12 @@ export interface AuthServerDeps {
|
|
|
58
70
|
bundles?: string[];
|
|
59
71
|
}) => string;
|
|
60
72
|
writeToken?: (alias: string, tokens: Record<string, unknown>) => void;
|
|
73
|
+
/** Scopes the alias asked for that `grantedScope` lacks, so the alias_reauth
|
|
74
|
+
* completion page can say what Google's granular consent left out. */
|
|
75
|
+
missingScopes?: (alias: string, grantedScope: string | undefined) => string[];
|
|
76
|
+
/** Whether a readable token is stored for the alias; a narrower re-auth
|
|
77
|
+
* grant keeps it rather than replace it. */
|
|
78
|
+
hasToken?: (alias: string) => boolean;
|
|
61
79
|
registeredClients?: Map<string, {
|
|
62
80
|
redirect_uris: string[];
|
|
63
81
|
}>;
|
|
@@ -70,8 +88,14 @@ export interface AuthServerDeps {
|
|
|
70
88
|
aliasEmail?: (alias: string) => string | undefined;
|
|
71
89
|
/** Map a verified sign-in email to the subject its tokens are minted under.
|
|
72
90
|
* Default = the MCP_OWNER_EMAILS allowlist -> {sub:'owner'}; null = denied.
|
|
73
|
-
* The tenant resolver (invite claim / email->tenant lookup) plugs in here.
|
|
74
|
-
|
|
91
|
+
* The tenant resolver (invite claim / email->tenant lookup) plugs in here.
|
|
92
|
+
* `identity` carries Google's `sub` and `hd` when the exchange returned
|
|
93
|
+
* them, so a resolver can key on the stable account id rather than an
|
|
94
|
+
* address that can be reassigned. */
|
|
95
|
+
resolveSubject?: (email: string, identity?: {
|
|
96
|
+
sub?: string;
|
|
97
|
+
hd?: string;
|
|
98
|
+
}) => {
|
|
75
99
|
sub: string;
|
|
76
100
|
} | null;
|
|
77
101
|
/** alias_add completion: persist the new alias's config row and tokens under
|
|
@@ -88,12 +112,17 @@ export interface AuthServer {
|
|
|
88
112
|
* INSIDE the signed state, so nothing at /authorize or /callback trusts a
|
|
89
113
|
* caller-supplied identity. Inert unless something calls it. */
|
|
90
114
|
mintFlowState: (opts: {
|
|
91
|
-
flow: 'alias_add'
|
|
115
|
+
flow: 'alias_add';
|
|
92
116
|
alias: string;
|
|
93
117
|
tenantId?: string;
|
|
94
118
|
bundles?: string[];
|
|
95
119
|
nonce?: string;
|
|
96
120
|
}) => Promise<string>;
|
|
121
|
+
/** The owner's re-auth link for `alias`: HMAC-signed, expiring, not single
|
|
122
|
+
* use (a chat client may prefetch it). /authorize refuses any alias_reauth
|
|
123
|
+
* request without a valid one, so an unauthenticated caller can neither
|
|
124
|
+
* start a re-auth nor learn which aliases exist or what they hold. */
|
|
125
|
+
reauthLink: (alias: string) => string;
|
|
97
126
|
}
|
|
98
127
|
/** C6: exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
|
|
99
128
|
* the fixed claude.ai callback. */
|
package/dist/oauth-as.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// whole surface is exercised by an offline harness.
|
|
8
8
|
import { createHash, randomBytes } from 'node:crypto';
|
|
9
9
|
import { isIPv4 } from 'node:net';
|
|
10
|
-
import { signAccessToken, verifyAccessToken, signState, verifyState, signAuthzCode, verifyAuthzCode, signPending, verifyPending, ReplayGuard, RefreshStore, STATE_TTL_DEFAULT, CODE_TTL_DEFAULT, ACCESS_TTL_DEFAULT, } from './mcp-token.js';
|
|
10
|
+
import { signAccessToken, verifyAccessToken, signState, verifyState, signAuthzCode, verifyAuthzCode, signReauthLink, verifyReauthLink, signPending, verifyPending, ReplayGuard, RefreshStore, STATE_TTL_DEFAULT, CODE_TTL_DEFAULT, ACCESS_TTL_DEFAULT, } from './mcp-token.js';
|
|
11
11
|
import { fetchCimdDocument, SsrfBlockedError } from './ssrf-guard.js';
|
|
12
12
|
const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
|
|
13
13
|
// DCR (/register) is public + unauthenticated, so its in-memory store must be
|
|
@@ -17,21 +17,34 @@ const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
|
|
|
17
17
|
export const DCR_MAX_CLIENTS = 1000;
|
|
18
18
|
export const DCR_MAX_REDIRECT_URIS = 10;
|
|
19
19
|
const DCR_MAX_URI_LEN = 2048;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
const OIDC_SUB = /^[\x21-\x7e]{1,255}$/;
|
|
21
|
+
const HOSTED_DOMAIN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i;
|
|
22
|
+
/** Only trust the id_token identity when Google marks the email verified (#8).
|
|
23
|
+
* The token comes straight from Google's token endpoint, so its claims are
|
|
24
|
+
* read unsigned. A malformed sub or hd is dropped, never passed on. */
|
|
25
|
+
export function verifiedIdentityFromIdToken(idToken) {
|
|
23
26
|
if (!idToken)
|
|
24
27
|
return undefined;
|
|
25
28
|
try {
|
|
26
29
|
const [, payload] = idToken.split('.');
|
|
27
30
|
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
|
|
28
31
|
const verified = claims.email_verified === true || claims.email_verified === 'true';
|
|
29
|
-
|
|
32
|
+
if (!verified)
|
|
33
|
+
return undefined;
|
|
34
|
+
const identity = { email: claims.email };
|
|
35
|
+
if (typeof claims.sub === 'string' && OIDC_SUB.test(claims.sub))
|
|
36
|
+
identity.sub = claims.sub;
|
|
37
|
+
if (typeof claims.hd === 'string' && claims.hd.length <= 253 && HOSTED_DOMAIN.test(claims.hd))
|
|
38
|
+
identity.hd = claims.hd;
|
|
39
|
+
return identity;
|
|
30
40
|
}
|
|
31
41
|
catch {
|
|
32
42
|
return undefined;
|
|
33
43
|
}
|
|
34
44
|
}
|
|
45
|
+
export function verifiedEmailFromIdToken(idToken) {
|
|
46
|
+
return verifiedIdentityFromIdToken(idToken)?.email;
|
|
47
|
+
}
|
|
35
48
|
// --- helpers ----------------------------------------------------------------
|
|
36
49
|
function json(res, status, body, extraHeaders = {}) {
|
|
37
50
|
res.writeHead(status, { 'Content-Type': 'application/json', ...extraHeaders });
|
|
@@ -240,6 +253,11 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
240
253
|
catch {
|
|
241
254
|
return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
|
|
242
255
|
}
|
|
256
|
+
// Only the client leg signs pendings, and it is always the owner sign-in;
|
|
257
|
+
// a pending signed before that rule (flow=alias_reauth) is refused here.
|
|
258
|
+
if (pending.flow !== 'owner_gate') {
|
|
259
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
|
|
260
|
+
}
|
|
243
261
|
if (!replay.consume(pending.jti, STATE_TTL_DEFAULT * 1000, now())) {
|
|
244
262
|
return errorPage(res, 400, 'E_STATE_INVALID', 'this approval was already used');
|
|
245
263
|
}
|
|
@@ -280,17 +298,20 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
280
298
|
// In-call re-auth (BV-1): a user-clicked `flow=alias_reauth` link has NO
|
|
281
299
|
// client leg — no code is delivered to any client, the refreshed token is
|
|
282
300
|
// written server-side for the alias — so it skips the client_id/redirect/
|
|
283
|
-
// PKCE requirements.
|
|
284
|
-
// identity to the alias's
|
|
285
|
-
if (q.get('flow') === 'alias_reauth' &&
|
|
301
|
+
// PKCE requirements. The link must be one this server signed (reauthLink),
|
|
302
|
+
// and /callback still binds the completing identity to the alias's email.
|
|
303
|
+
if (q.get('flow') === 'alias_reauth' && !clientId) {
|
|
286
304
|
if (config.legacyAliasReauth === false) {
|
|
287
305
|
// Alias-only, tenant-ambiguous: disabled on multi-tenant deployments.
|
|
288
306
|
return errorPage(res, 403, 'E_LEGACY_REAUTH_DISABLED', 'alias re-auth links are disabled on this deployment; ask the server for a fresh account link');
|
|
289
307
|
}
|
|
290
|
-
|
|
291
|
-
|
|
308
|
+
const alias = verifyReauthLink(base, secret, { alias: aliasParam ?? '', exp: q.get('exp') ?? '', sig: q.get('sig') ?? '' }, nowSec());
|
|
309
|
+
// One answer for an unsigned, forged, expired or stale link, so a
|
|
310
|
+
// request the server did not issue learns nothing about its aliases.
|
|
311
|
+
if (!alias || !deps.aliasEmail?.(alias)) {
|
|
312
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'this re-auth link is invalid or expired; ask the server for a fresh one');
|
|
292
313
|
}
|
|
293
|
-
return toGoogle(res, { flow: 'alias_reauth', client_id: '', redirect_uri: '', code_challenge: '', resource, alias
|
|
314
|
+
return toGoogle(res, { flow: 'alias_reauth', client_id: '', redirect_uri: '', code_challenge: '', resource, alias });
|
|
294
315
|
}
|
|
295
316
|
if (!clientId)
|
|
296
317
|
return json(res, 400, { error: 'invalid_request', message: 'client_id required', iss: base }, issHeader);
|
|
@@ -310,14 +331,16 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
310
331
|
return json(res, 400, { error: 'invalid_request', message: `resource must be ${resource}`, iss: base }, issHeader);
|
|
311
332
|
}
|
|
312
333
|
// (5) only after full validation do we build the request artifact.
|
|
334
|
+
// A client leg is always the owner sign-in. Re-auth has its own signed
|
|
335
|
+
// link: accepting flow=alias_reauth here let any client (a self-registered
|
|
336
|
+
// one, or a public CIMD client_id) start it and read the alias's scopes.
|
|
313
337
|
const statePayload = {
|
|
314
|
-
flow:
|
|
338
|
+
flow: 'owner_gate',
|
|
315
339
|
client_id: clientId,
|
|
316
340
|
redirect_uri: redirectUri,
|
|
317
341
|
code_challenge: codeChallenge,
|
|
318
342
|
client_state: clientState,
|
|
319
343
|
resource,
|
|
320
|
-
alias: q.get('alias') ?? undefined,
|
|
321
344
|
};
|
|
322
345
|
// Confused-deputy defense (#3): a self-registered DCR client can pick an
|
|
323
346
|
// arbitrary redirect_uri, so before we send the owner to Google (whose
|
|
@@ -373,7 +396,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
373
396
|
return errorPage(res, 403, 'access_denied', 'Google did not return an email for the signed-in account');
|
|
374
397
|
let refusal;
|
|
375
398
|
try {
|
|
376
|
-
const out = await deps.bindTenantAlias({ tenantId: st.tenantId, alias: st.alias, email: boundEmail, bundles: st.bundles, nonce: st.nonce, tokens: exchanged.tokens });
|
|
399
|
+
const out = await deps.bindTenantAlias({ tenantId: st.tenantId, alias: st.alias, email: boundEmail, bundles: st.bundles, nonce: st.nonce, tokens: exchanged.tokens, sub: exchanged.sub });
|
|
377
400
|
refusal = out?.refused;
|
|
378
401
|
}
|
|
379
402
|
catch (e) {
|
|
@@ -415,10 +438,26 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
415
438
|
log(`alias_reauth for "${st.alias}" returned no refresh token; stored token kept`);
|
|
416
439
|
return errorPage(res, 400, 'E_REAUTH_INCOMPLETE', 'Google did not return a long-lived token, so the stored one was kept. Open the re-auth link again and approve access.');
|
|
417
440
|
}
|
|
441
|
+
const granted = typeof exchanged.tokens.scope === 'string' ? exchanged.tokens.scope : undefined;
|
|
442
|
+
const missing = deps.missingScopes?.(st.alias, granted) ?? [];
|
|
443
|
+
const listed = missing.map((m) => `<code>${escapeHtml(m)}</code>`).join(', ');
|
|
444
|
+
// A narrower grant replaces nothing that works. The link can be reused
|
|
445
|
+
// for an hour and the Google URL's scope is not signed, so a narrower
|
|
446
|
+
// grant is not necessarily the account holder's choice.
|
|
447
|
+
if (missing.length > 0 && deps.hasToken?.(st.alias)) {
|
|
448
|
+
log(`alias_reauth for "${st.alias}" granted ${missing.length} fewer scope(s); stored token kept`);
|
|
449
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
450
|
+
res.end(`<!doctype html><meta charset=utf-8><p>E_SCOPE_NOT_GRANTED: Google did not grant ${missing.length} requested scope(s): ${listed}, so the stored access for "${escapeHtml(st.alias)}" was kept. Ask for a fresh re-auth link and leave every box ticked.</p>`);
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
418
453
|
deps.writeToken?.(st.alias, exchanged.tokens);
|
|
419
454
|
log(`callback ok flow=alias_reauth alias=${st.alias}`);
|
|
455
|
+
// With no stored token a partial grant still beats none; say what is missing.
|
|
456
|
+
const gap = missing.length
|
|
457
|
+
? `<p>Google did not grant ${missing.length} requested scope(s): ${listed}. Ask for a fresh re-auth link and leave every box ticked to restore them.</p>`
|
|
458
|
+
: '';
|
|
420
459
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
421
|
-
res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${escapeHtml(st.alias)}". You can close this window.</p
|
|
460
|
+
res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${escapeHtml(st.alias)}". You can close this window.</p>${gap}`);
|
|
422
461
|
return true;
|
|
423
462
|
}
|
|
424
463
|
// owner_gate: resolve the verified email to its subject (default = the
|
|
@@ -426,7 +465,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
426
465
|
// signed into the authz code and flows into every token minted from it.
|
|
427
466
|
const email = (exchanged.email ?? '').toLowerCase();
|
|
428
467
|
const iss = encodeURIComponent(base);
|
|
429
|
-
const subject = email ? resolveSubject(email) : null;
|
|
468
|
+
const subject = email ? resolveSubject(email, { sub: exchanged.sub, hd: exchanged.hd }) : null;
|
|
430
469
|
if (!subject) {
|
|
431
470
|
const sep = st.redirect_uri.includes('?') ? '&' : '?';
|
|
432
471
|
const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
|
|
@@ -537,8 +576,13 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
537
576
|
}
|
|
538
577
|
};
|
|
539
578
|
const mintFlowState = async ({ flow, alias, tenantId, bundles, nonce }) => {
|
|
579
|
+
// /authorize has a signed branch for alias_add only; anything else minted
|
|
580
|
+
// here would be a state no route verifies the way its flow needs.
|
|
581
|
+
if (flow !== 'alias_add')
|
|
582
|
+
throw new Error(`mintFlowState supports alias_add only, not ${String(flow)}`);
|
|
540
583
|
const state = await signState({ flow, client_id: '', redirect_uri: '', code_challenge: '', resource, alias, tenantId, bundles, nonce }, base, secret, nowSec());
|
|
541
584
|
return `${base}/authorize?flow=${flow}&state=${encodeURIComponent(state)}`;
|
|
542
585
|
};
|
|
543
|
-
|
|
586
|
+
const reauthLink = (alias) => `${base}/authorize?flow=alias_reauth&${signReauthLink(base, secret, alias, nowSec())}`;
|
|
587
|
+
return { routes, authenticate, mintFlowState, reauthLink };
|
|
544
588
|
}
|
package/dist/reauth-hint.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare function
|
|
1
|
+
export declare function setHttpReauthLink(linker: ((alias: string) => string) | null): void;
|
|
2
2
|
export declare function reauthHint(account: string): string;
|
package/dist/reauth-hint.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// B13 in-call re-auth (BV-1): a dead/missing per-account Google token is a
|
|
2
2
|
// leg-C failure. Over stdio the fix is the `auth --account` CLI; over HTTP the
|
|
3
|
-
// user cannot run a CLI, so the hint becomes a clickable link
|
|
4
|
-
// clientless alias_reauth flow (
|
|
5
|
-
//
|
|
6
|
-
let
|
|
7
|
-
export function
|
|
8
|
-
|
|
3
|
+
// user cannot run a CLI, so the hint becomes a clickable, server-signed link
|
|
4
|
+
// into the AS's clientless alias_reauth flow (AuthServer.reauthLink). The HTTP
|
|
5
|
+
// bootstrap installs the linker when the AS starts; stdio never does.
|
|
6
|
+
let httpReauthLink = null;
|
|
7
|
+
export function setHttpReauthLink(linker) {
|
|
8
|
+
httpReauthLink = linker;
|
|
9
9
|
}
|
|
10
10
|
export function reauthHint(account) {
|
|
11
|
-
if (
|
|
12
|
-
return `Re-authenticate "${account}" in your browser, then retry: ${
|
|
11
|
+
if (httpReauthLink) {
|
|
12
|
+
return `Re-authenticate "${account}" in your browser, then retry: ${httpReauthLink(account)}`;
|
|
13
13
|
}
|
|
14
14
|
return `Run: npx mcp-google-multi auth --account ${account}`;
|
|
15
15
|
}
|
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.59",
|
|
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",
|