mcp-google-multi 6.0.0-beta.1 → 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.
Files changed (103) hide show
  1. package/README.md +5 -3
  2. package/dist/accounts.d.ts +5 -0
  3. package/dist/accounts.js +12 -1
  4. package/dist/api-probe.js +3 -0
  5. package/dist/arg-normalize.d.ts +61 -4
  6. package/dist/arg-normalize.js +228 -4
  7. package/dist/arg-strict.d.ts +54 -0
  8. package/dist/arg-strict.js +163 -0
  9. package/dist/auth.js +15 -72
  10. package/dist/client.js +15 -5
  11. package/dist/config-file.d.ts +1 -0
  12. package/dist/config-file.js +4 -0
  13. package/dist/discover.js +35 -14
  14. package/dist/discovery-client.d.ts +8 -1
  15. package/dist/discovery-client.js +96 -17
  16. package/dist/doctor.d.ts +9 -1
  17. package/dist/doctor.js +32 -4
  18. package/dist/env-load.d.ts +11 -0
  19. package/dist/env-load.js +23 -1
  20. package/dist/executor.js +12 -1
  21. package/dist/fanout.js +43 -8
  22. package/dist/http-transport.d.ts +11 -2
  23. package/dist/http-transport.js +10 -5
  24. package/dist/index.js +112 -12
  25. package/dist/metrics-cli.d.ts +21 -0
  26. package/dist/metrics-cli.js +206 -0
  27. package/dist/metrics-tap.d.ts +12 -0
  28. package/dist/metrics-tap.js +95 -0
  29. package/dist/oauth-consent.d.ts +25 -10
  30. package/dist/oauth-consent.js +85 -39
  31. package/dist/outbound-allowlist.d.ts +34 -0
  32. package/dist/outbound-allowlist.js +108 -0
  33. package/dist/registry.d.ts +49 -2
  34. package/dist/registry.js +147 -7
  35. package/dist/scope-catalog.js +16 -0
  36. package/dist/services.d.ts +8 -0
  37. package/dist/services.js +28 -1
  38. package/dist/setup-prompt.d.ts +1 -1
  39. package/dist/setup-prompt.js +1 -1
  40. package/dist/tools/_coerce.d.ts +5 -0
  41. package/dist/tools/_coerce.js +11 -0
  42. package/dist/tools/_errors.d.ts +31 -3
  43. package/dist/tools/_errors.js +307 -43
  44. package/dist/tools/_local-files.js +7 -2
  45. package/dist/tools/account-wizard.d.ts +24 -1
  46. package/dist/tools/account-wizard.js +152 -51
  47. package/dist/tools/admin.js +7 -6
  48. package/dist/tools/analytics.d.ts +18 -0
  49. package/dist/tools/analytics.js +284 -0
  50. package/dist/tools/calendar.d.ts +7 -0
  51. package/dist/tools/calendar.js +75 -15
  52. package/dist/tools/chat.js +7 -4
  53. package/dist/tools/contacts.js +57 -30
  54. package/dist/tools/docs.js +56 -54
  55. package/dist/tools/drive.js +132 -70
  56. package/dist/tools/forms.js +7 -7
  57. package/dist/tools/generated/_shared.d.ts +6 -0
  58. package/dist/tools/generated/_shared.js +11 -1
  59. package/dist/tools/generated/admin.js +345 -214
  60. package/dist/tools/generated/analytics.d.ts +2 -0
  61. package/dist/tools/generated/analytics.js +981 -0
  62. package/dist/tools/generated/appsmarket.js +4 -4
  63. package/dist/tools/generated/calendar.js +28 -28
  64. package/dist/tools/generated/chat.js +83 -56
  65. package/dist/tools/generated/classroom.js +258 -216
  66. package/dist/tools/generated/cloudidentity.js +76 -68
  67. package/dist/tools/generated/cloudsearch.js +40 -39
  68. package/dist/tools/generated/contacts.js +20 -11
  69. package/dist/tools/generated/drive.js +62 -53
  70. package/dist/tools/generated/drivelabels.js +60 -32
  71. package/dist/tools/generated/forms.js +7 -7
  72. package/dist/tools/generated/gmail.js +136 -109
  73. package/dist/tools/generated/groupsmigration.js +1 -1
  74. package/dist/tools/generated/groupssettings.js +3 -3
  75. package/dist/tools/generated/index.js +2 -0
  76. package/dist/tools/generated/keep.js +8 -7
  77. package/dist/tools/generated/licensing.js +39 -23
  78. package/dist/tools/generated/meet.js +14 -14
  79. package/dist/tools/generated/method-map.d.ts +2 -0
  80. package/dist/tools/generated/method-map.js +894 -0
  81. package/dist/tools/generated/postmaster.js +3 -3
  82. package/dist/tools/generated/reseller.js +34 -28
  83. package/dist/tools/generated/script.js +29 -19
  84. package/dist/tools/generated/searchconsole.js +5 -3
  85. package/dist/tools/generated/sheets.js +10 -9
  86. package/dist/tools/generated/tasks.js +10 -4
  87. package/dist/tools/generated/vault.js +65 -56
  88. package/dist/tools/generated/workspaceevents.js +17 -16
  89. package/dist/tools/gmail.d.ts +10 -1
  90. package/dist/tools/gmail.js +50 -18
  91. package/dist/tools/google-api.js +48 -19
  92. package/dist/tools/meet.js +5 -2
  93. package/dist/tools/searchconsole.js +10 -10
  94. package/dist/tools/sheets.js +36 -48
  95. package/dist/tools/slides.js +6 -6
  96. package/dist/tools/tasks.js +16 -16
  97. package/dist/trim.d.ts +21 -0
  98. package/dist/trim.js +47 -0
  99. package/dist/usage-metrics.d.ts +155 -0
  100. package/dist/usage-metrics.js +603 -0
  101. package/dist/write-control.d.ts +27 -1
  102. package/dist/write-control.js +90 -11
  103. package/package.json +8 -3
