mcp-google-multi 6.0.0-alpha.61 → 6.0.0-alpha.63
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/discovery-client.js +19 -1
- package/dist/http-transport.js +26 -1
- package/dist/mcp-token.d.ts +5 -3
- package/dist/mcp-token.js +70 -15
- package/dist/oauth-as.js +60 -6
- package/package.json +1 -1
package/dist/discovery-client.js
CHANGED
|
@@ -74,6 +74,21 @@ export function discoveryCacheDir(env = process.env) {
|
|
|
74
74
|
return (env.DISCOVERY_CACHE_PATH ??
|
|
75
75
|
path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'mcp-google-multi', 'discovery'));
|
|
76
76
|
}
|
|
77
|
+
// Default cache path only (an operator's DISCOVERY_CACHE_PATH may be shared,
|
|
78
|
+
// even /tmp, and keeps its mode), only when this process owns it, and best
|
|
79
|
+
// effort. Clears group/other bits and keeps setuid/setgid/sticky.
|
|
80
|
+
function tightenOwnDir(dir) {
|
|
81
|
+
if (process.platform === 'win32')
|
|
82
|
+
return;
|
|
83
|
+
try {
|
|
84
|
+
const st = fs.statSync(dir);
|
|
85
|
+
if (st.uid === process.getuid?.() && (st.mode & 0o077) !== 0)
|
|
86
|
+
fs.chmodSync(dir, st.mode & ~0o077 & 0o7777);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// the cache still works with the directory's own mode
|
|
90
|
+
}
|
|
91
|
+
}
|
|
77
92
|
// A poisoned cache file could repoint baseUrl off-Google; refuse to send the
|
|
78
93
|
// user's Bearer token anywhere but a googleapis.com host.
|
|
79
94
|
export function isGoogleApiUrl(url) {
|
|
@@ -135,6 +150,7 @@ export async function loadMethodIndex(api, deps = {}) {
|
|
|
135
150
|
throw new Error(`Unknown api "${api}". Known: ${Object.keys(SUPPORTED_APIS).join(', ')}`);
|
|
136
151
|
}
|
|
137
152
|
const fetchFn = deps.fetchFn ?? ((url) => fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }));
|
|
153
|
+
const defaultDir = deps.cacheDir === undefined && process.env.DISCOVERY_CACHE_PATH === undefined;
|
|
138
154
|
const cacheDir = deps.cacheDir ?? discoveryCacheDir();
|
|
139
155
|
const cacheFile = path.join(cacheDir, `${api}.json`);
|
|
140
156
|
const readCache = () => {
|
|
@@ -172,7 +188,9 @@ export async function loadMethodIndex(api, deps = {}) {
|
|
|
172
188
|
if (!res.ok)
|
|
173
189
|
throw new Error(`HTTP ${res.status}`);
|
|
174
190
|
doc = (await res.json());
|
|
175
|
-
fs.mkdirSync(cacheDir, { recursive: true });
|
|
191
|
+
fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
|
|
192
|
+
if (defaultDir)
|
|
193
|
+
tightenOwnDir(cacheDir);
|
|
176
194
|
fs.writeFileSync(cacheFile, JSON.stringify(doc), { mode: 0o600 });
|
|
177
195
|
break;
|
|
178
196
|
}
|
package/dist/http-transport.js
CHANGED
|
@@ -66,6 +66,16 @@ function stopReading(socket) {
|
|
|
66
66
|
socket.pause();
|
|
67
67
|
socket.resume = () => socket;
|
|
68
68
|
}
|
|
69
|
+
// For a log line: the pathname only (a query can carry a code or a state),
|
|
70
|
+
// and never a throw, since the caller is the catch-all.
|
|
71
|
+
function pathOf(req) {
|
|
72
|
+
try {
|
|
73
|
+
return new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return '?';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
69
79
|
export class HttpTransportHost {
|
|
70
80
|
opts;
|
|
71
81
|
httpServer;
|
|
@@ -140,8 +150,23 @@ export class HttpTransportHost {
|
|
|
140
150
|
res.once('close', settle);
|
|
141
151
|
// Never rejects: close() waits on it, and a catch-all that itself threw
|
|
142
152
|
// must neither reject close() nor surface as an unhandled rejection.
|
|
153
|
+
// The thrown message can name server paths (a lock timeout does), so it
|
|
154
|
+
// goes only to the host's log.
|
|
143
155
|
const done = this.handle(req, res)
|
|
144
|
-
.catch((e) =>
|
|
156
|
+
.catch((e) => {
|
|
157
|
+
try {
|
|
158
|
+
this.fail(res, 500, 'internal_error', 'internal error');
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
// A throwing log must not reach the destroy below: the answer is sent.
|
|
162
|
+
try {
|
|
163
|
+
this.log(`500 internal_error path=${pathOf(req)}: ${e instanceof Error ? e.message : 'non-Error throw'}`);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// nowhere left to report it
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
})
|
|
145
170
|
.catch(() => void res.destroy());
|
|
146
171
|
this.handling.add(done);
|
|
147
172
|
void done.finally(() => this.handling.delete(done));
|
package/dist/mcp-token.d.ts
CHANGED
|
@@ -35,6 +35,9 @@ export interface StatePayload {
|
|
|
35
35
|
/** alias_add only: an opaque value the minting caller chose, signed like
|
|
36
36
|
* tenantId and handed back to its binder (e.g. a server-side link record). */
|
|
37
37
|
nonce?: string;
|
|
38
|
+
/** owner_gate only: base64url sha256 of the browser-binding cookie the
|
|
39
|
+
* Google redirect set; /callback refuses a browser that lacks it. */
|
|
40
|
+
bind?: string;
|
|
38
41
|
}
|
|
39
42
|
export interface CodePayload {
|
|
40
43
|
redirect_uri: string;
|
|
@@ -110,8 +113,7 @@ export declare class RefreshStore {
|
|
|
110
113
|
sub: string;
|
|
111
114
|
} | null;
|
|
112
115
|
/** Drop every ACTIVE record minted under `sub` (linear scan under the store
|
|
113
|
-
* lock)
|
|
114
|
-
*
|
|
115
|
-
* of active tokens dropped. */
|
|
116
|
+
* lock); the save then drops the spent entries of every family left with no
|
|
117
|
+
* active token. Returns the number of active tokens dropped. */
|
|
116
118
|
purgeTenant(sub: string): number;
|
|
117
119
|
}
|
package/dist/mcp-token.js
CHANGED
|
@@ -20,6 +20,8 @@ function newJti() {
|
|
|
20
20
|
return randomBytes(16).toString('base64url');
|
|
21
21
|
}
|
|
22
22
|
export async function signAccessToken(p) {
|
|
23
|
+
if (typeof p.sub !== 'string' || p.sub === '')
|
|
24
|
+
throw new Error('signAccessToken: sub must be a non-empty string');
|
|
23
25
|
const ttl = p.ttlSec ?? ACCESS_TTL_DEFAULT;
|
|
24
26
|
return new SignJWT({ scope: 'mcp:use', purpose: 'mcp_access' })
|
|
25
27
|
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
@@ -36,7 +38,9 @@ export async function verifyAccessToken(token, base, secret) {
|
|
|
36
38
|
const { payload } = await jwtVerify(token, secret, { issuer: base, audience: `${base}/mcp` });
|
|
37
39
|
if (payload.purpose !== 'mcp_access')
|
|
38
40
|
throw new Error('wrong token purpose');
|
|
39
|
-
|
|
41
|
+
if (typeof payload.sub !== 'string' || payload.sub === '')
|
|
42
|
+
throw new Error('access token has no subject');
|
|
43
|
+
return { sub: payload.sub, scope: String(payload.scope ?? ''), jti: String(payload.jti ?? '') };
|
|
40
44
|
}
|
|
41
45
|
async function signArtifact(claims, purpose, base, secret, iat, ttlSec) {
|
|
42
46
|
return new SignJWT({ ...claims, purpose })
|
|
@@ -129,6 +133,16 @@ export class ReplayGuard {
|
|
|
129
133
|
}
|
|
130
134
|
}
|
|
131
135
|
const SPENT_CAP = 2000;
|
|
136
|
+
function ownEntries(o) {
|
|
137
|
+
// An own `__proto__` key (JSON.parse makes one) would set the prototype when copied.
|
|
138
|
+
return o !== null && typeof o === 'object' && !Array.isArray(o) ? Object.entries(o).filter(([k]) => k !== '__proto__') : [];
|
|
139
|
+
}
|
|
140
|
+
function isLegacyRecord(r) {
|
|
141
|
+
if (r === null || typeof r !== 'object')
|
|
142
|
+
return false;
|
|
143
|
+
const x = r;
|
|
144
|
+
return typeof x.sub === 'string' && x.sub !== '' && typeof x.family === 'string' && typeof x.issuedAt === 'number' && Number.isFinite(x.issuedAt);
|
|
145
|
+
}
|
|
132
146
|
/**
|
|
133
147
|
* Persisted (encrypted under MASTER_KEY) opaque refresh-token store. Rotates on
|
|
134
148
|
* every use (a stolen token is usable at most once), and detects REUSE of a
|
|
@@ -144,22 +158,64 @@ export class RefreshStore {
|
|
|
144
158
|
this.masterKey = masterKey;
|
|
145
159
|
}
|
|
146
160
|
load() {
|
|
161
|
+
const data = { active: {}, spent: {} };
|
|
147
162
|
if (!existsSync(this.path))
|
|
148
|
-
return
|
|
163
|
+
return data;
|
|
164
|
+
let d;
|
|
149
165
|
try {
|
|
150
|
-
|
|
151
|
-
return { active: d.active ?? {}, spent: d.spent ?? {} };
|
|
166
|
+
d = decryptToken(readFileSync(this.path, 'utf-8'), this.masterKey);
|
|
152
167
|
}
|
|
153
168
|
catch {
|
|
154
|
-
return
|
|
169
|
+
return data;
|
|
155
170
|
}
|
|
171
|
+
// Only well-formed own entries survive, so no lookup can land on an
|
|
172
|
+
// inherited key or a record without a subject.
|
|
173
|
+
for (const [t, r] of ownEntries(d?.active)) {
|
|
174
|
+
if (isLegacyRecord(r))
|
|
175
|
+
data.active[t] = { sub: r.sub, issuedAt: r.issuedAt, family: r.family };
|
|
176
|
+
}
|
|
177
|
+
for (const [t, fam] of ownEntries(d?.spent))
|
|
178
|
+
if (typeof fam === 'string')
|
|
179
|
+
data.spent[t] = fam;
|
|
180
|
+
return data;
|
|
156
181
|
}
|
|
157
182
|
save(data) {
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
183
|
+
// A family with no active token has nothing left to revoke. Over the cap,
|
|
184
|
+
// the subject holding the most spent tokens loses its oldest (ties: the
|
|
185
|
+
// oldest entry), so a subject's rotations, across all its families, only
|
|
186
|
+
// push out its own history (docs/internals.md). Insertion order = rotation
|
|
187
|
+
// order.
|
|
188
|
+
const owner = new Map();
|
|
189
|
+
for (const r of Object.values(data.active))
|
|
190
|
+
owner.set(r.family, r.sub);
|
|
191
|
+
const order = Object.keys(data.spent);
|
|
192
|
+
const held = new Map();
|
|
193
|
+
let total = 0;
|
|
194
|
+
order.forEach((t, i) => {
|
|
195
|
+
const sub = owner.get(data.spent[t]);
|
|
196
|
+
if (sub === undefined) {
|
|
197
|
+
delete data.spent[t];
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const h = held.get(sub);
|
|
201
|
+
if (h)
|
|
202
|
+
h.at.push(i);
|
|
203
|
+
else
|
|
204
|
+
held.set(sub, { at: [i], head: 0 });
|
|
205
|
+
total += 1;
|
|
206
|
+
});
|
|
207
|
+
while (total > SPENT_CAP) {
|
|
208
|
+
let most = { at: [], head: 0 };
|
|
209
|
+
let mostN = 0;
|
|
210
|
+
for (const h of held.values()) {
|
|
211
|
+
const n = h.at.length - h.head;
|
|
212
|
+
if (n > mostN || (n === mostN && n > 0 && h.at[h.head] < most.at[most.head])) {
|
|
213
|
+
most = h;
|
|
214
|
+
mostN = n;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
delete data.spent[order[most.at[most.head++]]];
|
|
218
|
+
total -= 1;
|
|
163
219
|
}
|
|
164
220
|
atomicWriteFileSync(this.path, encryptToken(data, this.masterKey), 0o600);
|
|
165
221
|
}
|
|
@@ -184,10 +240,10 @@ export class RefreshStore {
|
|
|
184
240
|
rotate(oldToken, nowMs, accept) {
|
|
185
241
|
return withFileLock(this.path, () => {
|
|
186
242
|
const data = this.load();
|
|
187
|
-
const rec = data.active[oldToken];
|
|
243
|
+
const rec = Object.hasOwn(data.active, oldToken) ? data.active[oldToken] : undefined;
|
|
188
244
|
if (!rec) {
|
|
189
245
|
// Reuse of a rotated-away token signals theft: revoke the whole family.
|
|
190
|
-
const fam = data.spent[oldToken];
|
|
246
|
+
const fam = Object.hasOwn(data.spent, oldToken) ? data.spent[oldToken] : undefined;
|
|
191
247
|
if (fam) {
|
|
192
248
|
for (const [t, r] of Object.entries(data.active))
|
|
193
249
|
if (r.family === fam)
|
|
@@ -212,9 +268,8 @@ export class RefreshStore {
|
|
|
212
268
|
});
|
|
213
269
|
}
|
|
214
270
|
/** Drop every ACTIVE record minted under `sub` (linear scan under the store
|
|
215
|
-
* lock)
|
|
216
|
-
*
|
|
217
|
-
* of active tokens dropped. */
|
|
271
|
+
* lock); the save then drops the spent entries of every family left with no
|
|
272
|
+
* active token. Returns the number of active tokens dropped. */
|
|
218
273
|
purgeTenant(sub) {
|
|
219
274
|
return withFileLock(this.path, () => {
|
|
220
275
|
const data = this.load();
|
package/dist/oauth-as.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// tokens (leg C, unchanged). Mounts into B12's HttpTransportHost as `routes` +
|
|
6
6
|
// an `authenticate` for POST /mcp. All Google/CIMD I/O is injectable so the
|
|
7
7
|
// whole surface is exercised by an offline harness.
|
|
8
|
-
import { createHash, randomBytes } from 'node:crypto';
|
|
8
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
9
|
import { isIPv4 } from 'node:net';
|
|
10
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';
|
|
@@ -61,8 +61,8 @@ class SubjectCheckUnavailable extends Error {
|
|
|
61
61
|
function subjectUnavailable(res) {
|
|
62
62
|
return json(res, 503, { error: 'temporarily_unavailable', message: 'the sign-in could not be checked right now; retry shortly' }, { 'Retry-After': '5' });
|
|
63
63
|
}
|
|
64
|
-
function redirect(res, location) {
|
|
65
|
-
res.writeHead(302, { Location: location });
|
|
64
|
+
function redirect(res, location, extraHeaders = {}) {
|
|
65
|
+
res.writeHead(302, { Location: location, ...extraHeaders });
|
|
66
66
|
res.end();
|
|
67
67
|
return true;
|
|
68
68
|
}
|
|
@@ -102,6 +102,38 @@ export function redirectAllowed(redirectUri, docRedirectUris) {
|
|
|
102
102
|
return isLoopbackHostname(du.hostname) && du.protocol === r.protocol && du.pathname === r.pathname;
|
|
103
103
|
});
|
|
104
104
|
}
|
|
105
|
+
// The owner_gate browser binding (docs/internals.md). One cookie per flow, so
|
|
106
|
+
// two sign-ins started in one browser do not overwrite each other. __Host-
|
|
107
|
+
// requires Secure, which an http base (the loopback owner) cannot rely on in
|
|
108
|
+
// every browser, so an http base gets a plain host-only name instead.
|
|
109
|
+
const BIND_HASH = /^[A-Za-z0-9_-]{43}$/;
|
|
110
|
+
function bindCookieName(base, bind) {
|
|
111
|
+
return `${base.startsWith('https:') ? '__Host-' : ''}mgm-og-${bind.slice(0, 16)}`;
|
|
112
|
+
}
|
|
113
|
+
function bindCookieAttrs(base, maxAge) {
|
|
114
|
+
return `Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${base.startsWith('https:') ? '; Secure' : ''}`;
|
|
115
|
+
}
|
|
116
|
+
function readCookie(req, name) {
|
|
117
|
+
for (const part of (req.headers.cookie ?? '').split(';')) {
|
|
118
|
+
const i = part.indexOf('=');
|
|
119
|
+
if (i > 0 && part.slice(0, i).trim() === name)
|
|
120
|
+
return part.slice(i + 1).trim();
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
function sha256Url(value) {
|
|
125
|
+
return createHash('sha256').update(value).digest('base64url');
|
|
126
|
+
}
|
|
127
|
+
function browserBinding(req, base, bind) {
|
|
128
|
+
if (typeof bind !== 'string' || !BIND_HASH.test(bind))
|
|
129
|
+
return 'missing';
|
|
130
|
+
const value = readCookie(req, bindCookieName(base, bind));
|
|
131
|
+
if (!value)
|
|
132
|
+
return 'missing';
|
|
133
|
+
const got = createHash('sha256').update(value).digest();
|
|
134
|
+
const want = Buffer.from(bind, 'base64url');
|
|
135
|
+
return got.length === want.length && timingSafeEqual(got, want) ? 'ok' : 'mismatch';
|
|
136
|
+
}
|
|
105
137
|
function pkceS256(verifier) {
|
|
106
138
|
return createHash('sha256').update(verifier).digest('base64url');
|
|
107
139
|
}
|
|
@@ -234,11 +266,20 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
234
266
|
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
235
267
|
}
|
|
236
268
|
async function toGoogle(res, sp) {
|
|
269
|
+
// H4 F1: without this, a Google URL minted here and forwarded to someone
|
|
270
|
+
// else signs THEM in and hands their subject to the starting client.
|
|
271
|
+
const headers = {};
|
|
272
|
+
if (sp.flow === 'owner_gate') {
|
|
273
|
+
const value = randomBytes(32).toString('base64url');
|
|
274
|
+
const bind = sha256Url(value);
|
|
275
|
+
sp = { ...sp, bind };
|
|
276
|
+
headers['Set-Cookie'] = `${bindCookieName(base, bind)}=${value}; ${bindCookieAttrs(base, STATE_TTL_DEFAULT)}`;
|
|
277
|
+
}
|
|
237
278
|
const state = await signState(sp, base, secret, nowSec());
|
|
238
279
|
const authUrl = deps.buildGoogleAuthUrl
|
|
239
280
|
? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state, bundles: sp.bundles })
|
|
240
281
|
: `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(state)}`;
|
|
241
|
-
return redirect(res, authUrl);
|
|
282
|
+
return redirect(res, authUrl, headers);
|
|
242
283
|
}
|
|
243
284
|
// GET /authorize (validate + interstitial for DCR clients); POST /authorize
|
|
244
285
|
// (a DCR consent approval — verify the signed pending token, then proceed).
|
|
@@ -355,7 +396,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
355
396
|
// cannot forge, so there is nothing to spoof.
|
|
356
397
|
if (client.dcr) {
|
|
357
398
|
const pendingToken = await signPending(statePayload, base, secret, nowSec());
|
|
358
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
399
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Security-Policy': "frame-ancestors 'none'", 'X-Frame-Options': 'DENY' });
|
|
359
400
|
res.end(`<!doctype html><meta charset=utf-8><title>Authorize access</title>` +
|
|
360
401
|
`<body style="font-family:system-ui,sans-serif;max-width:36em;margin:3em auto;line-height:1.5">` +
|
|
361
402
|
`<h2>Authorize access?</h2>` +
|
|
@@ -382,6 +423,16 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
382
423
|
if (!replay.consume(st.jti, STATE_TTL_DEFAULT * 1000, now())) {
|
|
383
424
|
return errorPage(res, 400, 'E_STATE_INVALID', 'state has already been used (replay)');
|
|
384
425
|
}
|
|
426
|
+
// After the spend, so a refused callback URL cannot be replayed from the
|
|
427
|
+
// browser that holds the cookie; before the exchange and resolveSubject.
|
|
428
|
+
if (st.flow === 'owner_gate') {
|
|
429
|
+
const binding = browserBinding(req, base, st.bind);
|
|
430
|
+
if (binding !== 'ok') {
|
|
431
|
+
log(`callback refused flow=owner_gate: browser binding ${binding}`);
|
|
432
|
+
return errorPage(res, 400, 'E_BROWSER_MISMATCH', 'finish the sign-in in the browser that started it; start again from your app');
|
|
433
|
+
}
|
|
434
|
+
res.setHeader('Set-Cookie', `${bindCookieName(base, st.bind ?? '')}=; ${bindCookieAttrs(base, 0)}`);
|
|
435
|
+
}
|
|
385
436
|
if (!code)
|
|
386
437
|
return errorPage(res, 400, 'invalid_request', 'missing code');
|
|
387
438
|
let exchanged;
|
|
@@ -389,7 +440,10 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
389
440
|
exchanged = deps.exchangeCode ? await deps.exchangeCode(code, st.flow) : { tokens: {} };
|
|
390
441
|
}
|
|
391
442
|
catch (e) {
|
|
392
|
-
|
|
443
|
+
// The detail can name proxies and hosts; it goes to the log, not the browser.
|
|
444
|
+
const detail = e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection';
|
|
445
|
+
log(`callback exchange failed flow=${st.flow}: ${detail.replace(/[\r\n]+/g, ' ')}`);
|
|
446
|
+
return errorPage(res, 400, 'invalid_grant', 'Google could not complete the sign-in; start again from your app');
|
|
393
447
|
}
|
|
394
448
|
if (st.flow === 'alias_add') {
|
|
395
449
|
if (!st.alias || !st.tenantId)
|
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.63",
|
|
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",
|