mcp-google-multi 6.0.0-alpha.72 → 6.0.0-alpha.73
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/accounts.js +12 -25
- package/dist/arg-normalize.js +15 -35
- package/dist/arg-strict.js +8 -28
- package/dist/auth.js +4 -9
- package/dist/boot-gates.js +2 -4
- package/dist/cimd-cache.js +5 -11
- package/dist/client-config.js +6 -12
- package/dist/client.js +0 -0
- package/dist/compose.js +4 -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 +1 -1
- package/dist/doctor.js +7 -12
- 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.js +2 -5
- package/dist/http-transport.js +35 -88
- package/dist/identity.js +4 -7
- package/dist/index.js +7 -18
- package/dist/master-key.js +7 -18
- package/dist/mcp-token.d.ts +37 -13
- package/dist/mcp-token.js +118 -41
- 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 +2 -4
- package/dist/net-tuning.js +2 -4
- package/dist/oauth-as.d.ts +6 -1
- package/dist/oauth-as.js +39 -72
- 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.js +4 -12
- package/dist/registry.d.ts +0 -8
- package/dist/registry.js +32 -66
- package/dist/scope-catalog.js +4 -7
- package/dist/scope-observability.d.ts +1 -2
- 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 -21
- package/dist/tenant-purge.js +2 -4
- package/dist/token-store.js +0 -0
- package/dist/tools/_errors.js +21 -38
- package/dist/tools/_local-files.js +4 -8
- package/dist/tools/account-wizard.js +27 -48
- 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 +5 -6
- 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.js +6 -11
- package/dist/tools/gmail.js +22 -36
- package/dist/tools/google-api.js +4 -6
- 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 +0 -3
- package/dist/write-control.js +16 -4
- package/package.json +1 -1
package/dist/doctor.js
CHANGED
|
@@ -35,9 +35,8 @@ const DEFAULT_DEPS = {
|
|
|
35
35
|
probeApi: (alias) => probeApiEnablement(alias),
|
|
36
36
|
probeHttp: (cfg) => probeHttpEndpoints(cfg),
|
|
37
37
|
};
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
* compute from the public URL is the perpetual-401 interop bug. */
|
|
38
|
+
// §7 live check: a mismatch between PRM/AS metadata and what clients compute
|
|
39
|
+
// from the public URL is the perpetual-401 interop bug.
|
|
41
40
|
async function probeHttpEndpoints(cfg) {
|
|
42
41
|
try {
|
|
43
42
|
const prmRes = await fetch(`${cfg.publicUrl}/.well-known/oauth-protected-resource`, {
|
|
@@ -150,7 +149,7 @@ function sectionConfig(deps, set, tenantScope = false) {
|
|
|
150
149
|
return { id: 2, title: 'Config', verdict, lines, ...(hint ? { hint } : {}), ...(slug ? { slug } : {}) };
|
|
151
150
|
}
|
|
152
151
|
/** One line, state AND source, so the people being measured can see both
|
|
153
|
-
* here and in `diagnose
|
|
152
|
+
* here and in `diagnose`. Read-only. */
|
|
154
153
|
export function usageMetricsStatusLine(env) {
|
|
155
154
|
const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
|
|
156
155
|
let configValue;
|
|
@@ -337,10 +336,8 @@ export function overallVerdict(sections) {
|
|
|
337
336
|
return worst;
|
|
338
337
|
}
|
|
339
338
|
export async function runDiagnostics(deps = DEFAULT_DEPS, opts = {}) {
|
|
340
|
-
// Tenant scope omits the OPERATOR sections
|
|
341
|
-
//
|
|
342
|
-
// values — other people's addresses from a tenant's seat), and trims §2 to
|
|
343
|
-
// the context's own account count.
|
|
339
|
+
// Tenant scope omits the OPERATOR sections (§3 key provenance, §7 owner-gate
|
|
340
|
+
// emails) and trims §2 to the context's own account count.
|
|
344
341
|
const tenantScope = opts.scope === 'tenant';
|
|
345
342
|
const sections = [];
|
|
346
343
|
sections.push(sectionRuntime(deps, tenantScope));
|
|
@@ -380,10 +377,8 @@ export function renderDoctorText(report) {
|
|
|
380
377
|
}
|
|
381
378
|
const EMAIL_LOCAL_CHAR = /[A-Za-z0-9._%+-]/;
|
|
382
379
|
const EMAIL_DOMAIN_CHAR = /[A-Za-z0-9.-]/;
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
* polynomially on long local-charset runs without an @ (CodeQL
|
|
386
|
-
* js/polynomial-redos), and report text can embed arbitrary error strings. */
|
|
380
|
+
// A linear scan, not a regex: `local*@domain` backtracks polynomially on long
|
|
381
|
+
// local-charset runs without an @ (CodeQL js/polynomial-redos).
|
|
387
382
|
function maskEmails(text) {
|
|
388
383
|
let out = '';
|
|
389
384
|
let i = 0;
|
package/dist/env-load.js
CHANGED
|
@@ -3,11 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
-
// Precedence: real env > CWD .env > package-root .env > ~/.config .env
|
|
7
|
-
//
|
|
8
|
-
// tests/env-load.test.ts T4), so highest-priority file loads FIRST and real
|
|
9
|
-
// env wins by already being present; the boot snapshot re-assert is a second
|
|
10
|
-
// line of defense should those semantics ever change.
|
|
6
|
+
// Precedence: real env > CWD .env > package-root .env > ~/.config .env;
|
|
7
|
+
// the boot snapshot re-assert below is a second line of defense.
|
|
11
8
|
export function loadEnvFiles(paths = {}) {
|
|
12
9
|
// loadEnvFile exists since 20.12, so a feature check alone would let 20.12-21.x through.
|
|
13
10
|
const major = Number(process.versions.node.split('.', 1)[0]);
|
package/dist/executor.js
CHANGED
|
@@ -91,10 +91,8 @@ export async function executeApiMethod(method, args, deps = {}) {
|
|
|
91
91
|
});
|
|
92
92
|
const text = JSON.stringify(res.data ?? null);
|
|
93
93
|
if (text.length > MAX_RESPONSE_CHARS) {
|
|
94
|
-
// Budget the ENCODED head: jsonResult re-stringifies it, so
|
|
95
|
-
//
|
|
96
|
-
// result exceed the cap this tool advertises, by up to 15 percent on a
|
|
97
|
-
// quote-dense listing.
|
|
94
|
+
// Budget the ENCODED head: jsonResult re-stringifies it, so slicing raw
|
|
95
|
+
// characters overshot the advertised cap on a quote-dense listing.
|
|
98
96
|
return jsonResult({
|
|
99
97
|
truncated: true,
|
|
100
98
|
totalChars: text.length,
|
package/dist/fanout.js
CHANGED
|
@@ -4,19 +4,13 @@ import { echoName, echoNames, MAX_SUGGESTED_KEYS } from './arg-strict.js';
|
|
|
4
4
|
import { hints } from './hint-provider.js';
|
|
5
5
|
export const CSV_RE = /^[a-zA-Z0-9_-]+(\s*,\s*[a-zA-Z0-9_-]+)+$/;
|
|
6
6
|
const FANOUT_CONCURRENCY = 5;
|
|
7
|
-
// `aliases` is
|
|
8
|
-
//
|
|
9
|
-
// '*' would advertise and accept another registry's aliases. It is a getter:
|
|
10
|
-
// the set is read at parse time, so an alias added mid-session (account_add,
|
|
11
|
-
// a tenant link) is valid on tools registered before it existed.
|
|
7
|
+
// `aliases` is a getter read at parse time (not baked), so a registry only
|
|
8
|
+
// ever sees ITS OWN account set, and a mid-session alias (account_add) is valid on tools registered before it existed.
|
|
12
9
|
export function fanoutAccountField(description, aliases) {
|
|
13
10
|
const boot = aliases();
|
|
14
11
|
const csvExample = boot.length > 1 ? `; or a CSV subset like "${boot.slice(0, 2).join(',')}"` : '';
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// branch verbatim, which is the CSV regex, so a mistyped alias used to read
|
|
18
|
-
// as "Invalid string: must match pattern /^[a-zA-Z0-9_-]+(,...)/" and never
|
|
19
|
-
// named an alias. Hence the same message on BOTH.
|
|
12
|
+
// Same message on both union branches: for a string, zod surfaces the
|
|
13
|
+
// single non-aborted branch (the CSV regex) verbatim, which never named the alias.
|
|
20
14
|
const bad = () => unknownAliasMessage(aliases(), true);
|
|
21
15
|
return z
|
|
22
16
|
// The selector branch stays first: tools/list injects the live
|
|
@@ -46,9 +40,8 @@ export function noAccountsResult() {
|
|
|
46
40
|
export function parseAccountSelector(value, accounts = getAccountSet().aliases) {
|
|
47
41
|
if (value === '*')
|
|
48
42
|
return { ok: true, fanout: true, aliases: [...accounts] };
|
|
49
|
-
// A single alias used to skip validation
|
|
50
|
-
//
|
|
51
|
-
// check and same envelope wherever the alias was typed.
|
|
43
|
+
// A single alias used to skip validation and reach getClient, whose throw
|
|
44
|
+
// landed on the mapper's floor as an unclassified upstream_error.
|
|
52
45
|
if (!value.includes(',')) {
|
|
53
46
|
return accounts.includes(value)
|
|
54
47
|
? { ok: true, fanout: false, aliases: [value] }
|
|
@@ -132,10 +125,8 @@ export async function runFanout(handler, args, aliases) {
|
|
|
132
125
|
await Promise.all(Array.from({ length: Math.min(FANOUT_CONCURRENCY, aliases.length) }, () => worker()));
|
|
133
126
|
const failed = results.filter((r) => !r.ok).length;
|
|
134
127
|
if (failed === results.length && results.length > 0) {
|
|
135
|
-
// Every account failed, so this IS an error result
|
|
136
|
-
//
|
|
137
|
-
// successful-looking object flagged as an error, and metrics bucketed it
|
|
138
|
-
// as `other`. Hoist a real envelope, keeping the per-account detail.
|
|
128
|
+
// Every account failed, so this IS an error result: hoist a real
|
|
129
|
+
// top-level envelope (error/hint/retriable), keeping the per-account detail.
|
|
139
130
|
const slugs = [...new Set(results.map((r) => r.error?.error ?? 'internal'))];
|
|
140
131
|
const hints = [...new Set(results.map((r) => r.error?.hint).filter(Boolean))];
|
|
141
132
|
const retriable = results.every((r) => r.error?.retriable === true);
|
package/dist/fs-atomic.d.ts
CHANGED
package/dist/fs-atomic.js
CHANGED
|
@@ -129,9 +129,8 @@ function holderDead(holder, now) {
|
|
|
129
129
|
// Two unreadable parts compare equal without naming the same namespace or
|
|
130
130
|
// boot, so only a fully known identity is looked up by start time.
|
|
131
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
|
-
//
|
|
134
|
-
// the start time.
|
|
132
|
+
// A live namespace id is never shared, so the recorded pid is ours to look
|
|
133
|
+
// up: a restarted container reuses both the id and the pid, but not the start time.
|
|
135
134
|
const start = startOf(pid);
|
|
136
135
|
if (start !== undefined)
|
|
137
136
|
return start !== owner.start;
|
|
@@ -141,9 +140,8 @@ function holderDead(holder, now) {
|
|
|
141
140
|
return holder.mtimeMs < PROCESS_START_MS;
|
|
142
141
|
return probeDead(pid) || leaseOver;
|
|
143
142
|
}
|
|
144
|
-
// One waiter at a time breaks a dead lock
|
|
145
|
-
//
|
|
146
|
-
// False when another waiter is breaking it.
|
|
143
|
+
// One waiter at a time breaks a dead lock, or the second could unlink the
|
|
144
|
+
// first one's new lock; false when another waiter is already breaking it.
|
|
147
145
|
function breakLock(lock, holder) {
|
|
148
146
|
const breaker = `${lock}.break`;
|
|
149
147
|
// A token, not the inode: a breaker created after ours was removed can
|
|
@@ -198,10 +196,8 @@ function releaseBreaker(breaker, token) {
|
|
|
198
196
|
throw error;
|
|
199
197
|
}
|
|
200
198
|
}
|
|
201
|
-
// Hardlink-based advisory lock
|
|
202
|
-
//
|
|
203
|
-
// the owner file stays linked while held and names the holder's process
|
|
204
|
-
// instance. See docs/internals.md.
|
|
199
|
+
// Hardlink-based advisory lock; the lock body is the bare pid, as older
|
|
200
|
+
// readers expect. See docs/internals.md.
|
|
205
201
|
export function withFileLock(absPath, fn, label = `lock: ${absPath}`) {
|
|
206
202
|
const dir = path.dirname(absPath);
|
|
207
203
|
const lock = path.join(dir, `.${path.basename(absPath)}.lock`);
|
package/dist/hint-provider.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
// A plain array literal, not derived from the union
|
|
2
|
-
//
|
|
3
|
-
// caught by the completeness check below instead of silently compiling.
|
|
4
|
-
// `as const` so that check narrows to the literal tuple rather than widening
|
|
5
|
-
// every element back to the whole HintCase['kind'] union (which would make
|
|
6
|
-
// it pass trivially, whatever was listed).
|
|
1
|
+
// A plain array literal, not derived from the union, so a missing member is
|
|
2
|
+
// caught by the completeness check below rather than silently compiling.
|
|
7
3
|
export const HINT_KINDS = [
|
|
8
4
|
'reauth',
|
|
9
5
|
'legacy_plaintext_token',
|
|
@@ -43,10 +39,8 @@ let installed = null;
|
|
|
43
39
|
export function setHintProvider(p) {
|
|
44
40
|
installed = p;
|
|
45
41
|
}
|
|
46
|
-
// A throwing provider must never take down the call site:
|
|
47
|
-
//
|
|
48
|
-
// produce a Rendered falls back to the same literal a missing provider
|
|
49
|
-
// would, and the provider's own error text never reaches a client.
|
|
42
|
+
// A throwing provider must never take down the call site: it falls back to
|
|
43
|
+
// the same literal a missing provider would, never leaking its own error text.
|
|
50
44
|
export function hints() {
|
|
51
45
|
if (!installed)
|
|
52
46
|
return null;
|
package/dist/http-config.js
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
// Transport
|
|
2
|
-
//
|
|
3
|
-
// Owns the canonicalization rule: a single mismatch
|
|
4
|
-
// between the advertised resource URI and the client's `resource`/`aud` = a
|
|
5
|
-
// perpetual 401 loop (the #1 interop bug), so it is normalized exactly once.
|
|
1
|
+
// Transport + public-base-URL / Origin / host config, pure and resolved once
|
|
2
|
+
// at boot. A mismatch between the advertised URI and the client's `aud` is a perpetual 401 loop.
|
|
6
3
|
export const DEFAULT_HTTP_HOST = '127.0.0.1';
|
|
7
4
|
export const DEFAULT_HTTP_PORT = 4243;
|
|
8
5
|
export const CLAUDE_AI_ORIGIN = 'https://claude.ai';
|
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 oauth-as.ts; the default is a loopback-owner
|
|
5
|
-
// authenticator so the local-HTTP model works without the AS.
|
|
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' &&
|
|
@@ -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;
|
|
@@ -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)
|
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',
|
package/dist/index.js
CHANGED
|
@@ -139,10 +139,8 @@ async function main() {
|
|
|
139
139
|
}
|
|
140
140
|
const wantStdio = httpCfg.transport === 'stdio' || httpCfg.transport === 'both';
|
|
141
141
|
const wantHttp = transportIncludesHttp(httpCfg.transport);
|
|
142
|
-
// The SERVER never boots with an empty registry
|
|
143
|
-
//
|
|
144
|
-
// returned. A multi-tenant boot instead refuses the two process-wide env
|
|
145
|
-
// 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.
|
|
146
144
|
const mt = isMultiTenantBoot();
|
|
147
145
|
if (accountsAssertRequired(wantStdio, mt))
|
|
148
146
|
assertServerAccountsConfigured();
|
|
@@ -154,9 +152,7 @@ async function main() {
|
|
|
154
152
|
// specced "fatal at startup", never mid-dispatch.
|
|
155
153
|
const { resolveMasterKey } = await import('./master-key.js');
|
|
156
154
|
resolveMasterKey();
|
|
157
|
-
//
|
|
158
|
-
// when off (no wrapper, no dir, nothing initializes). One instance per
|
|
159
|
-
// transport so `boots` keys stay honest under `both`.
|
|
155
|
+
// One Metrics instance per transport so `boots` keys stay honest under `both`.
|
|
160
156
|
const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
|
|
161
157
|
const metricsState = resolveUsageMetrics(process.env, loadConfigFile()?.usageMetrics, envSrc?.kind === 'file' ? envSrc.file : undefined);
|
|
162
158
|
if (metricsState.warning)
|
|
@@ -196,11 +192,7 @@ async function main() {
|
|
|
196
192
|
registry.installListHandler();
|
|
197
193
|
registerSetupPrompt(server);
|
|
198
194
|
const stdioHooks = requestHooksFor(registry, stdioCtx, stdioMetrics);
|
|
199
|
-
//
|
|
200
|
-
// envelope rewrite is INNERMOST (last to touch the frame), the tap sits
|
|
201
|
-
// above it (classifying the ORIGINAL validation prose), arg normalization
|
|
202
|
-
// outermost. The tap only counts outbound JSON-RPC error frames (no
|
|
203
|
-
// handler ran) plus id->tool names.
|
|
195
|
+
// Composition order (outbound runs outermost-first): see docs/internals.md (Argument normalization).
|
|
204
196
|
let transport = withValidationEnvelope(new StdioServerTransport(), stdioHooks.validationEnvelope);
|
|
205
197
|
if (stdioMetrics) {
|
|
206
198
|
const { tapUsageMetrics } = await import('./metrics-tap.js');
|
|
@@ -208,10 +200,8 @@ async function main() {
|
|
|
208
200
|
}
|
|
209
201
|
await server.connect(stdioHooks.argShapeFor || stdioHooks.strictArgs
|
|
210
202
|
? withArgNormalization(transport,
|
|
211
|
-
// Gated exactly as the HTTP leg gates
|
|
212
|
-
//
|
|
213
|
-
// GOOGLE_ARG_NORMALIZE=off, so the same call succeeded on stdio
|
|
214
|
-
// 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.
|
|
215
205
|
stdioHooks.argShapeFor ?? (() => undefined), undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined, stdioHooks.strictArgs)
|
|
216
206
|
: transport);
|
|
217
207
|
}
|
|
@@ -220,8 +210,7 @@ async function main() {
|
|
|
220
210
|
const owners = parseOwnerEmails(process.env);
|
|
221
211
|
const gates = mtBootGates();
|
|
222
212
|
if (gates?.multiTenant) {
|
|
223
|
-
// Same fail-fast
|
|
224
|
-
// 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
|
|
225
214
|
// mechanism exists", not a flat owner allowlist.
|
|
226
215
|
gates.assertProvisioningGate();
|
|
227
216
|
}
|
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
|
}
|
|
@@ -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.`);
|