@@ -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
+ }
@@ -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
- * Start the loopback server, await the OAuth redirect, validate the CSRF
22
- * `state` (RFC 6749 §10.12), and exchange the code. Returns the token set for
23
- * the caller to persist. Never process.exit's (safe inside a live server) and
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 awaitLoopbackConsent(client: OAuth2Client, expectedState: string, opts?: {
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<Record<string, unknown>>;
43
+ }): Promise<LoopbackConsent>;
@@ -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). The `auth --account` CLI keeps its own inline copy for now; this module
6
- // is the reusable, live-server-safe form (throws typed errors instead of
7
- // process.exit, and times out so a never-completed consent can't wedge a tool
8
- // call forever). The redirect matches the CLI exactly so an existing registered
9
- // Desktop client keeps working.
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(`E_LOOPBACK_PORT_IN_USE: port ${LOOPBACK_PORT} is already in use; close the other process and retry.`);
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
- /** Build the loopback OAuth2 client from env credentials (throws if unset). */
37
- export function buildConsentClient() {
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, LOOPBACK_REDIRECT);
49
+ return new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, redirect);
41
50
  }
42
51
  /**
43
- * Start the loopback server, await the OAuth redirect, validate the CSRF
44
- * `state` (RFC 6749 §10.12), and exchange the code. Returns the token set for
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 awaitLoopbackConsent(client, expectedState, opts = {}) {
55
+ export function openLoopbackConsent(opts = {}) {
49
56
  const timeoutMs = opts.timeoutMs ?? 5 * 60_000;
50
- return new Promise((resolve, reject) => {
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, LOOPBACK_REDIRECT).searchParams;
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
- reject(new ConsentDeniedError(error));
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 — possible CSRF attempt. Aborting.</p>');
76
- reject(new Error('E_OAUTH_STATE_MISMATCH: OAuth state token mismatch'));
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
- reject(new ConsentDeniedError('no authorization code received'));
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
- resolve(tokens);
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
- reject(e);
121
+ fail(e);
92
122
  }
93
123
  });
94
124
  server.on('error', (err) => {
95
- if (timer)
96
- clearTimeout(timer);
97
- reject(err.code === 'EADDRINUSE' ? new LoopbackPortInUseError() : err);
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
- server.listen(LOOPBACK_PORT, '127.0.0.1', () => {
100
- timer = setTimeout(() => {
101
- server.close();
102
- server.closeAllConnections();
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;
@@ -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
+ }
@@ -1,7 +1,8 @@
1
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
2
  import { z } from 'zod';
3
3
  import { type Policy } from './write-control.js';
4
4
  import type { ArgShape } from './arg-normalize.js';
5
+ import type { Metrics } from './usage-metrics.js';
5
6
  export type Cud = 'read' | 'create' | 'update' | 'delete';
6
7
  export type DiscoveryMode = 'lazy' | 'curated' | 'eager';
7
8
  export declare function resolveDiscoveryMode(env?: NodeJS.ProcessEnv): DiscoveryMode;
@@ -30,16 +31,25 @@ export interface CatalogOperation {
30
31
  summary: string;
31
32
  args: string[];
32
33
  cud: Cud;
34
+ /** Present only when no scope bundle in the catalog can authorize this
35
+ * method, so the call cannot succeed on any account however it is
36
+ * configured. The tool stays callable: graceful dispatch then returns the
37
+ * real scope error rather than a bare "not found". */
38
+ unreachable?: true;
33
39
  }
