mcp-google-multi 6.0.0-beta.3 → 6.0.0-beta.4
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 +5 -5
- package/dist/accounts.d.ts +3 -3
- package/dist/accounts.js +18 -31
- package/dist/arg-normalize.js +15 -35
- package/dist/arg-strict.js +8 -28
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +5 -10
- package/dist/boot-gates.d.ts +1 -1
- package/dist/boot-gates.js +3 -5
- package/dist/cimd-cache.js +5 -11
- package/dist/client-config.d.ts +2 -2
- package/dist/client-config.js +8 -14
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +8 -1
- package/dist/compose.js +11 -11
- package/dist/config-file.js +7 -12
- package/dist/discover.js +4 -9
- package/dist/discovery-client.js +14 -33
- package/dist/doctor.d.ts +3 -3
- package/dist/doctor.js +12 -18
- package/dist/env-load.js +2 -5
- package/dist/executor.js +2 -4
- package/dist/fanout.js +8 -17
- package/dist/fs-atomic.d.ts +0 -3
- package/dist/fs-atomic.js +6 -10
- package/dist/hint-provider.js +4 -10
- package/dist/http-config.d.ts +2 -2
- package/dist/http-config.js +3 -7
- package/dist/http-transport.d.ts +5 -5
- package/dist/http-transport.js +39 -92
- package/dist/identity.d.ts +2 -2
- package/dist/identity.js +6 -9
- package/dist/index.js +19 -31
- package/dist/master-key.d.ts +3 -3
- package/dist/master-key.js +11 -22
- package/dist/mcp-token.d.ts +3 -13
- package/dist/mcp-token.js +16 -32
- package/dist/metrics-cli.d.ts +1 -1
- package/dist/metrics-cli.js +1 -1
- package/dist/metrics-tap.js +2 -5
- package/dist/migrate-config.d.ts +1 -2
- package/dist/migrate-config.js +3 -5
- package/dist/net-tuning.js +2 -4
- package/dist/oauth-as.d.ts +2 -2
- package/dist/oauth-as.js +32 -73
- package/dist/oauth-consent.js +4 -10
- package/dist/outbound-allowlist.js +4 -9
- package/dist/reauth-hint.js +2 -5
- package/dist/registry-transfer.d.ts +1 -1
- package/dist/registry-transfer.js +5 -13
- package/dist/registry.d.ts +1 -9
- package/dist/registry.js +38 -66
- package/dist/scope-catalog.d.ts +5 -0
- package/dist/scope-catalog.js +17 -7
- package/dist/scope-observability.d.ts +2 -3
- package/dist/scope-observability.js +8 -14
- package/dist/services.d.ts +3 -0
- package/dist/services.js +5 -10
- package/dist/ssrf-guard.js +11 -22
- package/dist/tenant-purge.js +2 -4
- package/dist/token-store.js +0 -0
- package/dist/tools/_coerce.d.ts +4 -4
- package/dist/tools/_errors.js +22 -39
- package/dist/tools/_local-files.js +4 -8
- package/dist/tools/account-wizard.d.ts +4 -3
- package/dist/tools/account-wizard.js +48 -54
- package/dist/tools/accounts-tool.d.ts +1 -1
- package/dist/tools/accounts-tool.js +1 -1
- package/dist/tools/admin.js +2 -3
- package/dist/tools/analytics.js +4 -5
- package/dist/tools/calendar.js +4 -7
- package/dist/tools/chat.js +2 -3
- package/dist/tools/contacts.js +8 -9
- package/dist/tools/docs.js +4 -9
- package/dist/tools/drive.js +18 -27
- package/dist/tools/forms.js +2 -3
- package/dist/tools/gmail-mime.d.ts +1 -1
- package/dist/tools/gmail-mime.js +7 -12
- package/dist/tools/gmail.d.ts +2 -2
- package/dist/tools/gmail.js +26 -40
- package/dist/tools/google-api.js +5 -7
- package/dist/tools/meet.js +2 -3
- package/dist/tools/searchconsole.js +2 -3
- package/dist/tools/sheets.js +2 -3
- package/dist/tools/slides.js +2 -3
- package/dist/tools/tasks.js +2 -3
- package/dist/trim.js +2 -3
- package/dist/usage-metrics.js +5 -12
- package/dist/write-control.d.ts +4 -8
- package/dist/write-control.js +3 -4
- package/package.json +21 -21
package/dist/http-transport.js
CHANGED
|
@@ -1,30 +1,21 @@
|
|
|
1
1
|
import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
|
|
2
|
-
// node:http server, the route table, the front guard (Host / Origin / DNS-rebind),
|
|
3
|
-
// and the stateless per-request /mcp dispatch. The OAuth AS endpoints and Bearer
|
|
4
|
-
// verification are a seam filled by B13 (oauth-authorization-server); B12 ships a
|
|
5
|
-
// loopback-owner authenticator so the local-HTTP model works before the AS lands.
|
|
6
2
|
import { createServer } from 'node:http';
|
|
7
3
|
import { withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
|
|
8
4
|
import { logSafe } from './trim.js';
|
|
9
5
|
// A host wrapping the log callbacks it hands core needs this, and trim has no package export.
|
|
10
6
|
export { logSafe } from './trim.js';
|
|
11
7
|
// A hung handler that keeps the connection open would otherwise hold the global
|
|
12
|
-
// serialize() lock forever
|
|
13
|
-
// Drive exports, fan-out) still finish; the point is only to guarantee release.
|
|
8
|
+
// serialize() lock forever; generous by default so slow-but-valid calls still finish.
|
|
14
9
|
const DISPATCH_TIMEOUT_DEFAULT = 120_000;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
* per-call work by however many frames fit in it. */
|
|
10
|
+
// Without a cap, one body multiplies per-call work by however many
|
|
11
|
+
// JSON-RPC frames the SDK's synchronous batch screening finds in it.
|
|
18
12
|
const MAX_BATCH_FRAMES = 16;
|
|
19
|
-
// A
|
|
20
|
-
//
|
|
21
|
-
// shaped slug or an overlong message. No new slug literal enters core itself —
|
|
22
|
-
// the slug is always the host's own value.
|
|
13
|
+
// A refusal's body reaches the wire verbatim, so it's validated: no new slug
|
|
14
|
+
// literal enters core, and an overlong message can't leak an operator path/env var.
|
|
23
15
|
const REFUSAL_SLUG_RE = /^[a-z][a-z0-9_]{0,39}$/;
|
|
24
16
|
const MAX_REFUSAL_MESSAGE_CHARS = 300;
|
|
25
|
-
// C0/C1 controls, format chars (
|
|
26
|
-
//
|
|
27
|
-
// letters stay allowed.
|
|
17
|
+
// C0/C1 controls, format chars (bidi overrides, zero-width joiners) and
|
|
18
|
+
// line/paragraph separators; ordinary non-ASCII letters stay allowed.
|
|
28
19
|
const NON_PRINTABLE_RE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
|
29
20
|
function isValidRefusal(r) {
|
|
30
21
|
return (typeof r.error === 'string' &&
|
|
@@ -42,7 +33,7 @@ export function parseOwnerEmails(env = process.env) {
|
|
|
42
33
|
}
|
|
43
34
|
/** Front guard: an Origin, if present, must be allowlisted; a Host must be
|
|
44
35
|
* allowlisted. Absent Origin passes (native/CLI/backend clients — the claude.ai
|
|
45
|
-
* connector calls /mcp server-to-server with no Origin
|
|
36
|
+
* connector calls /mcp server-to-server with no Origin). */
|
|
46
37
|
export function originAllowed(origin, allowed) {
|
|
47
38
|
if (!origin)
|
|
48
39
|
return true;
|
|
@@ -81,25 +72,17 @@ export function jsonRpcMethod(body) {
|
|
|
81
72
|
/** setTimeout fires at once for a delay above this, so a longer grace would
|
|
82
73
|
* cut every connection immediately. */
|
|
83
74
|
const MAX_GRACE_MS = 2 ** 31 - 1;
|
|
84
|
-
// A lingering close
|
|
85
|
-
// body pipelined after the close) is reset by the kernel, which drops whatever
|
|
86
|
-
// of the last response is still unsent. So the socket only ends its side and
|
|
87
|
-
// waits for the client's FIN, or for the grace to end.
|
|
75
|
+
// A lingering close (ends the socket, never destroys it); see docs/internals.md (Graceful close).
|
|
88
76
|
function endAfterFlush(socket) {
|
|
89
77
|
if (socket.destroyed || socket.writableEnded)
|
|
90
78
|
return;
|
|
91
|
-
//
|
|
92
|
-
// socket
|
|
93
|
-
// an early destroy does. Only the client's FIN or the grace ends it now.
|
|
79
|
+
// Disarm the keep-alive timer Node arms on finish, or it destroys the
|
|
80
|
+
// socket ~6s later and cuts a slow reader's unread tail the same way an early destroy does.
|
|
94
81
|
socket.setTimeout(0);
|
|
95
82
|
socket.end();
|
|
96
83
|
}
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
// pile up without limit. Reading stops for good instead, which lets TCP flow
|
|
100
|
-
// control stop the client. Node resumes a socket after every parsed request
|
|
101
|
-
// and on drain, so resume becomes a no-op. Such a socket never sees the
|
|
102
|
-
// client's FIN either: the grace ends it.
|
|
84
|
+
// Stops reading for good so TCP flow control stops the client, instead of
|
|
85
|
+
// piling up a flood pipelined behind a held response; see docs/internals.md (Graceful close).
|
|
103
86
|
function stopReading(socket) {
|
|
104
87
|
socket.pause();
|
|
105
88
|
socket.resume = () => socket;
|
|
@@ -117,18 +100,12 @@ function pathOf(req) {
|
|
|
117
100
|
export class HttpTransportHost {
|
|
118
101
|
opts;
|
|
119
102
|
httpServer;
|
|
120
|
-
//
|
|
121
|
-
// captures its transport per request (protocol.js), but the gap between
|
|
122
|
-
// connect() and the dispatch capturing it would still race under true
|
|
123
|
-
// concurrency, so all traffic to ONE server runs under one mutex. The free
|
|
124
|
-
// core has one lane (the boot server); with resolveServer each resolved
|
|
125
|
-
// server gets its own, however subjects map onto servers.
|
|
103
|
+
// Per-server dispatch mutex; see docs/internals.md.
|
|
126
104
|
locks = new Map();
|
|
127
105
|
// Requests holding a lane slot, per server; used only with maxQueuedPerLane.
|
|
128
106
|
depth = new Map();
|
|
129
|
-
// What a graceful close waits on: open sockets
|
|
130
|
-
//
|
|
131
|
-
// every handle() that has not settled.
|
|
107
|
+
// What a graceful close waits on: open sockets and every handle() not yet
|
|
108
|
+
// settled; see docs/internals.md (Graceful close).
|
|
132
109
|
sockets = new Set();
|
|
133
110
|
pending = new Map();
|
|
134
111
|
// Sockets that have accepted a request: nothing pending means the last
|
|
@@ -145,9 +122,8 @@ export class HttpTransportHost {
|
|
|
145
122
|
const run = prev.then(fn, fn);
|
|
146
123
|
const tail = run.then(() => undefined, () => undefined);
|
|
147
124
|
this.locks.set(key, tail);
|
|
148
|
-
// An idle lane drops out
|
|
149
|
-
//
|
|
150
|
-
// already replaced it, so the identity check never frees a live lane.
|
|
125
|
+
// An idle lane drops out of the map; the identity check below skips a
|
|
126
|
+
// later request that already replaced this tail, so it never frees a live lane.
|
|
151
127
|
void tail.then(() => {
|
|
152
128
|
if (this.locks.get(key) === tail)
|
|
153
129
|
this.locks.delete(key);
|
|
@@ -161,9 +137,8 @@ export class HttpTransportHost {
|
|
|
161
137
|
const { config } = this.opts;
|
|
162
138
|
const server = createServer((req, res) => {
|
|
163
139
|
const socket = req.socket;
|
|
164
|
-
// Once closing, a
|
|
165
|
-
//
|
|
166
|
-
// Its body is left unread: nothing reads the socket again.
|
|
140
|
+
// Once closing, a request arriving now is never handled (RFC 9112 §9.6)
|
|
141
|
+
// and its body is left unread.
|
|
167
142
|
if (this.closing) {
|
|
168
143
|
stopReading(socket);
|
|
169
144
|
return;
|
|
@@ -190,10 +165,8 @@ export class HttpTransportHost {
|
|
|
190
165
|
};
|
|
191
166
|
res.once('finish', settle);
|
|
192
167
|
res.once('close', settle);
|
|
193
|
-
// Never rejects
|
|
194
|
-
//
|
|
195
|
-
// The thrown message can name server paths (a lock timeout does), so it
|
|
196
|
-
// goes only to the host's log.
|
|
168
|
+
// Never rejects (close() waits on it); the thrown message can name
|
|
169
|
+
// server paths, so it goes only to the host's log.
|
|
197
170
|
const done = this.handle(req, res)
|
|
198
171
|
.catch((e) => {
|
|
199
172
|
try {
|
|
@@ -265,20 +238,13 @@ export class HttpTransportHost {
|
|
|
265
238
|
}
|
|
266
239
|
async closeGracefully(s, graceMs) {
|
|
267
240
|
this.closing = true;
|
|
268
|
-
// Node's close() also destroys
|
|
269
|
-
//
|
|
270
|
-
// body would be cut short. Idle sockets are closed below instead.
|
|
241
|
+
// Node's close() also destroys idle connections, which would cut short a
|
|
242
|
+
// large response still flushing; idle sockets are closed below instead.
|
|
271
243
|
const nodeIdleCloser = s.closeIdleConnections;
|
|
272
244
|
s.closeIdleConnections = () => undefined;
|
|
273
245
|
const closed = new Promise((resolve) => s.close(() => resolve()));
|
|
274
246
|
s.closeIdleConnections = nodeIdleCloser;
|
|
275
|
-
//
|
|
276
|
-
// after the last of them: that one says so if it has not started, and the
|
|
277
|
-
// settle hook in start() ends the socket once it finishes. Node itself
|
|
278
|
-
// destroys a socket once a Connection: close response is flushed, which
|
|
279
|
-
// resets it just as endAfterFlush explains, so that end lingers too. A
|
|
280
|
-
// socket with nothing pending may still be delivering the tail of its
|
|
281
|
-
// last response, so only one that never accepted a request is destroyed.
|
|
247
|
+
// Socket bookkeeping below; see docs/internals.md (Graceful close).
|
|
282
248
|
for (const socket of this.sockets) {
|
|
283
249
|
const entry = this.pending.get(socket);
|
|
284
250
|
if (!entry && !this.served.has(socket)) {
|
|
@@ -357,10 +323,7 @@ export class HttpTransportHost {
|
|
|
357
323
|
}
|
|
358
324
|
if (!this.frontGuard(req, res))
|
|
359
325
|
return;
|
|
360
|
-
// Registered before anything awaits
|
|
361
|
-
// waits on the lane must not have it dispatched once its turn comes. A
|
|
362
|
-
// response queued behind another on its connection never emits 'close', so
|
|
363
|
-
// the socket is checked too.
|
|
326
|
+
// Registered before anything awaits (client-gone skip); see docs/internals.md.
|
|
364
327
|
let gone = false;
|
|
365
328
|
res.once('close', () => (gone = true));
|
|
366
329
|
const socket = req.socket;
|
|
@@ -375,8 +338,8 @@ export class HttpTransportHost {
|
|
|
375
338
|
return;
|
|
376
339
|
}
|
|
377
340
|
const sub = auth.sub ?? 'owner';
|
|
378
|
-
// Tenant resolution (
|
|
379
|
-
// server. Absent resolver = the one boot server for every subject.
|
|
341
|
+
// Tenant resolution (a multi-tenant host fills this seam): map the
|
|
342
|
+
// verified subject to ITS server. Absent resolver = the one boot server for every subject.
|
|
380
343
|
let target = {
|
|
381
344
|
server: this.opts.server,
|
|
382
345
|
argShapeFor: this.opts.argShapeFor,
|
|
@@ -400,8 +363,7 @@ export class HttpTransportHost {
|
|
|
400
363
|
return;
|
|
401
364
|
}
|
|
402
365
|
// An invalid shape falls through to today's exact answer rather than
|
|
403
|
-
// forwarding something unvalidated
|
|
404
|
-
// either way.
|
|
366
|
+
// forwarding something unvalidated.
|
|
405
367
|
resolved = null;
|
|
406
368
|
}
|
|
407
369
|
target = resolved;
|
|
@@ -437,9 +399,8 @@ export class HttpTransportHost {
|
|
|
437
399
|
}
|
|
438
400
|
}
|
|
439
401
|
async admitted(req, res, target, sub, clientGone) {
|
|
440
|
-
// Thread the verified subject to tool handlers
|
|
441
|
-
//
|
|
442
|
-
// bearer value must not re-enter the dispatch path via handler context.
|
|
402
|
+
// Thread the verified subject to tool handlers via ctx.http.authInfo;
|
|
403
|
+
// token/clientId stay empty so the bearer value never re-enters the dispatch path.
|
|
443
404
|
req.auth = {
|
|
444
405
|
token: '',
|
|
445
406
|
clientId: '',
|
|
@@ -457,28 +418,16 @@ export class HttpTransportHost {
|
|
|
457
418
|
if (Array.isArray(body) && body.length > maxFrames) {
|
|
458
419
|
return this.fail(res, 400, 'batch_too_large', `a JSON-RPC batch may hold at most ${maxFrames} messages`);
|
|
459
420
|
}
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
// exact-Host match is stricter than the front guard and would 403 valid
|
|
463
|
-
// Hosts (double enforcement, differing rules).
|
|
421
|
+
// The SDK's own DNS-rebind guard is disabled: Host/Origin are already
|
|
422
|
+
// enforced above, and its stricter exact-Host match would 403 valid Hosts.
|
|
464
423
|
const transport = new NodeStreamableHTTPServerTransport({
|
|
465
424
|
sessionIdGenerator: undefined,
|
|
466
425
|
enableJsonResponse: true,
|
|
467
426
|
enableDnsRebindingProtection: false,
|
|
468
427
|
});
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
// can never hit "Already connected". The serialized section is guaranteed to
|
|
472
|
-
// settle three ways — the handler finishes, the client disconnects (res
|
|
473
|
-
// 'close'), or a dispatch deadline fires — so neither a client that holds the
|
|
474
|
-
// connection open nor a hung upstream can wedge the lock. The transport is
|
|
475
|
-
// closed in a finally (which resets server._transport) before the lock
|
|
476
|
-
// releases. (A shared initialized-state persists across stateless requests;
|
|
477
|
-
// benign for the single-owner design.)
|
|
428
|
+
// connect + dispatch + disconnect run inside the per-server mutex, keyed
|
|
429
|
+
// on the server identity, never the subject; see docs/internals.md.
|
|
478
430
|
const deadlineMs = this.opts.dispatchTimeoutMs ?? DISPATCH_TIMEOUT_DEFAULT;
|
|
479
|
-
// The lock lane is the SERVER identity, never the subject: two subjects
|
|
480
|
-
// reaching one server (the boot server, or a resolver mapping several
|
|
481
|
-
// subjects to one) would otherwise race it through the connect gap.
|
|
482
431
|
const { server: mcpServer, argShapeFor, strictArgs, validationEnvelope } = target;
|
|
483
432
|
await this.serializeFor(mcpServer, async () => {
|
|
484
433
|
if (clientGone()) {
|
|
@@ -491,16 +440,14 @@ export class HttpTransportHost {
|
|
|
491
440
|
timer = setTimeout(() => resolve('timeout'), deadlineMs);
|
|
492
441
|
timer.unref?.();
|
|
493
442
|
});
|
|
494
|
-
//
|
|
495
|
-
// so the tap above it still classifies the original validation prose.
|
|
443
|
+
// Envelope rewrite innermost, as on stdio; see docs/internals.md (Argument normalization).
|
|
496
444
|
const enveloped = withValidationEnvelope(transport, validationEnvelope ?? {});
|
|
497
445
|
const tapped = this.opts.metricsTap ? this.opts.metricsTap(enveloped) : enveloped;
|
|
498
446
|
await mcpServer.connect(argShapeFor || strictArgs
|
|
499
447
|
? withArgNormalization(tapped, argShapeFor ?? (() => undefined), this.opts.log, this.opts.onArgRename, strictArgs)
|
|
500
448
|
: tapped);
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
// rejection — but a genuine dispatch error still propagates (rethrown below).
|
|
449
|
+
// Non-rejecting arm: an orphaned handler settling after the deadline wins
|
|
450
|
+
// must not surface as an unhandled rejection; a real error is rethrown below.
|
|
504
451
|
let dispatchErr;
|
|
505
452
|
const dispatchArm = transport
|
|
506
453
|
.handleRequest(req, res, body)
|
|
@@ -538,7 +485,7 @@ export class HttpTransportHost {
|
|
|
538
485
|
health(res) {
|
|
539
486
|
const { config, ownerConfigured, version } = this.opts;
|
|
540
487
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
541
|
-
// No secrets, no owner emails
|
|
488
|
+
// No secrets, no owner emails.
|
|
542
489
|
res.end(JSON.stringify({
|
|
543
490
|
status: 'ok',
|
|
544
491
|
transport: config.transport,
|
package/dist/identity.d.ts
CHANGED
|
@@ -4,8 +4,8 @@ import { hasToken, readToken, updateToken, writeToken } from './token-store.js';
|
|
|
4
4
|
import { type Policy, type Transport } from './write-control.js';
|
|
5
5
|
/**
|
|
6
6
|
* The one forward-compat seam (frozen public API): the free core builds
|
|
7
|
-
* exactly one context with subject "owner";
|
|
8
|
-
* from N registries. Nothing here may assume a global current-account.
|
|
7
|
+
* exactly one context with subject "owner"; a multi-tenant host instantiates
|
|
8
|
+
* N contexts from N registries. Nothing here may assume a global current-account.
|
|
9
9
|
* `subject` is a string (not the literal 'owner') so a context can carry a
|
|
10
10
|
* tenant id; the free core still always builds 'owner'.
|
|
11
11
|
*/
|
package/dist/identity.js
CHANGED
|
@@ -2,18 +2,15 @@ import { getAccountSet, getTokenDir, refreshAccountSetIfStale } from './accounts
|
|
|
2
2
|
import { makeGetClient } from './client.js';
|
|
3
3
|
import { createTokenStore } from './token-store.js';
|
|
4
4
|
import { resolvePolicy } from './write-control.js';
|
|
5
|
-
// Only buildIdentityContext mints the owner brand
|
|
6
|
-
// never unlocks
|
|
7
|
-
// operator diagnose report): a hand-built context claiming 'owner' stays a
|
|
8
|
-
// non-owner context.
|
|
5
|
+
// Only buildIdentityContext mints the owner brand: the subject string alone
|
|
6
|
+
// never unlocks owner-only surfaces, so a hand-built context claiming 'owner' stays non-owner.
|
|
9
7
|
const ownerContexts = new WeakSet();
|
|
10
8
|
export function isOwnerContext(ctx) {
|
|
11
9
|
return ctx !== undefined && ownerContexts.has(ctx);
|
|
12
10
|
}
|
|
13
11
|
export function buildIdentityContext(env = process.env, opts = {}) {
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// behavior-identical to the module-level functions for the free core.
|
|
12
|
+
// The SAME factories a multi-tenant host instantiates per tenant, bound here
|
|
13
|
+
// to the global registry + token dir.
|
|
17
14
|
const tokenStore = createTokenStore(getTokenDir());
|
|
18
15
|
const ctx = {
|
|
19
16
|
subject: 'owner',
|
|
@@ -22,8 +19,8 @@ export function buildIdentityContext(env = process.env, opts = {}) {
|
|
|
22
19
|
get accounts() {
|
|
23
20
|
return getAccountSet();
|
|
24
21
|
},
|
|
25
|
-
// The dispatch transport rides into the resolved policy as a reserved seam
|
|
26
|
-
//
|
|
22
|
+
// The dispatch transport rides into the resolved policy as a reserved seam;
|
|
23
|
+
// it does not change any write-control verdict.
|
|
27
24
|
policy: resolvePolicy(env, { transport: opts.transport }),
|
|
28
25
|
getClient: makeGetClient('owner', {
|
|
29
26
|
accounts: getAccountSet,
|
package/dist/index.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// First import: triggers accounts.js module load (env files + registry) before
|
|
3
|
-
// anything else. Named to also pull the server-only empty-registry guard
|
|
3
|
+
// anything else. Named to also pull the server-only empty-registry guard.
|
|
4
4
|
import { assertServerAccountsConfigured } from './accounts.js';
|
|
5
5
|
import { accountsAssertRequired, assertNoEnvAccountsMode, assertNoEnvOptionalScopesMode, isMultiTenantBoot, mtBootGates } from './boot-gates.js';
|
|
6
6
|
import { readFileSync } from 'node:fs';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
10
|
-
import { McpServer } from "@modelcontextprotocol/server";
|
|
11
10
|
import { resolveDiscoveryMode } from './registry.js';
|
|
12
11
|
import { isAllowed, describePolicy } from './write-control.js';
|
|
13
12
|
import { buildIdentityContext } from './identity.js';
|
|
14
|
-
import { buildRegistry, requestHooksFor } from './compose.js';
|
|
13
|
+
import { buildRegistry, createMcpServer, requestHooksFor } from './compose.js';
|
|
15
14
|
import { registerSetupPrompt } from './setup-prompt.js';
|
|
16
15
|
import { applyNetTuning } from './net-tuning.js';
|
|
17
16
|
import { withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
|
|
@@ -81,7 +80,7 @@ async function main() {
|
|
|
81
80
|
if (process.argv.includes('config') && process.argv.includes('check')) {
|
|
82
81
|
const ctx = buildIdentityContext();
|
|
83
82
|
const policy = ctx.policy;
|
|
84
|
-
const registry = buildRegistry(
|
|
83
|
+
const registry = buildRegistry(createMcpServer(pkg.version), ctx);
|
|
85
84
|
const cud = registry.tools.filter((t) => t.cud !== 'read');
|
|
86
85
|
const disabled = cud.filter((t) => !isAllowed(t, policy));
|
|
87
86
|
const counts = registry.visibleCount();
|
|
@@ -140,10 +139,8 @@ async function main() {
|
|
|
140
139
|
}
|
|
141
140
|
const wantStdio = httpCfg.transport === 'stdio' || httpCfg.transport === 'both';
|
|
142
141
|
const wantHttp = transportIncludesHttp(httpCfg.transport);
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// returned. A multi-tenant boot instead refuses the two process-wide env
|
|
146
|
-
// vectors that would leak one operator's accounts/scopes into every tenant.
|
|
142
|
+
// The SERVER never boots with an empty registry (stdio always, HTTP when
|
|
143
|
+
// single-owner); a multi-tenant boot instead refuses the two process-wide env vectors.
|
|
147
144
|
const mt = isMultiTenantBoot();
|
|
148
145
|
if (accountsAssertRequired(wantStdio, mt))
|
|
149
146
|
assertServerAccountsConfigured();
|
|
@@ -155,9 +152,7 @@ async function main() {
|
|
|
155
152
|
// specced "fatal at startup", never mid-dispatch.
|
|
156
153
|
const { resolveMasterKey } = await import('./master-key.js');
|
|
157
154
|
resolveMasterKey();
|
|
158
|
-
//
|
|
159
|
-
// when off (no wrapper, no dir, nothing initializes). One instance per
|
|
160
|
-
// transport so `boots` keys stay honest under `both`.
|
|
155
|
+
// One Metrics instance per transport so `boots` keys stay honest under `both`.
|
|
161
156
|
const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
|
|
162
157
|
const metricsState = resolveUsageMetrics(process.env, loadConfigFile()?.usageMetrics, envSrc?.kind === 'file' ? envSrc.file : undefined);
|
|
163
158
|
if (metricsState.warning)
|
|
@@ -187,21 +182,17 @@ async function main() {
|
|
|
187
182
|
});
|
|
188
183
|
}
|
|
189
184
|
}
|
|
190
|
-
// Build one McpServer + registry per transport at boot (
|
|
191
|
-
//
|
|
185
|
+
// Build one McpServer + registry per transport at boot (never
|
|
186
|
+
// rebuilt per request); `both` runs the two concurrently.
|
|
192
187
|
if (wantStdio) {
|
|
193
|
-
const server =
|
|
188
|
+
const server = createMcpServer(pkg.version);
|
|
194
189
|
const stdioMetrics = initMetricsFor('stdio', resolveDiscoveryMode());
|
|
195
190
|
const stdioCtx = buildIdentityContext(process.env, { transport: 'stdio' });
|
|
196
191
|
const registry = buildRegistry(server, stdioCtx, undefined, stdioMetrics);
|
|
197
192
|
registry.installListHandler();
|
|
198
193
|
registerSetupPrompt(server);
|
|
199
194
|
const stdioHooks = requestHooksFor(registry, stdioCtx, stdioMetrics);
|
|
200
|
-
//
|
|
201
|
-
// envelope rewrite is INNERMOST (last to touch the frame), the tap sits
|
|
202
|
-
// above it (classifying the ORIGINAL validation prose), arg normalization
|
|
203
|
-
// outermost. The tap only counts outbound JSON-RPC error frames (no
|
|
204
|
-
// handler ran) plus id->tool names.
|
|
195
|
+
// Composition order (outbound runs outermost-first): see docs/internals.md (Argument normalization).
|
|
205
196
|
let transport = withValidationEnvelope(new StdioServerTransport(), stdioHooks.validationEnvelope);
|
|
206
197
|
if (stdioMetrics) {
|
|
207
198
|
const { tapUsageMetrics } = await import('./metrics-tap.js');
|
|
@@ -209,10 +200,8 @@ async function main() {
|
|
|
209
200
|
}
|
|
210
201
|
await server.connect(stdioHooks.argShapeFor || stdioHooks.strictArgs
|
|
211
202
|
? withArgNormalization(transport,
|
|
212
|
-
// Gated exactly as the HTTP leg gates
|
|
213
|
-
//
|
|
214
|
-
// GOOGLE_ARG_NORMALIZE=off, so the same call succeeded on stdio
|
|
215
|
-
// and failed on HTTP once screening rejects.
|
|
203
|
+
// Gated exactly as the HTTP leg gates argShapeFor, or GOOGLE_ARG_NORMALIZE=off
|
|
204
|
+
// would rename keys on stdio while HTTP's screening rejects the same call.
|
|
216
205
|
stdioHooks.argShapeFor ?? (() => undefined), undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined, stdioHooks.strictArgs)
|
|
217
206
|
: transport);
|
|
218
207
|
}
|
|
@@ -221,31 +210,30 @@ async function main() {
|
|
|
221
210
|
const owners = parseOwnerEmails(process.env);
|
|
222
211
|
const gates = mtBootGates();
|
|
223
212
|
if (gates?.multiTenant) {
|
|
224
|
-
// Same fail-fast
|
|
225
|
-
// must never boot; under tenancy the gate is "a valid provisioning
|
|
213
|
+
// Same fail-fast shape as below, but the gate is "a valid provisioning
|
|
226
214
|
// mechanism exists", not a flat owner allowlist.
|
|
227
215
|
gates.assertProvisioningGate();
|
|
228
216
|
}
|
|
229
217
|
else {
|
|
230
|
-
//
|
|
218
|
+
// The owner allowlist is the entire multi-tenant collapse;
|
|
231
219
|
// refuse to open an ungated HTTP endpoint.
|
|
232
220
|
if (owners.length === 0) {
|
|
233
221
|
process.stderr.write('E_OWNER_EMAILS_REQUIRED: MCP_TRANSPORT includes http but MCP_OWNER_EMAILS is empty. Set MCP_OWNER_EMAILS to the Google email(s) allowed to authenticate.\n');
|
|
234
222
|
process.exit(1);
|
|
235
223
|
}
|
|
236
224
|
}
|
|
237
|
-
//
|
|
225
|
+
// Stateless HTTP cannot push tools/list_changed, so it forces curated.
|
|
238
226
|
const configuredMode = (process.env.GOOGLE_DISCOVERY ?? '').trim().toLowerCase();
|
|
239
227
|
if (configuredMode && configuredMode !== 'curated') {
|
|
240
228
|
process.stderr.write(`GOOGLE_DISCOVERY="${configuredMode}" is ignored over HTTP; the stateless transport forces "curated".\n`);
|
|
241
229
|
}
|
|
242
|
-
const httpServer =
|
|
230
|
+
const httpServer = createMcpServer(pkg.version);
|
|
243
231
|
const httpMetrics = initMetricsFor('http', 'curated');
|
|
244
232
|
const httpCtx = buildIdentityContext(process.env, { transport: 'http' });
|
|
245
233
|
const registry = buildRegistry(httpServer, httpCtx, 'curated', httpMetrics);
|
|
246
234
|
registry.installListHandler();
|
|
247
235
|
registerSetupPrompt(httpServer);
|
|
248
|
-
//
|
|
236
|
+
// Mount the OAuth 2.1 AS (MCP-client tokens + owner login) + the Bearer authenticator.
|
|
249
237
|
const { buildAuthServer, verifiedEmailFromIdToken } = await import('./oauth-as.js');
|
|
250
238
|
const { jwtSecretFrom } = await import('./mcp-token.js');
|
|
251
239
|
const { resolveJwtKey, resolveMasterKeyForDispatch } = await import('./master-key.js');
|
|
@@ -288,7 +276,7 @@ async function main() {
|
|
|
288
276
|
aliasEmail: (alias) => getAccountSet().configs[alias]?.email,
|
|
289
277
|
log: (l) => process.stderr.write(`[as] ${l}\n`),
|
|
290
278
|
});
|
|
291
|
-
//
|
|
279
|
+
// Over HTTP, a dead/missing per-account token surfaces a clickable,
|
|
292
280
|
// signed re-auth link into the AS's alias_reauth flow instead of a CLI hint.
|
|
293
281
|
const { setHttpReauthLink } = await import('./reauth-hint.js');
|
|
294
282
|
setHttpReauthLink((alias) => authServer.reauthLink(alias));
|
|
@@ -314,7 +302,7 @@ async function main() {
|
|
|
314
302
|
});
|
|
315
303
|
await host.start();
|
|
316
304
|
process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
|
|
317
|
-
// Graceful shutdown so `docker run --init`
|
|
305
|
+
// Graceful shutdown so `docker run --init` forwards SIGTERM cleanly.
|
|
318
306
|
const shutdown = () => {
|
|
319
307
|
host.close().finally(() => process.exit(0));
|
|
320
308
|
};
|
package/dist/master-key.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface KeyDeps {
|
|
|
15
15
|
hasAnyToken?: () => boolean;
|
|
16
16
|
}
|
|
17
17
|
export declare function __setKeychainFactoryForTest(fn: ((account: string) => KeychainEntry | null) | null): void;
|
|
18
|
-
/**
|
|
18
|
+
/** Single choke point: no other module may read the env vars directly.
|
|
19
19
|
* Boot/CLI form: failures exit with a clean stderr slug ("fatal at startup"). */
|
|
20
20
|
export declare function resolveMasterKey(deps?: KeyDeps): ResolvedKey;
|
|
21
21
|
/** Dispatch form: identical resolution, but failures THROW (caught by the tool
|
|
@@ -28,14 +28,14 @@ export declare function peekMasterKeyProvenance(deps?: KeyDeps): KeyProvenance |
|
|
|
28
28
|
* outstanding MCP tokens and clients re-auth silently. */
|
|
29
29
|
export declare function resolveJwtKey(deps?: KeyDeps): ResolvedKey;
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* After the FIRST successful token decrypt with an env-sourced key,
|
|
32
32
|
* mirror it into the keychain (only when the keychain holds no entry) so the
|
|
33
33
|
* key survives an env loss. Gating on a successful decrypt means a stale or
|
|
34
34
|
* wrong env key is never mirrored. Best-effort: failures are doctor material,
|
|
35
35
|
* never fatal.
|
|
36
36
|
*/
|
|
37
37
|
export declare function noteSuccessfulDecrypt(): void;
|
|
38
|
-
/**
|
|
38
|
+
/** Reset: drop the generated MASTER_KEY material (0600 file + keychain
|
|
39
39
|
* entry) so the next run re-provisions. Never removes an env-provided key (the
|
|
40
40
|
* deployer owns that; `env:true` flags it so reset can warn). The caller MUST
|
|
41
41
|
* have wiped every .enc token first — regenerating with tokens present would
|
package/dist/master-key.js
CHANGED
|
@@ -5,10 +5,8 @@ import { createRequire } from 'node:module';
|
|
|
5
5
|
import { configDir, failStartup } from './config-file.js';
|
|
6
6
|
import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
|
|
7
7
|
import { getTokenDir } from './accounts.js';
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// failure (no prebuild, native load error, headless Linux without a Secret
|
|
11
|
-
// Service) as "keychain unavailable", falling through to the 0600 file.
|
|
8
|
+
// Native optionalDependency, loaded lazily and synchronously (the token
|
|
9
|
+
// store's key read is sync); EVERY failure falls through to the 0600 file.
|
|
12
10
|
const require_ = createRequire(import.meta.url);
|
|
13
11
|
function systemKeychain(account) {
|
|
14
12
|
try {
|
|
@@ -32,8 +30,7 @@ function systemKeychain(account) {
|
|
|
32
30
|
}
|
|
33
31
|
}
|
|
34
32
|
// Default keychain factory, replaceable ONLY for tests: unit tests must never
|
|
35
|
-
// read or write the developer's real OS keyring
|
|
36
|
-
// test fixture key into a real GNOME keyring — never again).
|
|
33
|
+
// read or write the developer's real OS keyring.
|
|
37
34
|
let keychainFactory = systemKeychain;
|
|
38
35
|
export function __setKeychainFactoryForTest(fn) {
|
|
39
36
|
keychainFactory = fn ?? systemKeychain;
|
|
@@ -46,14 +43,8 @@ function anyEncFileUnder(dir) {
|
|
|
46
43
|
return false;
|
|
47
44
|
}
|
|
48
45
|
}
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// one client) must still stop a fresh key from being minted. TOKEN_STORE_PATH
|
|
52
|
-
// may relocate the token dir outside configDir(), so scan it directly; the
|
|
53
|
-
// recursive configDir() walk then covers BOTH the tenant-namespaced token root
|
|
54
|
-
// (tenants/<id>) AND any top-level master-key-encrypted registry beside it
|
|
55
|
-
// (e.g. a downstream tenant map / invite store). Minting a fresh MASTER_KEY
|
|
56
|
-
// over any existing .enc bricks it irreversibly, so the scan must see them all.
|
|
46
|
+
// Globs the token dir rather than iterating registered aliases, so an orphan
|
|
47
|
+
// .enc still stops a fresh key from being minted. See docs/internals.md.
|
|
57
48
|
function anyTokenFileExists() {
|
|
58
49
|
return anyEncFileUnder(getTokenDir()) || anyEncFileUnder(configDir());
|
|
59
50
|
}
|
|
@@ -98,7 +89,7 @@ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
|
|
|
98
89
|
const fromFile = readKeyFile(filePath);
|
|
99
90
|
if (fromFile)
|
|
100
91
|
return { key: fromFile, provenance: 'file' };
|
|
101
|
-
// Generate-on-setup. The hard guard
|
|
92
|
+
// Generate-on-setup. The hard guard: minting a fresh
|
|
102
93
|
// MASTER_KEY while <alias>.enc files exist would silently brick them all.
|
|
103
94
|
if (hardGuard && hasAnyToken()) {
|
|
104
95
|
fail('E_MASTER_KEY_MISSING_TOKENS_EXIST', `no ${envName} found in env, keychain or ${filePath}, but encrypted tokens exist under ${getTokenDir()}. ` +
|
|
@@ -115,10 +106,8 @@ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
|
|
|
115
106
|
return { key: racedFile, provenance: 'file' };
|
|
116
107
|
// Exactly randomBytes(32) base64: hits deriveKey's 32-byte fast path.
|
|
117
108
|
const generated = randomBytes(32).toString('base64');
|
|
118
|
-
// The 0600 file is ALWAYS written
|
|
119
|
-
//
|
|
120
|
-
// lives only there bricks every token at the next boot. The file is the
|
|
121
|
-
// durable sink; byte-exact read-back gates any use of the key.
|
|
109
|
+
// The 0600 file is ALWAYS written (some keychain backends don't survive
|
|
110
|
+
// a reboot); byte-exact read-back gates any use of the key.
|
|
122
111
|
atomicWriteFileSync(filePath, `${generated}\n`, 0o600);
|
|
123
112
|
if (readKeyFile(filePath) !== generated) {
|
|
124
113
|
fail('E_KEY_PROVISIONING_FAILED', `generated ${envName} could not be read back byte-exact from ${filePath}; refusing to encrypt anything with an unverified key.`);
|
|
@@ -151,7 +140,7 @@ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
|
|
|
151
140
|
}
|
|
152
141
|
let cachedMaster = null;
|
|
153
142
|
let cachedJwt = null;
|
|
154
|
-
/**
|
|
143
|
+
/** Single choke point: no other module may read the env vars directly.
|
|
155
144
|
* Boot/CLI form: failures exit with a clean stderr slug ("fatal at startup"). */
|
|
156
145
|
export function resolveMasterKey(deps) {
|
|
157
146
|
if (deps)
|
|
@@ -189,7 +178,7 @@ export function resolveJwtKey(deps) {
|
|
|
189
178
|
}
|
|
190
179
|
let mirrored = false;
|
|
191
180
|
/**
|
|
192
|
-
*
|
|
181
|
+
* After the FIRST successful token decrypt with an env-sourced key,
|
|
193
182
|
* mirror it into the keychain (only when the keychain holds no entry) so the
|
|
194
183
|
* key survives an env loss. Gating on a successful decrypt means a stale or
|
|
195
184
|
* wrong env key is never mirrored. Best-effort: failures are doctor material,
|
|
@@ -211,7 +200,7 @@ export function noteSuccessfulDecrypt() {
|
|
|
211
200
|
// Keychain backend unavailable; env stays the working source.
|
|
212
201
|
}
|
|
213
202
|
}
|
|
214
|
-
/**
|
|
203
|
+
/** Reset: drop the generated MASTER_KEY material (0600 file + keychain
|
|
215
204
|
* entry) so the next run re-provisions. Never removes an env-provided key (the
|
|
216
205
|
* deployer owns that; `env:true` flags it so reset can warn). The caller MUST
|
|
217
206
|
* have wiped every .enc token first — regenerating with tokens present would
|
package/dist/mcp-token.d.ts
CHANGED
|
@@ -88,7 +88,7 @@ export declare class ReplayGuard {
|
|
|
88
88
|
reserve(jti: string, ttlMs: number, nowMs: number): 'ok' | 'full';
|
|
89
89
|
/** Record a jti as spent. A full guard refuses instead of evicting a live
|
|
90
90
|
* jti: a flood of spends would otherwise make a spent artifact usable again,
|
|
91
|
-
* such as a callback the browser binding refused
|
|
91
|
+
* such as a callback the browser binding refused. `artifactExpMs`
|
|
92
92
|
* keeps the record alive as long as the artifact itself, even when the clock
|
|
93
93
|
* stepped back between its signing and this spend. */
|
|
94
94
|
spend(jti: string, ttlMs: number, nowMs: number, artifactExpMs?: number): ReplaySpend;
|
|
@@ -138,10 +138,10 @@ export interface RefreshStoreOptions {
|
|
|
138
138
|
* Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
|
|
139
139
|
* use, and a token carries its family and generation under a MAC, so
|
|
140
140
|
* presenting any generation the family has rotated past is REUSE: the family
|
|
141
|
-
* is revoked for the rest of its life
|
|
141
|
+
* is revoked for the rest of its life. The store keeps one
|
|
142
142
|
* record per family, whatever its rotation count. Every read-modify-write goes
|
|
143
143
|
* through a file lock so concurrent stdio+HTTP processes can't lost-update or
|
|
144
|
-
* double-spend
|
|
144
|
+
* double-spend. See docs/internals.md.
|
|
145
145
|
*/
|
|
146
146
|
export declare class RefreshStore {
|
|
147
147
|
private readonly path;
|
|
@@ -165,16 +165,6 @@ export declare class RefreshStore {
|
|
|
165
165
|
private load;
|
|
166
166
|
private save;
|
|
167
167
|
private emit;
|
|
168
|
-
/** Evict `sub`'s own least-recently-used sessions (both formats) so at
|
|
169
|
-
* most `limit` of its live sessions remain; dropping one here is the same
|
|
170
|
-
* as deleting it from `data.families` / `data.active` on a revoke, so it
|
|
171
|
-
* can never be presented again (reuse detection for every OTHER family is
|
|
172
|
-
* untouched). Only `sub`'s own entries are ever inspected.
|
|
173
|
-
*
|
|
174
|
-
* `keepKey` is the family just issued: it is excluded from the ranking so
|
|
175
|
-
* a clock step back, or a concurrent rotate's `usedAt = max(usedAt, nowMs)`
|
|
176
|
-
* racing ahead of it under the lock, can never make the brand-new family
|
|
177
|
-
* look like the least recently used one and drop itself. */
|
|
178
168
|
private enforceFamilyCap;
|
|
179
169
|
private newFamilyId;
|
|
180
170
|
issue(nowMs: number, sub: string): string;
|