mcp-google-multi 6.0.0-alpha.60 → 6.0.0-alpha.62
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.d.ts +24 -1
- package/dist/http-transport.js +212 -8
- package/dist/mcp-token.d.ts +2 -3
- package/dist/mcp-token.js +38 -8
- 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.d.ts
CHANGED
|
@@ -61,6 +61,11 @@ export interface HttpHostOptions {
|
|
|
61
61
|
onArgRename?: (tool: string, renames: number) => void;
|
|
62
62
|
/** Unknown-argument screening (arg-strict.ts); absent = off. */
|
|
63
63
|
strictArgs?: StrictArgOptions;
|
|
64
|
+
/** Per-server cap on /mcp requests reading their body, queued or running;
|
|
65
|
+
* the next answers 429 lane_busy before its body is read. Unset = no cap. */
|
|
66
|
+
maxQueuedPerLane?: number;
|
|
67
|
+
/** Max JSON-RPC messages in one /mcp body (default 16). */
|
|
68
|
+
maxBatchFrames?: number;
|
|
64
69
|
}
|
|
65
70
|
export declare function parseOwnerEmails(env?: NodeJS.ProcessEnv): string[];
|
|
66
71
|
/** Front guard: an Origin, if present, must be allowlisted; a Host must be
|
|
@@ -74,11 +79,28 @@ export declare class HttpTransportHost {
|
|
|
74
79
|
private readonly opts;
|
|
75
80
|
private httpServer?;
|
|
76
81
|
private readonly locks;
|
|
82
|
+
private readonly depth;
|
|
83
|
+
private readonly sockets;
|
|
84
|
+
private readonly pending;
|
|
85
|
+
private readonly handling;
|
|
86
|
+
private closing;
|
|
87
|
+
private graceful?;
|
|
77
88
|
constructor(opts: HttpHostOptions);
|
|
78
89
|
private serializeFor;
|
|
79
90
|
private log;
|
|
80
91
|
start(): Promise<void>;
|
|
81
|
-
|
|
92
|
+
/** Without graceMs, every connection is cut at once, even during a graceful
|
|
93
|
+
* close. With it, the host stops accepting, lets in-flight requests finish
|
|
94
|
+
* and their responses finish writing, then resolves once each client has
|
|
95
|
+
* closed its side; whatever is still open after graceMs is cut. A repeat
|
|
96
|
+
* call with graceMs during a graceful close joins it, keeping the first
|
|
97
|
+
* grace. graceMs must be a number from 0 to 2^31-1 ms (about 24.8 days, the
|
|
98
|
+
* longest delay a timer holds); anything else, Infinity included, rejects
|
|
99
|
+
* with a TypeError and leaves the host as it was. */
|
|
100
|
+
close(opts?: {
|
|
101
|
+
graceMs?: number;
|
|
102
|
+
}): Promise<void>;
|
|
103
|
+
private closeGracefully;
|
|
82
104
|
/** The bound port (useful when listening on port 0 in tests). */
|
|
83
105
|
address(): {
|
|
84
106
|
port: number;
|
|
@@ -86,6 +108,7 @@ export declare class HttpTransportHost {
|
|
|
86
108
|
private handle;
|
|
87
109
|
private frontGuard;
|
|
88
110
|
private mcp;
|
|
111
|
+
private admitted;
|
|
89
112
|
private health;
|
|
90
113
|
private readJson;
|
|
91
114
|
private fail;
|
package/dist/http-transport.js
CHANGED
|
@@ -44,6 +44,38 @@ export function jsonRpcMethod(body) {
|
|
|
44
44
|
return body.map(one).filter(Boolean).join(',') || 'batch';
|
|
45
45
|
return one(body) ?? 'unknown';
|
|
46
46
|
}
|
|
47
|
+
/** setTimeout fires at once for a delay above this, so a longer grace would
|
|
48
|
+
* cut every connection immediately. */
|
|
49
|
+
const MAX_GRACE_MS = 2 ** 31 - 1;
|
|
50
|
+
// A lingering close: a socket destroyed while its client is still sending (a
|
|
51
|
+
// body pipelined after the close) is reset by the kernel, which drops whatever
|
|
52
|
+
// of the last response is still unsent. So the socket only ends its side and
|
|
53
|
+
// waits for the client's FIN, or for the grace to end.
|
|
54
|
+
function endAfterFlush(socket) {
|
|
55
|
+
if (socket.destroyed || socket.writableEnded)
|
|
56
|
+
return;
|
|
57
|
+
socket.end();
|
|
58
|
+
}
|
|
59
|
+
// A request that arrives while closing is never answered, so Node keeps it
|
|
60
|
+
// until the socket closes, and a flood pipelined behind a held response would
|
|
61
|
+
// pile up without limit. Reading stops for good instead, which lets TCP flow
|
|
62
|
+
// control stop the client. Node resumes a socket after every parsed request
|
|
63
|
+
// and on drain, so resume becomes a no-op. Such a socket never sees the
|
|
64
|
+
// client's FIN either: the grace ends it.
|
|
65
|
+
function stopReading(socket) {
|
|
66
|
+
socket.pause();
|
|
67
|
+
socket.resume = () => socket;
|
|
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
|
+
}
|
|
47
79
|
export class HttpTransportHost {
|
|
48
80
|
opts;
|
|
49
81
|
httpServer;
|
|
@@ -54,6 +86,16 @@ export class HttpTransportHost {
|
|
|
54
86
|
// core has one lane (the boot server); with resolveServer each resolved
|
|
55
87
|
// server gets its own, however subjects map onto servers.
|
|
56
88
|
locks = new Map();
|
|
89
|
+
// Requests holding a lane slot, per server; used only with maxQueuedPerLane.
|
|
90
|
+
depth = new Map();
|
|
91
|
+
// What a graceful close waits on: open sockets, per socket the requests it
|
|
92
|
+
// has accepted whose response has not settled and the last of them, and
|
|
93
|
+
// every handle() that has not settled.
|
|
94
|
+
sockets = new Set();
|
|
95
|
+
pending = new Map();
|
|
96
|
+
handling = new Set();
|
|
97
|
+
closing = false;
|
|
98
|
+
graceful;
|
|
57
99
|
constructor(opts) {
|
|
58
100
|
this.opts = opts;
|
|
59
101
|
}
|
|
@@ -77,7 +119,64 @@ export class HttpTransportHost {
|
|
|
77
119
|
async start() {
|
|
78
120
|
const { config } = this.opts;
|
|
79
121
|
const server = createServer((req, res) => {
|
|
80
|
-
|
|
122
|
+
const socket = req.socket;
|
|
123
|
+
// Once closing, a connection ends after the last request it accepted
|
|
124
|
+
// before the close, so one arriving now is never handled (RFC 9112 9.6).
|
|
125
|
+
// Its body is left unread: nothing reads the socket again.
|
|
126
|
+
if (this.closing) {
|
|
127
|
+
stopReading(socket);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const entry = this.pending.get(socket);
|
|
131
|
+
if (entry) {
|
|
132
|
+
entry.count++;
|
|
133
|
+
entry.last = res;
|
|
134
|
+
}
|
|
135
|
+
else
|
|
136
|
+
this.pending.set(socket, { count: 1, last: res });
|
|
137
|
+
let settled = false;
|
|
138
|
+
const settle = () => {
|
|
139
|
+
if (settled)
|
|
140
|
+
return;
|
|
141
|
+
settled = true;
|
|
142
|
+
const e = this.pending.get(socket);
|
|
143
|
+
if (!e || --e.count > 0)
|
|
144
|
+
return;
|
|
145
|
+
this.pending.delete(socket);
|
|
146
|
+
if (this.closing)
|
|
147
|
+
endAfterFlush(socket);
|
|
148
|
+
};
|
|
149
|
+
res.once('finish', settle);
|
|
150
|
+
res.once('close', settle);
|
|
151
|
+
// Never rejects: close() waits on it, and a catch-all that itself threw
|
|
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.
|
|
155
|
+
const done = this.handle(req, res)
|
|
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
|
+
})
|
|
170
|
+
.catch(() => void res.destroy());
|
|
171
|
+
this.handling.add(done);
|
|
172
|
+
void done.finally(() => this.handling.delete(done));
|
|
173
|
+
});
|
|
174
|
+
server.on('connection', (socket) => {
|
|
175
|
+
this.sockets.add(socket);
|
|
176
|
+
socket.once('close', () => {
|
|
177
|
+
this.sockets.delete(socket);
|
|
178
|
+
this.pending.delete(socket);
|
|
179
|
+
});
|
|
81
180
|
});
|
|
82
181
|
// Slow-loris mitigation behind the tunnel.
|
|
83
182
|
server.headersTimeout = 15_000;
|
|
@@ -93,15 +192,72 @@ export class HttpTransportHost {
|
|
|
93
192
|
});
|
|
94
193
|
this.log(`listening on ${config.host}:${config.port} (public ${config.publicUrl}, transport ${config.transport})`);
|
|
95
194
|
}
|
|
96
|
-
|
|
195
|
+
/** Without graceMs, every connection is cut at once, even during a graceful
|
|
196
|
+
* close. With it, the host stops accepting, lets in-flight requests finish
|
|
197
|
+
* and their responses finish writing, then resolves once each client has
|
|
198
|
+
* closed its side; whatever is still open after graceMs is cut. A repeat
|
|
199
|
+
* call with graceMs during a graceful close joins it, keeping the first
|
|
200
|
+
* grace. graceMs must be a number from 0 to 2^31-1 ms (about 24.8 days, the
|
|
201
|
+
* longest delay a timer holds); anything else, Infinity included, rejects
|
|
202
|
+
* with a TypeError and leaves the host as it was. */
|
|
203
|
+
async close(opts = {}) {
|
|
204
|
+
const { graceMs } = opts;
|
|
205
|
+
if (graceMs !== undefined && !(typeof graceMs === 'number' && graceMs >= 0 && graceMs <= MAX_GRACE_MS)) {
|
|
206
|
+
throw new TypeError(`graceMs must be a number from 0 to ${MAX_GRACE_MS}`);
|
|
207
|
+
}
|
|
97
208
|
const s = this.httpServer;
|
|
98
209
|
if (!s)
|
|
99
210
|
return;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
211
|
+
if (graceMs === undefined) {
|
|
212
|
+
await new Promise((resolve) => {
|
|
213
|
+
s.close(() => resolve());
|
|
214
|
+
s.closeAllConnections?.();
|
|
215
|
+
});
|
|
216
|
+
this.httpServer = undefined;
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Starting over would destroy every socket in its lingering close.
|
|
220
|
+
this.graceful ??= this.closeGracefully(s, graceMs).finally(() => (this.graceful = undefined));
|
|
221
|
+
return this.graceful;
|
|
222
|
+
}
|
|
223
|
+
async closeGracefully(s, graceMs) {
|
|
224
|
+
this.closing = true;
|
|
225
|
+
// Node's close() also destroys every connection it deems idle, and that
|
|
226
|
+
// includes one whose response has ended but is still flushing: a large
|
|
227
|
+
// body would be cut short. Idle sockets are closed below instead.
|
|
228
|
+
const nodeIdleCloser = s.closeIdleConnections;
|
|
229
|
+
s.closeIdleConnections = () => undefined;
|
|
230
|
+
const closed = new Promise((resolve) => s.close(() => resolve()));
|
|
231
|
+
s.closeIdleConnections = nodeIdleCloser;
|
|
232
|
+
// Every request accepted before now is answered, and a connection ends
|
|
233
|
+
// after the last of them: that one says so if it has not started, and the
|
|
234
|
+
// settle hook in start() ends the socket once it finishes. Node itself
|
|
235
|
+
// destroys a socket once a Connection: close response is flushed, which
|
|
236
|
+
// resets it just as endAfterFlush explains, so that end lingers too.
|
|
237
|
+
for (const socket of this.sockets) {
|
|
238
|
+
const entry = this.pending.get(socket);
|
|
239
|
+
if (!entry) {
|
|
240
|
+
socket.destroy();
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
socket.destroySoon = () => endAfterFlush(socket);
|
|
244
|
+
if (!entry.last.headersSent)
|
|
245
|
+
entry.last.setHeader('Connection', 'close');
|
|
246
|
+
}
|
|
247
|
+
let timer;
|
|
248
|
+
const expired = new Promise((resolve) => {
|
|
249
|
+
timer = setTimeout(resolve, graceMs);
|
|
103
250
|
});
|
|
104
|
-
|
|
251
|
+
try {
|
|
252
|
+
await Promise.race([closed.then(() => Promise.all(this.handling)), expired]);
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
s.closeAllConnections();
|
|
257
|
+
await closed;
|
|
258
|
+
this.httpServer = undefined;
|
|
259
|
+
this.closing = false;
|
|
260
|
+
}
|
|
105
261
|
}
|
|
106
262
|
/** The bound port (useful when listening on port 0 in tests). */
|
|
107
263
|
address() {
|
|
@@ -154,6 +310,14 @@ export class HttpTransportHost {
|
|
|
154
310
|
}
|
|
155
311
|
if (!this.frontGuard(req, res))
|
|
156
312
|
return;
|
|
313
|
+
// Registered before anything awaits: a client that leaves while its request
|
|
314
|
+
// waits on the lane must not have it dispatched once its turn comes. A
|
|
315
|
+
// response queued behind another on its connection never emits 'close', so
|
|
316
|
+
// the socket is checked too.
|
|
317
|
+
let gone = false;
|
|
318
|
+
res.once('close', () => (gone = true));
|
|
319
|
+
const socket = req.socket;
|
|
320
|
+
const clientGone = () => gone || socket.destroyed;
|
|
157
321
|
const auth = await this.opts.authenticate(req);
|
|
158
322
|
if (!auth.ok) {
|
|
159
323
|
for (const [k, v] of Object.entries(auth.headers ?? {}))
|
|
@@ -186,6 +350,31 @@ export class HttpTransportHost {
|
|
|
186
350
|
return;
|
|
187
351
|
}
|
|
188
352
|
}
|
|
353
|
+
// Checked before the body read, so a refused request never buffers its body.
|
|
354
|
+
const cap = this.opts.maxQueuedPerLane;
|
|
355
|
+
if (cap === undefined)
|
|
356
|
+
return this.admitted(req, res, target, sub, clientGone);
|
|
357
|
+
const lane = target.server;
|
|
358
|
+
const held = this.depth.get(lane) ?? 0;
|
|
359
|
+
if (held >= cap) {
|
|
360
|
+
res.setHeader('Retry-After', '1');
|
|
361
|
+
this.fail(res, 429, 'lane_busy', 'too many requests are already queued for this server');
|
|
362
|
+
this.log('429 lane_busy path=/mcp');
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
this.depth.set(lane, held + 1);
|
|
366
|
+
try {
|
|
367
|
+
await this.admitted(req, res, target, sub, clientGone);
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
const left = (this.depth.get(lane) ?? 1) - 1;
|
|
371
|
+
if (left > 0)
|
|
372
|
+
this.depth.set(lane, left);
|
|
373
|
+
else
|
|
374
|
+
this.depth.delete(lane);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async admitted(req, res, target, sub, clientGone) {
|
|
189
378
|
// Thread the verified subject to tool handlers: the Node transport forwards
|
|
190
379
|
// req.auth verbatim as ctx.http.authInfo. Token/clientId stay empty — the
|
|
191
380
|
// bearer value must not re-enter the dispatch path via handler context.
|
|
@@ -202,8 +391,9 @@ export class HttpTransportHost {
|
|
|
202
391
|
catch (e) {
|
|
203
392
|
return this.fail(res, 400, 'invalid_body', e.message);
|
|
204
393
|
}
|
|
205
|
-
|
|
206
|
-
|
|
394
|
+
const maxFrames = this.opts.maxBatchFrames ?? MAX_BATCH_FRAMES;
|
|
395
|
+
if (Array.isArray(body) && body.length > maxFrames) {
|
|
396
|
+
return this.fail(res, 400, 'batch_too_large', `a JSON-RPC batch may hold at most ${maxFrames} messages`);
|
|
207
397
|
}
|
|
208
398
|
// Host/Origin are enforced by the front guard above (uniformly for /mcp and
|
|
209
399
|
// the mounted AS routes), so the SDK's own DNS-rebind guard is disabled: its
|
|
@@ -229,6 +419,10 @@ export class HttpTransportHost {
|
|
|
229
419
|
// subjects to one) would otherwise race it through the connect gap.
|
|
230
420
|
const { server: mcpServer, argShapeFor, strictArgs, validationEnvelope } = target;
|
|
231
421
|
await this.serializeFor(mcpServer, async () => {
|
|
422
|
+
if (clientGone()) {
|
|
423
|
+
this.log('499 client_gone path=/mcp');
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
232
426
|
const disconnected = new Promise((resolve) => res.once('close', () => resolve('closed')));
|
|
233
427
|
let timer;
|
|
234
428
|
const timedOut = new Promise((resolve) => {
|
|
@@ -294,6 +488,10 @@ export class HttpTransportHost {
|
|
|
294
488
|
readJson(req) {
|
|
295
489
|
const max = this.opts.maxBodyBytes ?? 4_000_000;
|
|
296
490
|
return new Promise((resolve, reject) => {
|
|
491
|
+
// A client that left while the request was authenticated or resolved has
|
|
492
|
+
// already destroyed it, and no 'end' or 'error' would ever follow.
|
|
493
|
+
if (req.destroyed)
|
|
494
|
+
return reject(new Error('request aborted'));
|
|
297
495
|
let size = 0;
|
|
298
496
|
const chunks = [];
|
|
299
497
|
req.on('data', (c) => {
|
|
@@ -322,6 +520,12 @@ export class HttpTransportHost {
|
|
|
322
520
|
fail(res, status, error, message) {
|
|
323
521
|
if (res.writableEnded)
|
|
324
522
|
return;
|
|
523
|
+
// A response already started cannot become an error one; cutting it tells
|
|
524
|
+
// the client it is incomplete.
|
|
525
|
+
if (res.headersSent) {
|
|
526
|
+
res.destroy();
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
325
529
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
326
530
|
res.end(JSON.stringify({ error, message }));
|
|
327
531
|
}
|
package/dist/mcp-token.d.ts
CHANGED
|
@@ -110,8 +110,7 @@ export declare class RefreshStore {
|
|
|
110
110
|
sub: string;
|
|
111
111
|
} | null;
|
|
112
112
|
/** Drop every ACTIVE record minted under `sub` (linear scan under the store
|
|
113
|
-
* lock)
|
|
114
|
-
*
|
|
115
|
-
* of active tokens dropped. */
|
|
113
|
+
* lock); the save then drops the spent entries of every family left with no
|
|
114
|
+
* active token. Returns the number of active tokens dropped. */
|
|
116
115
|
purgeTenant(sub: string): number;
|
|
117
116
|
}
|
package/dist/mcp-token.js
CHANGED
|
@@ -155,11 +155,42 @@ export class RefreshStore {
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
save(data) {
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
158
|
+
// A family with no active token has nothing left to revoke. Over the cap,
|
|
159
|
+
// the subject holding the most spent tokens loses its oldest (ties: the
|
|
160
|
+
// oldest entry), so a subject's rotations, across all its families, only
|
|
161
|
+
// push out its own history (docs/internals.md). Insertion order = rotation
|
|
162
|
+
// order.
|
|
163
|
+
const owner = new Map();
|
|
164
|
+
for (const r of Object.values(data.active))
|
|
165
|
+
owner.set(r.family, r.sub);
|
|
166
|
+
const order = Object.keys(data.spent);
|
|
167
|
+
const held = new Map();
|
|
168
|
+
let total = 0;
|
|
169
|
+
order.forEach((t, i) => {
|
|
170
|
+
const sub = owner.get(data.spent[t]);
|
|
171
|
+
if (sub === undefined) {
|
|
172
|
+
delete data.spent[t];
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const h = held.get(sub);
|
|
176
|
+
if (h)
|
|
177
|
+
h.at.push(i);
|
|
178
|
+
else
|
|
179
|
+
held.set(sub, { at: [i], head: 0 });
|
|
180
|
+
total += 1;
|
|
181
|
+
});
|
|
182
|
+
while (total > SPENT_CAP) {
|
|
183
|
+
let most = { at: [], head: 0 };
|
|
184
|
+
let mostN = 0;
|
|
185
|
+
for (const h of held.values()) {
|
|
186
|
+
const n = h.at.length - h.head;
|
|
187
|
+
if (n > mostN || (n === mostN && n > 0 && h.at[h.head] < most.at[most.head])) {
|
|
188
|
+
most = h;
|
|
189
|
+
mostN = n;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
delete data.spent[order[most.at[most.head++]]];
|
|
193
|
+
total -= 1;
|
|
163
194
|
}
|
|
164
195
|
atomicWriteFileSync(this.path, encryptToken(data, this.masterKey), 0o600);
|
|
165
196
|
}
|
|
@@ -212,9 +243,8 @@ export class RefreshStore {
|
|
|
212
243
|
});
|
|
213
244
|
}
|
|
214
245
|
/** Drop every ACTIVE record minted under `sub` (linear scan under the store
|
|
215
|
-
* lock)
|
|
216
|
-
*
|
|
217
|
-
* of active tokens dropped. */
|
|
246
|
+
* lock); the save then drops the spent entries of every family left with no
|
|
247
|
+
* active token. Returns the number of active tokens dropped. */
|
|
218
248
|
purgeTenant(sub) {
|
|
219
249
|
return withFileLock(this.path, () => {
|
|
220
250
|
const data = this.load();
|
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.62",
|
|
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",
|