34
40
  export declare function inferCud(name: string): Cud;
35
41
  export declare class ToolRegistry {
36
42
  private readonly server;
43
+ private readonly metrics;
37
44
  readonly tools: ToolEntry[];
38
45
  readonly policy: Policy;
39
46
  readonly registerTool: McpServer['registerTool'];
40
47
  private readonly revealed;
41
48
  private readonly jsonSchemaCache;
42
49
  private readonly argShapeCache;
50
+ /** Memoized isUngrantable verdict per tool: the answer depends on the frozen
51
+ * bundle catalog only, so it never changes within a process. */
52
+ private readonly ungrantable;
43
53
  private readonly compactOutput;
44
54
  private registeringMeta;
45
55
  /** Configured visibility mode (GOOGLE_DISCOVERY); default lazy = v5 exact. */
@@ -47,13 +57,41 @@ export declare class ToolRegistry {
47
57
  /** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
48
58
  * lazy surface to curated without touching the configured mode. */
49
59
  private expanded;
50
- constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode);
60
+ constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode, metrics?: Metrics | null);
51
61
  registerMeta: McpServer['registerTool'];
52
62
  services(): string[];
53
63
  /** Declared input-schema keys + scalar kinds for one tool (tools/call arg
54
64
  * normalization; the kind drives value coercion on renamed keys). */
55
65
  argShape(name: string): ArgShape | undefined;
66
+ /** Declared argument keys for a tool, in declaration order; undefined when
67
+ * the tool is not registered. Backs unknown-argument screening, which needs
68
+ * the full key list rather than argShape's scalar-kind subset view. */
69
+ declaredKeys(name: string): readonly string[] | undefined;
70
+ /** Keys spelling a similar concept elsewhere in the same service, for the
71
+ * hint on a call whose key matched nothing. Kept only when a key is declared
72
+ * by at least two tools in the service or by a curated one, so one-off
73
+ * generated parameters do not become advice. */
74
+ siblingSpellings(tool: string, unknownKeys: string[]): Array<{
75
+ key: string;
76
+ tools: string[];
77
+ }>;
56
78
  catalog(service: string, query?: string): CatalogOperation[];
79
+ /** The metrics recorder, for hook sites (escape hatch); null when off. */
80
+ get usageMetrics(): Metrics | null;
81
+ /** Every registered tool name, hidden ones included: they stay callable, so
82
+ * a did-you-mean may legitimately point at one. */
83
+ toolNames(): string[];
84
+ /** Membership test against the REGISTERED tool set (hidden tools included:
85
+ * they stay callable). The metrics tap uses this so a client-supplied name
86
+ * can never enter the closed vocabulary. */
87
+ hasTool(name: string): boolean;
88
+ /** Op-name vocabulary for a service, split by provenance so the capped
89
+ * discover descriptions can list curated ops and only summarize the
90
+ * generated long tail. */
91
+ opNames(service: string): {
92
+ curated: string[];
93
+ generated: string[];
94
+ };
57
95
  reveal(service: string): boolean;
58
96
  /** discover_all: advertise the full curated set at once. Idempotent. */
59
97
  expand(): boolean;
@@ -63,6 +101,15 @@ export declare class ToolRegistry {
63
101
  * dispatch), zero correctness impact. */
64
102
  collapse(): boolean;
65
103
  private effectiveMode;
104
+ /**
105
+ * True when NO bundle in the catalog grants any of the method's alternative
106
+ * scopes, so the call cannot succeed on any account under any configuration.
107
+ * Static: it asks the catalog, not an account, so it is safe to cache.
108
+ * `classifyScope` with nothing granted and no profile reports `add_bundle`
109
+ * for a scope some bundle could supply and `unknown_scope` only when none
110
+ * can, which is exactly the distinction wanted here.
111
+ */
112
+ isUngrantable(tool: ToolEntry): boolean;
66
113
  isVisible(tool: ToolEntry): boolean;
67
114
  visibleCount(): {
68
115
  eager: number;