mcp-google-multi 6.0.0-alpha.9 → 6.0.0-beta.2
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 +6 -4
- package/dist/accounts.d.ts +5 -0
- package/dist/accounts.js +12 -1
- package/dist/api-probe.js +3 -0
- package/dist/arg-normalize.d.ts +76 -0
- package/dist/arg-normalize.js +314 -0
- package/dist/arg-strict.d.ts +54 -0
- package/dist/arg-strict.js +163 -0
- package/dist/auth.js +15 -72
- package/dist/client.js +15 -5
- package/dist/config-file.d.ts +1 -0
- package/dist/config-file.js +4 -0
- package/dist/discover.js +35 -14
- package/dist/discovery-client.d.ts +8 -1
- package/dist/discovery-client.js +96 -17
- package/dist/doctor.d.ts +9 -1
- package/dist/doctor.js +32 -4
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +23 -1
- package/dist/executor.js +12 -1
- package/dist/fanout.js +43 -8
- package/dist/http-transport.d.ts +13 -1
- package/dist/http-transport.js +10 -4
- package/dist/index.js +113 -10
- package/dist/metrics-cli.d.ts +21 -0
- package/dist/metrics-cli.js +206 -0
- package/dist/metrics-tap.d.ts +12 -0
- package/dist/metrics-tap.js +95 -0
- package/dist/oauth-consent.d.ts +25 -10
- package/dist/oauth-consent.js +85 -39
- package/dist/outbound-allowlist.d.ts +34 -0
- package/dist/outbound-allowlist.js +108 -0
- package/dist/registry.d.ts +54 -2
- package/dist/registry.js +180 -7
- package/dist/scope-catalog.js +16 -0
- package/dist/services.d.ts +8 -0
- package/dist/services.js +28 -1
- package/dist/setup-prompt.d.ts +1 -1
- package/dist/setup-prompt.js +1 -1
- package/dist/tools/_coerce.d.ts +5 -0
- package/dist/tools/_coerce.js +11 -0
- package/dist/tools/_errors.d.ts +31 -3
- package/dist/tools/_errors.js +307 -43
- package/dist/tools/_local-files.js +7 -2
- package/dist/tools/account-wizard.d.ts +24 -1
- package/dist/tools/account-wizard.js +152 -51
- package/dist/tools/admin.js +7 -6
- package/dist/tools/analytics.d.ts +18 -0
- package/dist/tools/analytics.js +284 -0
- package/dist/tools/calendar.d.ts +7 -0
- package/dist/tools/calendar.js +75 -15
- package/dist/tools/chat.js +7 -4
- package/dist/tools/contacts.js +57 -30
- package/dist/tools/docs.js +56 -54
- package/dist/tools/drive.js +132 -70
- package/dist/tools/forms.js +7 -7
- package/dist/tools/generated/_shared.d.ts +6 -0
- package/dist/tools/generated/_shared.js +11 -1
- package/dist/tools/generated/admin.js +345 -214
- package/dist/tools/generated/analytics.d.ts +2 -0
- package/dist/tools/generated/analytics.js +981 -0
- package/dist/tools/generated/appsmarket.js +4 -4
- package/dist/tools/generated/calendar.js +28 -28
- package/dist/tools/generated/chat.js +83 -56
- package/dist/tools/generated/classroom.js +258 -216
- package/dist/tools/generated/cloudidentity.js +76 -68
- package/dist/tools/generated/cloudsearch.js +40 -39
- package/dist/tools/generated/contacts.js +20 -11
- package/dist/tools/generated/drive.js +62 -53
- package/dist/tools/generated/drivelabels.js +60 -32
- package/dist/tools/generated/forms.js +7 -7
- package/dist/tools/generated/gmail.js +136 -109
- package/dist/tools/generated/groupsmigration.js +1 -1
- package/dist/tools/generated/groupssettings.js +3 -3
- package/dist/tools/generated/index.js +2 -0
- package/dist/tools/generated/keep.js +8 -7
- package/dist/tools/generated/licensing.js +39 -23
- package/dist/tools/generated/meet.js +14 -14
- package/dist/tools/generated/method-map.d.ts +2 -0
- package/dist/tools/generated/method-map.js +894 -0
- package/dist/tools/generated/postmaster.js +3 -3
- package/dist/tools/generated/reseller.js +34 -28
- package/dist/tools/generated/script.js +29 -19
- package/dist/tools/generated/searchconsole.js +5 -3
- package/dist/tools/generated/sheets.js +10 -9
- package/dist/tools/generated/tasks.js +10 -4
- package/dist/tools/generated/vault.js +65 -56
- package/dist/tools/generated/workspaceevents.js +17 -16
- package/dist/tools/gmail.d.ts +10 -1
- package/dist/tools/gmail.js +50 -18
- package/dist/tools/google-api.js +48 -19
- package/dist/tools/meet.js +5 -2
- package/dist/tools/searchconsole.js +10 -10
- package/dist/tools/sheets.js +36 -48
- package/dist/tools/slides.js +6 -6
- package/dist/tools/tasks.js +16 -16
- package/dist/trim.d.ts +21 -0
- package/dist/trim.js +47 -0
- package/dist/usage-metrics.d.ts +155 -0
- package/dist/usage-metrics.js +603 -0
- package/dist/write-control.d.ts +27 -1
- package/dist/write-control.js +90 -11
- package/package.json +12 -4
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { describeMetricsDir, mergeDay } from './usage-metrics.js';
|
|
4
|
+
function loadDayDocs(dir, sinceDays) {
|
|
5
|
+
const aggDir = path.join(dir, 'agg');
|
|
6
|
+
let files;
|
|
7
|
+
try {
|
|
8
|
+
files = fs.readdirSync(aggDir).filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort();
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
const cutoff = sinceDays !== undefined ? Date.now() - sinceDays * 86_400_000 : undefined;
|
|
14
|
+
const docs = [];
|
|
15
|
+
for (const f of files) {
|
|
16
|
+
if (cutoff !== undefined && Date.parse(`${f.slice(0, 10)}T00:00:00Z`) < cutoff)
|
|
17
|
+
continue;
|
|
18
|
+
try {
|
|
19
|
+
const doc = JSON.parse(fs.readFileSync(path.join(aggDir, f), 'utf-8'));
|
|
20
|
+
if (doc && doc.v === 1)
|
|
21
|
+
docs.push(doc);
|
|
22
|
+
}
|
|
23
|
+
catch { /* unreadable day file: skip, the report is best-effort */ }
|
|
24
|
+
}
|
|
25
|
+
return docs;
|
|
26
|
+
}
|
|
27
|
+
function sum(docs) {
|
|
28
|
+
return docs.reduce((a, b) => mergeDay(a, b));
|
|
29
|
+
}
|
|
30
|
+
function parseSince(argv) {
|
|
31
|
+
const i = argv.indexOf('--since');
|
|
32
|
+
if (i === -1 || !argv[i + 1])
|
|
33
|
+
return undefined;
|
|
34
|
+
const m = argv[i + 1].match(/^(\d+)d$/);
|
|
35
|
+
return m ? Number(m[1]) : undefined;
|
|
36
|
+
}
|
|
37
|
+
const pct = (num, den) => (den === 0 ? '-' : `${Math.round((num / den) * 100)}%`);
|
|
38
|
+
const top = (m, n) => Object.entries(m).filter(([k]) => k !== '_overflow').sort((a, b) => b[1] - a[1]).slice(0, n);
|
|
39
|
+
function renderReport(merged, days, out) {
|
|
40
|
+
out(`local usage metrics: ${days} day file(s), ${merged.calls} calls, node ${merged.node}`);
|
|
41
|
+
out(`boots: ${Object.entries(merged.boots).map(([k, v]) => `${k}=${v}`).join(' ') || '(none)'}`);
|
|
42
|
+
out('');
|
|
43
|
+
out('tool calls err err% hint top slug');
|
|
44
|
+
for (const [name, t] of Object.entries(merged.tools).sort((a, b) => b[1].n - a[1].n).slice(0, 30)) {
|
|
45
|
+
const errs = Object.values(t.err).reduce((s, v) => s + v, 0);
|
|
46
|
+
const slug = top(t.err, 1)[0]?.[0] ?? '';
|
|
47
|
+
out(`${name.padEnd(34)}${String(t.n).padStart(5)}${String(errs).padStart(6)}${pct(errs, t.n).padStart(6)}${String(t.hint).padStart(6)} ${slug}`);
|
|
48
|
+
}
|
|
49
|
+
const hintRows = Object.entries(merged.hints);
|
|
50
|
+
if (hintRows.length > 0) {
|
|
51
|
+
out('');
|
|
52
|
+
out('hint coverage per error class (hinted/total)');
|
|
53
|
+
for (const [slug, h] of hintRows.sort((a, b) => b[1].hinted + b[1].unhinted - a[1].hinted - a[1].unhinted)) {
|
|
54
|
+
out(` ${slug.padEnd(24)}${h.hinted}/${h.hinted + h.unhinted} (${pct(h.hinted, h.hinted + h.unhinted)})`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const retryRows = Object.entries(merged.retries);
|
|
58
|
+
if (retryRows.length > 0) {
|
|
59
|
+
out('');
|
|
60
|
+
out('retry self-correction per error class (ok/total after hinted vs unhinted errors)');
|
|
61
|
+
for (const [slug, r] of retryRows) {
|
|
62
|
+
out(` ${slug.padEnd(24)}hinted ${r.hintedOk}/${r.hintedOk + r.hintedFail} (${pct(r.hintedOk, r.hintedOk + r.hintedFail)}) unhinted ${r.unhintedOk}/${r.unhintedOk + r.unhintedFail} (${pct(r.unhintedOk, r.unhintedOk + r.unhintedFail)})`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const esc = merged.escape;
|
|
66
|
+
if (Object.keys(esc.methods).length > 0 || esc.unknown_method > 0 || esc.unknown_api > 0) {
|
|
67
|
+
out('');
|
|
68
|
+
out(`escape hatch: unknown_method=${esc.unknown_method} unknown_api=${esc.unknown_api} overflow=${esc._overflow}`);
|
|
69
|
+
for (const [id, n] of top(esc.methods, 20))
|
|
70
|
+
out(` ${id.padEnd(50)}${n}`);
|
|
71
|
+
const apis = top(esc.apis_searched, 10).map(([k, v]) => `${k}=${v}`).join(' ');
|
|
72
|
+
if (apis)
|
|
73
|
+
out(` searched: ${apis}`);
|
|
74
|
+
}
|
|
75
|
+
if (Object.keys(merged.validation).length > 0) {
|
|
76
|
+
out('');
|
|
77
|
+
out('schema validation rejections (never reached a handler)');
|
|
78
|
+
for (const [tool, n] of top(merged.validation, 15))
|
|
79
|
+
out(` ${tool.padEnd(34)}${n}`);
|
|
80
|
+
}
|
|
81
|
+
const bigrams = top(merged.bigrams, 10);
|
|
82
|
+
if (bigrams.length > 0) {
|
|
83
|
+
out('');
|
|
84
|
+
out('top tool bigrams');
|
|
85
|
+
for (const [pair, n] of bigrams)
|
|
86
|
+
out(` ${pair.padEnd(50)}${n}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const tail = (id) => id.split('.').slice(1).join('.');
|
|
90
|
+
/** The promotion-queue view: ranked evidence, a human decides (spec section 5). */
|
|
91
|
+
export function classifyPromotion(merged, data) {
|
|
92
|
+
const curated = new Set();
|
|
93
|
+
for (const id of data.curatedIds) {
|
|
94
|
+
curated.add(id);
|
|
95
|
+
curated.add(tail(id));
|
|
96
|
+
}
|
|
97
|
+
const byId = new Map();
|
|
98
|
+
const byTail = new Map();
|
|
99
|
+
const toolToId = new Map();
|
|
100
|
+
for (const [id, tool] of Object.entries(data.methodMap)) {
|
|
101
|
+
byId.set(id, tool);
|
|
102
|
+
byTail.set(tail(id), tool);
|
|
103
|
+
toolToId.set(tool, id);
|
|
104
|
+
}
|
|
105
|
+
const rows = [];
|
|
106
|
+
// (a) generated tools with real traffic = curation candidates.
|
|
107
|
+
for (const [tool, t] of Object.entries(merged.tools)) {
|
|
108
|
+
const id = toolToId.get(tool);
|
|
109
|
+
if (id && !curated.has(id) && !curated.has(tail(id))) {
|
|
110
|
+
rows.push({ methodId: id, tool, calls: t.n, kind: 'curation-candidate' });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// (b)/(c) escape methodIds; the tail join covers legacy doc prefixes.
|
|
114
|
+
for (const [id, calls] of Object.entries(merged.escape.methods)) {
|
|
115
|
+
if (curated.has(id) || curated.has(tail(id)))
|
|
116
|
+
continue; // already curated: drops out
|
|
117
|
+
const twin = byId.get(id) ?? byTail.get(tail(id));
|
|
118
|
+
rows.push(twin
|
|
119
|
+
? { methodId: id, tool: twin, calls, kind: 'visibility-failure' }
|
|
120
|
+
: { methodId: id, calls, kind: 'generation-gap' });
|
|
121
|
+
}
|
|
122
|
+
return rows.sort((a, b) => b.calls - a.calls);
|
|
123
|
+
}
|
|
124
|
+
function renderPromotion(rows, out) {
|
|
125
|
+
const section = (kind, title, note) => {
|
|
126
|
+
const list = rows.filter((r) => r.kind === kind);
|
|
127
|
+
if (list.length === 0)
|
|
128
|
+
return;
|
|
129
|
+
out(`### ${title}`);
|
|
130
|
+
out(note);
|
|
131
|
+
out('');
|
|
132
|
+
out('| methodId | tool | calls |');
|
|
133
|
+
out('|---|---|---|');
|
|
134
|
+
for (const r of list)
|
|
135
|
+
out(`| ${r.methodId} | ${r.tool ?? '(none)'} | ${r.calls} |`);
|
|
136
|
+
out('');
|
|
137
|
+
};
|
|
138
|
+
if (rows.length === 0) {
|
|
139
|
+
out('No promotion evidence yet: no generated-tool traffic and no escape-hatch methodIds recorded.');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
section('curation-candidate', 'Curation candidates', 'Generated tools with real traffic; a curated wrapper may be earned.');
|
|
143
|
+
section('visibility-failure', 'Visibility failures', 'The escape hatch was used where a generated tool exists; discovery/naming problem, not a coverage gap.');
|
|
144
|
+
section('generation-gap', 'Generation gaps', 'Escape methodIds with no tool at all.');
|
|
145
|
+
}
|
|
146
|
+
export function runMetricsCli(argv, deps) {
|
|
147
|
+
const out = deps.out ?? ((l) => process.stdout.write(`${l}\n`));
|
|
148
|
+
const env = deps.env ?? process.env;
|
|
149
|
+
const json = argv.includes('--json');
|
|
150
|
+
const sub = argv.includes('merge') ? 'merge' : 'report';
|
|
151
|
+
let merged;
|
|
152
|
+
let dayCount = 0;
|
|
153
|
+
if (sub === 'merge') {
|
|
154
|
+
const files = argv.slice(argv.indexOf('merge') + 1).filter((a) => !a.startsWith('--'));
|
|
155
|
+
if (files.length === 0) {
|
|
156
|
+
out('usage: mcp-google-multi metrics merge <file...> [--json]');
|
|
157
|
+
return 2;
|
|
158
|
+
}
|
|
159
|
+
const docs = [];
|
|
160
|
+
for (const f of files) {
|
|
161
|
+
let parsed;
|
|
162
|
+
try {
|
|
163
|
+
parsed = JSON.parse(fs.readFileSync(f, 'utf-8'));
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
out(`cannot read ${f}: ${e.message}`);
|
|
167
|
+
return 2;
|
|
168
|
+
}
|
|
169
|
+
// A report wrapper carries {v, days, agg}; a raw day doc carries tools.
|
|
170
|
+
const doc = parsed.agg?.v === 1 ? parsed.agg : parsed.v === 1 && parsed.tools ? parsed : undefined;
|
|
171
|
+
if (!doc || doc.v !== 1) {
|
|
172
|
+
out(`${f} is neither a day aggregate nor a metrics report --json output`);
|
|
173
|
+
return 2;
|
|
174
|
+
}
|
|
175
|
+
docs.push(doc);
|
|
176
|
+
dayCount += 1;
|
|
177
|
+
}
|
|
178
|
+
merged = sum(docs);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
const dir = describeMetricsDir(env).dir;
|
|
182
|
+
const docs = loadDayDocs(dir, parseSince(argv));
|
|
183
|
+
if (docs.length === 0) {
|
|
184
|
+
out(deps.enabled
|
|
185
|
+
? `local usage metrics are on but no day files exist yet under ${dir}`
|
|
186
|
+
: 'local usage metrics are off (default); enable with GOOGLE_USAGE_METRICS=on');
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
merged = sum(docs);
|
|
190
|
+
dayCount = docs.length;
|
|
191
|
+
}
|
|
192
|
+
if (argv.includes('--promotion')) {
|
|
193
|
+
const rows = classifyPromotion(merged, deps.promotion);
|
|
194
|
+
if (json)
|
|
195
|
+
out(JSON.stringify({ v: 1, days: dayCount, promotion: rows }));
|
|
196
|
+
else
|
|
197
|
+
renderPromotion(rows, out);
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
if (json) {
|
|
201
|
+
out(JSON.stringify({ v: 1, days: dayCount, agg: merged }));
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
renderReport(merged, dayCount, out);
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Transport } from "@modelcontextprotocol/server";
|
|
2
|
+
import type { Metrics } from './usage-metrics.js';
|
|
3
|
+
import { isSchemaValidationText } from './arg-normalize.js';
|
|
4
|
+
export { isSchemaValidationText };
|
|
5
|
+
/**
|
|
6
|
+
* @param isKnownTool membership test against the REGISTERED tool set. The
|
|
7
|
+
* name on a `tools/call` frame is client-supplied, so a shape check alone
|
|
8
|
+
* would let a shape-valid but invented name reach disk and break the
|
|
9
|
+
* closed-vocabulary guarantee. A validation failure against a name the server
|
|
10
|
+
* never registered is a not-found, and is counted as one.
|
|
11
|
+
*/
|
|
12
|
+
export declare function tapUsageMetrics(transport: Transport, metrics: Metrics, isKnownTool?: (name: string) => boolean): Transport;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { isSchemaValidationText } from './arg-normalize.js';
|
|
2
|
+
const PENDING_CAP = 1_000;
|
|
3
|
+
// The detector lives next to the rewriter that consumes the same frames, so
|
|
4
|
+
// the two can never drift apart. This tap sees the ORIGINAL prose:
|
|
5
|
+
// withValidationEnvelope sits BELOW it and converts that same frame into an
|
|
6
|
+
// envelope for the client, so classification here and the contract on the wire
|
|
7
|
+
// stay independent.
|
|
8
|
+
export { isSchemaValidationText };
|
|
9
|
+
/**
|
|
10
|
+
* @param isKnownTool membership test against the REGISTERED tool set. The
|
|
11
|
+
* name on a `tools/call` frame is client-supplied, so a shape check alone
|
|
12
|
+
* would let a shape-valid but invented name reach disk and break the
|
|
13
|
+
* closed-vocabulary guarantee. A validation failure against a name the server
|
|
14
|
+
* never registered is a not-found, and is counted as one.
|
|
15
|
+
*/
|
|
16
|
+
export function tapUsageMetrics(transport, metrics, isKnownTool = () => false) {
|
|
17
|
+
const pending = new Map();
|
|
18
|
+
const recordValidation = (tool) => {
|
|
19
|
+
if (tool !== undefined && isKnownTool(tool))
|
|
20
|
+
metrics.recordRpc('schema_validation', tool);
|
|
21
|
+
else
|
|
22
|
+
metrics.recordRpc('tool_not_found');
|
|
23
|
+
};
|
|
24
|
+
const wrapper = {
|
|
25
|
+
start: () => transport.start(),
|
|
26
|
+
send: (message, options) => {
|
|
27
|
+
try {
|
|
28
|
+
const m = message;
|
|
29
|
+
// Responses only: a server-initiated REQUEST carries an id from a
|
|
30
|
+
// different id space, and evicting on it would drop a real pending call.
|
|
31
|
+
if (m.id !== undefined && message.method === undefined) {
|
|
32
|
+
const tool = pending.get(m.id);
|
|
33
|
+
pending.delete(m.id);
|
|
34
|
+
if (m.error && typeof m.error.code === 'number') {
|
|
35
|
+
if (m.error.code === -32602) {
|
|
36
|
+
if (/unknown tool|not found/i.test(m.error.message ?? ''))
|
|
37
|
+
metrics.recordRpc('tool_not_found');
|
|
38
|
+
else
|
|
39
|
+
recordValidation(tool);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
metrics.recordRpc(m.error.code);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else if (m.result?.isError === true) {
|
|
46
|
+
const first = m.result.content?.[0]?.text;
|
|
47
|
+
if (typeof first === 'string' && isSchemaValidationText(first)) {
|
|
48
|
+
recordValidation(tool);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch { /* the tap may never break the wire */ }
|
|
54
|
+
return transport.send(message, options);
|
|
55
|
+
},
|
|
56
|
+
close: () => transport.close(),
|
|
57
|
+
};
|
|
58
|
+
Object.defineProperty(wrapper, 'onmessage', {
|
|
59
|
+
get: () => transport.onmessage,
|
|
60
|
+
set: (handler) => {
|
|
61
|
+
transport.onmessage = handler
|
|
62
|
+
? (message, extra) => {
|
|
63
|
+
try {
|
|
64
|
+
const m = message;
|
|
65
|
+
if (m.id !== undefined && m.method === 'tools/call' && typeof m.params?.name === 'string') {
|
|
66
|
+
if (pending.size >= PENDING_CAP)
|
|
67
|
+
pending.clear();
|
|
68
|
+
pending.set(m.id, m.params.name);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch { /* never break the wire */ }
|
|
72
|
+
handler(message, extra);
|
|
73
|
+
}
|
|
74
|
+
: undefined;
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
for (const prop of ['onclose', 'onerror']) {
|
|
78
|
+
Object.defineProperty(wrapper, prop, {
|
|
79
|
+
get: () => transport[prop],
|
|
80
|
+
set: (v) => {
|
|
81
|
+
transport[prop] = v;
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
Object.defineProperty(wrapper, 'sessionId', { get: () => transport.sessionId });
|
|
86
|
+
if (transport.setProtocolVersion) {
|
|
87
|
+
wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
|
|
88
|
+
}
|
|
89
|
+
// v2-only; see the same forward in arg-normalize.ts. Both proxies compose,
|
|
90
|
+
// so a member dropped by either layer never reaches the real transport.
|
|
91
|
+
if (transport.setSupportedProtocolVersions) {
|
|
92
|
+
wrapper.setSupportedProtocolVersions = (v) => transport.setSupportedProtocolVersions(v);
|
|
93
|
+
}
|
|
94
|
+
return wrapper;
|
|
95
|
+
}
|
package/dist/oauth-consent.d.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { OAuth2Client } from 'googleapis-common';
|
|
2
|
-
export declare const LOOPBACK_PORT = 4242;
|
|
3
|
-
export declare const LOOPBACK_REDIRECT = "http://localhost:4242/oauth2callback";
|
|
4
2
|
/** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
|
|
5
3
|
export declare class ClientCredentialsMissingError extends Error {
|
|
6
4
|
constructor();
|
|
@@ -14,15 +12,32 @@ export declare class ConsentTimeoutError extends Error {
|
|
|
14
12
|
export declare class ConsentDeniedError extends Error {
|
|
15
13
|
constructor(reason: string);
|
|
16
14
|
}
|
|
15
|
+
export declare const TESTING_MODE_WARNING: string;
|
|
17
16
|
export declare function hasClientCredentials(): boolean;
|
|
18
|
-
/** Build the loopback OAuth2 client from env credentials (throws if unset). */
|
|
19
|
-
export declare function buildConsentClient(): OAuth2Client;
|
|
20
17
|
/**
|
|
21
|
-
*
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
24
|
-
* times out so a stalled consent can't wedge a tool call.
|
|
18
|
+
* Build the loopback OAuth2 client from env credentials (throws if unset).
|
|
19
|
+
* `redirect` comes from openLoopbackConsent(): the port is only known once the
|
|
20
|
+
* listener is bound.
|
|
25
21
|
*/
|
|
26
|
-
export declare function
|
|
22
|
+
export declare function buildConsentClient(redirect: string): OAuth2Client;
|
|
23
|
+
export interface LoopbackConsent {
|
|
24
|
+
/** `http://localhost:<ephemeral port>/oauth2callback` — build the auth URL from this. */
|
|
25
|
+
redirect: string;
|
|
26
|
+
/**
|
|
27
|
+
* Await the OAuth redirect, validate the CSRF `state` (RFC 6749 §10.12), and
|
|
28
|
+
* exchange the code. Returns the token set for the caller to persist.
|
|
29
|
+
*/
|
|
30
|
+
finish(client: OAuth2Client, expectedState: string): Promise<Record<string, unknown>>;
|
|
31
|
+
/**
|
|
32
|
+
* Abandon the consent: shuts the listener down WITHOUT settling finish(), so
|
|
33
|
+
* an abandoned flow can never surface as an unhandled rejection later.
|
|
34
|
+
*/
|
|
35
|
+
close(): void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Bind the loopback listener first (ephemeral port), so the redirect URI is
|
|
39
|
+
* known before the auth URL is built and the callback can't race the browser.
|
|
40
|
+
*/
|
|
41
|
+
export declare function openLoopbackConsent(opts?: {
|
|
27
42
|
timeoutMs?: number;
|
|
28
|
-
}): Promise<
|
|
43
|
+
}): Promise<LoopbackConsent>;
|
package/dist/oauth-consent.js
CHANGED
|
@@ -2,13 +2,11 @@ import { OAuth2Client } from 'googleapis-common';
|
|
|
2
2
|
import http from 'node:http';
|
|
3
3
|
import { URL } from 'node:url';
|
|
4
4
|
// Shared loopback OAuth consent (leg C of cc-auth), used by the account wizard
|
|
5
|
-
// (B7)
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
export const LOOPBACK_PORT = 4242;
|
|
11
|
-
export const LOOPBACK_REDIRECT = `http://localhost:${LOOPBACK_PORT}/oauth2callback`;
|
|
5
|
+
// (B7) and the `auth --account` CLI. Live-server-safe: typed errors instead of
|
|
6
|
+
// process.exit, and a timeout so a never-completed consent can't wedge a tool
|
|
7
|
+
// call forever. The listener binds an EPHEMERAL loopback port (RFC 8252 §7.3;
|
|
8
|
+
// Google Desktop clients accept any http://localhost:<port> redirect), so a
|
|
9
|
+
// second local process on a fixed port can never break auth.
|
|
12
10
|
/** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
|
|
13
11
|
export class ClientCredentialsMissingError extends Error {
|
|
14
12
|
constructor() {
|
|
@@ -17,7 +15,7 @@ export class ClientCredentialsMissingError extends Error {
|
|
|
17
15
|
}
|
|
18
16
|
export class LoopbackPortInUseError extends Error {
|
|
19
17
|
constructor() {
|
|
20
|
-
super(
|
|
18
|
+
super('E_LOOPBACK_PORT_IN_USE: the loopback consent listener could not bind a port; retry.');
|
|
21
19
|
}
|
|
22
20
|
}
|
|
23
21
|
export class ConsentTimeoutError extends Error {
|
|
@@ -30,80 +28,128 @@ export class ConsentDeniedError extends Error {
|
|
|
30
28
|
super(`E_CONSENT_DENIED: ${reason}`);
|
|
31
29
|
}
|
|
32
30
|
}
|
|
31
|
+
// Surfaced after every successful consent: the expiry is invisible until the
|
|
32
|
+
// token dies a week later as reauth_required, so the moment of success is the
|
|
33
|
+
// one place the warning is guaranteed to be seen. Full walkthrough in
|
|
34
|
+
// docs/google-cloud-setup.md.
|
|
35
|
+
export const TESTING_MODE_WARNING = 'Heads-up: while your OAuth client\'s Publishing status is "Testing", Google expires refresh tokens after 7 days (weekly re-auth for every account). ' +
|
|
36
|
+
'When your setup works, set it to "In production" at https://console.cloud.google.com/auth/audience. ' +
|
|
37
|
+
'No verification review is needed for personal use; see docs/google-cloud-setup.md.';
|
|
33
38
|
export function hasClientCredentials() {
|
|
34
39
|
return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
|
35
40
|
}
|
|
36
|
-
/**
|
|
37
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Build the loopback OAuth2 client from env credentials (throws if unset).
|
|
43
|
+
* `redirect` comes from openLoopbackConsent(): the port is only known once the
|
|
44
|
+
* listener is bound.
|
|
45
|
+
*/
|
|
46
|
+
export function buildConsentClient(redirect) {
|
|
38
47
|
if (!hasClientCredentials())
|
|
39
48
|
throw new ClientCredentialsMissingError();
|
|
40
|
-
return new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET,
|
|
49
|
+
return new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, redirect);
|
|
41
50
|
}
|
|
42
51
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* the caller to persist. Never process.exit's (safe inside a live server) and
|
|
46
|
-
* times out so a stalled consent can't wedge a tool call.
|
|
52
|
+
* Bind the loopback listener first (ephemeral port), so the redirect URI is
|
|
53
|
+
* known before the auth URL is built and the callback can't race the browser.
|
|
47
54
|
*/
|
|
48
|
-
export function
|
|
55
|
+
export function openLoopbackConsent(opts = {}) {
|
|
49
56
|
const timeoutMs = opts.timeoutMs ?? 5 * 60_000;
|
|
50
|
-
return new Promise((
|
|
57
|
+
return new Promise((resolveOpen, rejectOpen) => {
|
|
58
|
+
let redirect = '';
|
|
51
59
|
let timer;
|
|
60
|
+
let closed = false;
|
|
61
|
+
let pending;
|
|
62
|
+
const shutdown = () => {
|
|
63
|
+
if (closed)
|
|
64
|
+
return;
|
|
65
|
+
closed = true;
|
|
66
|
+
if (timer)
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
server.close();
|
|
69
|
+
server.closeAllConnections();
|
|
70
|
+
};
|
|
71
|
+
const fail = (err) => {
|
|
72
|
+
const p = pending;
|
|
73
|
+
pending = undefined;
|
|
74
|
+
shutdown();
|
|
75
|
+
p?.reject(err);
|
|
76
|
+
};
|
|
52
77
|
const server = http.createServer(async (req, res) => {
|
|
53
78
|
if (!req.url || !req.url.startsWith('/oauth2callback')) {
|
|
54
79
|
res.writeHead(404).end();
|
|
55
80
|
return;
|
|
56
81
|
}
|
|
82
|
+
if (!pending) {
|
|
83
|
+
// Only reachable if something other than our own browser launch hit
|
|
84
|
+
// the port before finish() armed the exchange; nothing to do with it.
|
|
85
|
+
res.writeHead(503).end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const p = pending;
|
|
57
89
|
const done = (code, body) => {
|
|
58
90
|
res.writeHead(code, { 'Content-Type': 'text/html' });
|
|
59
91
|
res.end(body);
|
|
60
|
-
if (timer)
|
|
61
|
-
clearTimeout(timer);
|
|
62
|
-
server.close();
|
|
63
|
-
server.closeAllConnections();
|
|
64
92
|
};
|
|
65
93
|
try {
|
|
66
|
-
const qs = new URL(req.url,
|
|
94
|
+
const qs = new URL(req.url, redirect).searchParams;
|
|
67
95
|
const error = qs.get('error');
|
|
68
96
|
if (error) {
|
|
69
97
|
done(400, `<p>Authorization denied: ${error}</p>`);
|
|
70
|
-
|
|
98
|
+
fail(new ConsentDeniedError(error));
|
|
71
99
|
return;
|
|
72
100
|
}
|
|
73
101
|
const returnedState = qs.get('state');
|
|
74
|
-
if (returnedState !== expectedState) {
|
|
75
|
-
done(400, '<p>State mismatch
|
|
76
|
-
|
|
102
|
+
if (returnedState !== p.expectedState) {
|
|
103
|
+
done(400, '<p>State mismatch: possible CSRF attempt. Aborting.</p>');
|
|
104
|
+
fail(new Error('E_OAUTH_STATE_MISMATCH: OAuth state token mismatch'));
|
|
77
105
|
return;
|
|
78
106
|
}
|
|
79
107
|
const code = qs.get('code');
|
|
80
108
|
if (!code) {
|
|
81
109
|
done(400, '<p>No authorization code received.</p>');
|
|
82
|
-
|
|
110
|
+
fail(new ConsentDeniedError('no authorization code received'));
|
|
83
111
|
return;
|
|
84
112
|
}
|
|
85
|
-
const { tokens } = await client.getToken(code);
|
|
113
|
+
const { tokens } = await p.client.getToken(code);
|
|
86
114
|
done(200, '<h2>Authentication successful!</h2><p>You can close this tab.</p>');
|
|
87
|
-
|
|
115
|
+
pending = undefined;
|
|
116
|
+
shutdown();
|
|
117
|
+
p.resolve(tokens);
|
|
88
118
|
}
|
|
89
119
|
catch (e) {
|
|
90
120
|
done(500, '<p>Internal error during authentication.</p>');
|
|
91
|
-
|
|
121
|
+
fail(e);
|
|
92
122
|
}
|
|
93
123
|
});
|
|
94
124
|
server.on('error', (err) => {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
125
|
+
const mapped = err.code === 'EADDRINUSE' ? new LoopbackPortInUseError() : err;
|
|
126
|
+
if (pending) {
|
|
127
|
+
fail(mapped);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
shutdown();
|
|
131
|
+
rejectOpen(mapped);
|
|
132
|
+
}
|
|
98
133
|
});
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
reject(new ConsentTimeoutError());
|
|
104
|
-
}, timeoutMs);
|
|
134
|
+
// Bind to loopback only — never expose the OAuth callback to the local network.
|
|
135
|
+
server.listen(0, '127.0.0.1', () => {
|
|
136
|
+
redirect = `http://localhost:${server.address().port}/oauth2callback`;
|
|
137
|
+
timer = setTimeout(() => fail(new ConsentTimeoutError()), timeoutMs);
|
|
105
138
|
// unref so a pending consent never keeps the process alive on its own.
|
|
106
139
|
timer.unref?.();
|
|
140
|
+
resolveOpen({
|
|
141
|
+
redirect,
|
|
142
|
+
finish(client, expectedState) {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
if (closed) {
|
|
145
|
+
reject(new ConsentTimeoutError());
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
pending = { client, expectedState, resolve, reject };
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
close: shutdown,
|
|
152
|
+
});
|
|
107
153
|
});
|
|
108
154
|
});
|
|
109
155
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface OutboundAllowlist {
|
|
2
|
+
entries: string[];
|
|
3
|
+
allows(email: string): boolean;
|
|
4
|
+
}
|
|
5
|
+
/** Parse GOOGLE_OUTBOUND_ALLOWLIST: comma-separated addresses and @domain
|
|
6
|
+
* suffixes ("a@b.com, @company.com"). null = feature off (nothing gated). */
|
|
7
|
+
export declare function resolveOutboundAllowlist(env?: NodeJS.ProcessEnv): OutboundAllowlist | null;
|
|
8
|
+
/** The addresses in `emails` the active allowlist rejects; [] when off. */
|
|
9
|
+
export declare function outboundViolations(emails: string[], env?: NodeJS.ProcessEnv): string[];
|
|
10
|
+
/** The standard error envelope for a blocked outbound target. */
|
|
11
|
+
export declare function outboundDeniedEnvelope(kind: string, blocked: string[], account: string, env?: NodeJS.ProcessEnv): {
|
|
12
|
+
content: {
|
|
13
|
+
type: "text";
|
|
14
|
+
text: string;
|
|
15
|
+
}[];
|
|
16
|
+
isError: true;
|
|
17
|
+
};
|
|
18
|
+
/** Convenience: envelope when violations exist, else null. */
|
|
19
|
+
export declare function checkOutbound(kind: string, emails: string[], account: string, env?: NodeJS.ProcessEnv): {
|
|
20
|
+
content: {
|
|
21
|
+
type: "text";
|
|
22
|
+
text: string;
|
|
23
|
+
}[];
|
|
24
|
+
isError: true;
|
|
25
|
+
} | null;
|
|
26
|
+
export declare function collectBodyRecipients(body: unknown): string[];
|
|
27
|
+
/** Gate one escape-hatch/generated dispatch; envelope when blocked, else null. */
|
|
28
|
+
export declare function checkOutboundForMethod(methodId: string, body: unknown, account: string, env?: NodeJS.ProcessEnv): {
|
|
29
|
+
content: {
|
|
30
|
+
type: "text";
|
|
31
|
+
text: string;
|
|
32
|
+
}[];
|
|
33
|
+
isError: true;
|
|
34
|
+
} | null;
|