mcp-google-multi 6.0.0-alpha.26 → 6.0.0-alpha.28

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 CHANGED
@@ -55,6 +55,10 @@ New to all this? It's written for someone who just installed Claude Code and has
55
55
 
56
56
  **Go deeper:** [Configuration reference](./docs/configuration.md) · [What's covered](./COVERAGE.md) · [Features tour](./docs/features.md) · [Remote / HTTP setup](./docs/http-setup.md) · [Secrets in a vault](./docs/secrets.md) · [Migrating to v6](./MIGRATION-v6.md) · [Security policy](./SECURITY.md) · [Roadmap](https://github.com/bakissation/mcp-google-multi/milestones)
57
57
 
58
+ ## Local usage metrics (off by default)
59
+
60
+ The server can keep anonymous, **local-only** usage aggregates for its operator: tool names, error classes, latency buckets. Never arguments, payloads, message content, accounts, or identities of any kind, and **zero network egress ever** — the data cannot leave your machine unless you copy files yourself. It is off until you set `GOOGLE_USAGE_METRICS=on`; when on, the boot log and `doctor` say so and name the source. Read your own data with `mcp-google-multi metrics report`. Details, file format, and the honest threat model: [docs/usage-metrics.md](./docs/usage-metrics.md).
61
+
58
62
  ## Maintainer & credits
59
63
 
60
64
  Built and maintained by **Abdelbaki Berkati** — [berkati.xyz](https://berkati.xyz) · [@bakissation](https://github.com/bakissation). [Read the case study →](https://berkati.xyz/case-studies/mcp-google-multi/)
package/dist/executor.js CHANGED
@@ -2,6 +2,7 @@ import { getClient } from './client.js';
2
2
  import { expandPath, isGoogleApiUrl } from './discovery-client.js';
3
3
  import { handleGoogleApiError } from './tools/_errors.js';
4
4
  import { scopeHintForMethod } from './scope-observability.js';
5
+ import { checkOutboundForMethod } from './outbound-allowlist.js';
5
6
  export const MAX_RESPONSE_CHARS = 100_000;
6
7
  export function jsonResult(payload, isError = false) {
7
8
  const base = { content: [{ type: 'text', text: JSON.stringify(payload) }] };
@@ -29,6 +30,11 @@ export function resolveRequestBody(httpMethod, body) {
29
30
  return body ?? undefined;
30
31
  }
31
32
  export async function executeApiMethod(method, args, deps = {}) {
33
+ // Outbound allowlist: no-op when off; with it active, structured bodies are
34
+ // inspected for recipient fields and raw-compose methods are refused.
35
+ const outbound = checkOutboundForMethod(method.id, args.body, args.account);
36
+ if (outbound)
37
+ return outbound;
32
38
  if (args.queryParams?.alt === 'media') {
33
39
  return jsonResult({
34
40
  error: 'binary_unsupported',
package/dist/index.js CHANGED
@@ -103,6 +103,22 @@ async function main() {
103
103
  process.exitCode = await runResetCli(process.argv);
104
104
  return;
105
105
  }
106
+ if (process.argv.includes('metrics')) {
107
+ const { runMetricsCli } = await import('./metrics-cli.js');
108
+ const { GENERATED_METHOD_TOOLS, CURATED_METHOD_IDS } = await import('./tools/generated/method-map.js');
109
+ const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
110
+ let configValue;
111
+ try {
112
+ configValue = loadConfigFile(undefined, 'throw')?.usageMetrics;
113
+ }
114
+ catch { /* an invalid config never blocks reading local metrics files */ }
115
+ const state = resolveUsageMetrics(process.env, configValue, envSrc?.kind === 'file' ? envSrc.file : undefined);
116
+ process.exitCode = runMetricsCli(process.argv, {
117
+ enabled: state.enabled,
118
+ promotion: { methodMap: GENERATED_METHOD_TOOLS, curatedIds: CURATED_METHOD_IDS },
119
+ });
120
+ return;
121
+ }
106
122
  if (process.argv.includes('write-client-config')) {
107
123
  const { runWriteClientConfigCli } = await import('./client-config.js');
108
124
  process.exitCode = await runWriteClientConfigCli(process.argv);
@@ -0,0 +1,21 @@
1
+ import { type DayAgg } from './usage-metrics.js';
2
+ export interface PromotionData {
3
+ methodMap: Record<string, string>;
4
+ curatedIds: readonly string[];
5
+ }
6
+ export interface MetricsCliDeps {
7
+ enabled: boolean;
8
+ promotion: PromotionData;
9
+ env?: NodeJS.ProcessEnv;
10
+ out?: (line: string) => void;
11
+ }
12
+ interface PromotionRow {
13
+ methodId: string;
14
+ tool?: string;
15
+ calls: number;
16
+ kind: 'curation-candidate' | 'visibility-failure' | 'generation-gap';
17
+ }
18
+ /** The promotion-queue view: ranked evidence, a human decides (spec section 5). */
19
+ export declare function classifyPromotion(merged: DayAgg, data: PromotionData): PromotionRow[];
20
+ export declare function runMetricsCli(argv: string[], deps: MetricsCliDeps): number;
21
+ export {};
@@ -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
+ }
@@ -20,6 +20,19 @@ export function tapUsageMetrics(transport, metrics) {
20
20
  metrics.recordRpc(m.error.code);
21
21
  }
22
22
  }
23
+ else if (m.result?.isError === true) {
24
+ // SDK 1.x synthesizes input-validation failures as isError RESULTS
25
+ // ("MCP error -32602: ..."), skipping the handler entirely, so the
26
+ // registry wrapper never sees them either. Handler envelopes are
27
+ // JSON text and never carry this prefix, so nothing double counts.
28
+ const first = m.result.content?.[0]?.text;
29
+ if (typeof first === 'string' && first.startsWith('MCP error -32602:')) {
30
+ if (/tool \S+ not found|unknown tool/i.test(first))
31
+ metrics.recordRpc('tool_not_found');
32
+ else
33
+ metrics.recordRpc('schema_validation', tool);
34
+ }
35
+ }
23
36
  }
24
37
  }
25
38
  catch { /* the tap may never break the wire */ }
@@ -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;
@@ -0,0 +1,108 @@
1
+ // Opt-in outbound recipient allowlist for unattended deployments: a
2
+ // prompt-injection blast-radius control (a hijacked agent cannot mail, invite
3
+ // or share outside the operator's list), a safety feature first and an
4
+ // enterprise checkbox second. FREE-core forever (ee-definition-v0 handoff).
5
+ // Off by default: an unset/empty GOOGLE_OUTBOUND_ALLOWLIST gates nothing.
6
+ /** Parse GOOGLE_OUTBOUND_ALLOWLIST: comma-separated addresses and @domain
7
+ * suffixes ("a@b.com, @company.com"). null = feature off (nothing gated). */
8
+ export function resolveOutboundAllowlist(env = process.env) {
9
+ const raw = env.GOOGLE_OUTBOUND_ALLOWLIST;
10
+ if (raw === undefined || raw.trim() === '')
11
+ return null;
12
+ const entries = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
13
+ if (entries.length === 0)
14
+ return null;
15
+ const exact = new Set(entries.filter((e) => !e.startsWith('@')));
16
+ const domains = entries.filter((e) => e.startsWith('@'));
17
+ return {
18
+ entries,
19
+ allows(email) {
20
+ const a = email.trim().toLowerCase();
21
+ if (exact.has(a))
22
+ return true;
23
+ return domains.some((d) => a.endsWith(d));
24
+ },
25
+ };
26
+ }
27
+ /** The addresses in `emails` the active allowlist rejects; [] when off. */
28
+ export function outboundViolations(emails, env = process.env) {
29
+ const list = resolveOutboundAllowlist(env);
30
+ if (!list)
31
+ return [];
32
+ return emails.map((e) => e.trim()).filter(Boolean).filter((e) => !list.allows(e));
33
+ }
34
+ /** The standard error envelope for a blocked outbound target. */
35
+ export function outboundDeniedEnvelope(kind, blocked, account, env = process.env) {
36
+ const list = resolveOutboundAllowlist(env);
37
+ return {
38
+ content: [
39
+ {
40
+ type: 'text',
41
+ text: JSON.stringify({
42
+ error: 'recipient_not_allowed',
43
+ message: `${kind} blocked by the outbound allowlist: ${blocked.join(', ')}.`,
44
+ hint: `GOOGLE_OUTBOUND_ALLOWLIST is active (${(list?.entries ?? []).join(', ')}). ` +
45
+ 'The operator must add the address (or its @domain) to the list, or unset the variable, to allow this target.',
46
+ retriable: false,
47
+ account,
48
+ }),
49
+ },
50
+ ],
51
+ isError: true,
52
+ };
53
+ }
54
+ /** Convenience: envelope when violations exist, else null. */
55
+ export function checkOutbound(kind, emails, account, env = process.env) {
56
+ const blocked = outboundViolations(emails, env);
57
+ return blocked.length > 0 ? outboundDeniedEnvelope(kind, blocked, account, env) : null;
58
+ }
59
+ // Escape-hatch / generated-tool enforcement. Structured bodies are inspected
60
+ // for the known recipient fields; RAW compose methods cannot be inspected
61
+ // (base64 RFC 822), so with the allowlist active they are refused outright in
62
+ // favor of the curated tools that enforce it.
63
+ const RAW_SEND_METHODS = new Set(['gmail.users.messages.send', 'gmail.users.drafts.send', 'gmail.users.drafts.create', 'gmail.users.messages.insert', 'gmail.users.messages.import']);
64
+ export function collectBodyRecipients(body) {
65
+ const out = [];
66
+ const walk = (v, depth) => {
67
+ if (!v || typeof v !== 'object' || depth > 4)
68
+ return;
69
+ if (Array.isArray(v)) {
70
+ for (const x of v)
71
+ walk(x, depth + 1);
72
+ return;
73
+ }
74
+ for (const [k, x] of Object.entries(v)) {
75
+ if ((k === 'emailAddress' || k === 'email') && typeof x === 'string' && x.includes('@'))
76
+ out.push(x);
77
+ else if (k === 'attendees' || k === 'permissions')
78
+ walk(x, depth + 1);
79
+ else if (typeof x === 'object')
80
+ walk(x, depth + 1);
81
+ }
82
+ };
83
+ walk(body, 0);
84
+ return out;
85
+ }
86
+ /** Gate one escape-hatch/generated dispatch; envelope when blocked, else null. */
87
+ export function checkOutboundForMethod(methodId, body, account, env = process.env) {
88
+ if (!resolveOutboundAllowlist(env))
89
+ return null;
90
+ if (RAW_SEND_METHODS.has(methodId)) {
91
+ return {
92
+ content: [
93
+ {
94
+ type: 'text',
95
+ text: JSON.stringify({
96
+ error: 'recipient_not_allowed',
97
+ message: `${methodId} carries an uninspectable raw message while GOOGLE_OUTBOUND_ALLOWLIST is active.`,
98
+ hint: 'Use gmail_send / gmail_create_draft, which enforce the allowlist on parsed recipients.',
99
+ retriable: false,
100
+ account,
101
+ }),
102
+ },
103
+ ],
104
+ isError: true,
105
+ };
106
+ }
107
+ return checkOutbound(`${methodId} recipient`, collectBodyRecipients(body), account, env);
108
+ }
@@ -3,6 +3,7 @@ import { coerceArray, coerceBoolean } from './_coerce.js';
3
3
  import { calendar as calendarClient } from '@googleapis/calendar';
4
4
  import { accountAliasSchema } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
+ import { checkOutbound } from '../outbound-allowlist.js';
6
7
  import { handleGoogleApiError } from './_errors.js';
7
8
  import { sliceClean } from '../trim.js';
8
9
  const accountEnum = accountAliasSchema.optional();
@@ -118,6 +119,11 @@ export function registerCalendarTools(server) {
118
119
  },
119
120
  }, async ({ account, summary, start, end, description, location, attendees, calendarId, allDay }) => {
120
121
  try {
122
+ if (attendees) {
123
+ const outbound = checkOutbound('calendar attendee', attendees.split(','), String(account));
124
+ if (outbound)
125
+ return outbound;
126
+ }
121
127
  const auth = await getClient(account);
122
128
  const cal = calendarClient({ version: 'v3', auth });
123
129
  const event = { summary };
@@ -168,6 +174,11 @@ export function registerCalendarTools(server) {
168
174
  },
169
175
  }, async ({ account, eventId, summary, start, end, description, location, attendees, calendarId }) => {
170
176
  try {
177
+ if (attendees) {
178
+ const outbound = checkOutbound('calendar attendee', attendees.split(','), String(account));
179
+ if (outbound)
180
+ return outbound;
181
+ }
171
182
  const auth = await getClient(account);
172
183
  const cal = calendarClient({ version: 'v3', auth });
173
184
  // Fetch the event first so a 404 surfaces before we attempt the patch.
@@ -5,6 +5,7 @@ import { accountAliasSchema, getAccountSet } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError } from './_errors.js';
7
7
  import { openLocalReadStream, prepareLocalDest } from './_local-files.js';
8
+ import { checkOutbound, outboundDeniedEnvelope, resolveOutboundAllowlist } from '../outbound-allowlist.js';
8
9
  import { isAllowed, writeDisabledResult } from '../write-control.js';
9
10
  import { capText } from '../trim.js';
10
11
  import * as fs from 'fs';
@@ -628,6 +629,25 @@ export function registerDriveTools(server) {
628
629
  expirationTime: z.string().optional().describe('RFC 3339 timestamp when access expires. Only valid for role="reader" or "commenter".'),
629
630
  },
630
631
  }, async ({ account, fileId, type, role, emailAddress, domain, sendNotification, emailMessage, transferOwnership, expirationTime }) => {
632
+ {
633
+ // Outbound allowlist (off unless GOOGLE_OUTBOUND_ALLOWLIST is set):
634
+ // user/group grantees must match; a domain share needs its exact
635
+ // "@domain" entry; "anyone" (link sharing) is refused while active.
636
+ const list = resolveOutboundAllowlist();
637
+ if (list) {
638
+ if ((type === 'user' || type === 'group') && emailAddress) {
639
+ const outbound = checkOutbound('drive_share grantee', [emailAddress], String(account));
640
+ if (outbound)
641
+ return outbound;
642
+ }
643
+ else if (type === 'domain' && domain && !list.entries.includes(`@${domain.trim().toLowerCase()}`)) {
644
+ return outboundDeniedEnvelope('drive_share domain grantee', [`@${domain}`], String(account));
645
+ }
646
+ else if (type === 'anyone') {
647
+ return outboundDeniedEnvelope('drive_share grantee', ['anyone (link sharing)'], String(account));
648
+ }
649
+ }
650
+ }
631
651
  try {
632
652
  const auth = await getClient(account);
633
653
  const drive = driveClient({ version: 'v3', auth });
@@ -0,0 +1,2 @@
1
+ export declare const GENERATED_METHOD_TOOLS: Record<string, string>;
2
+ export declare const CURATED_METHOD_IDS: readonly string[];