mcp-google-multi 6.0.0-alpha.27 → 6.0.0-alpha.29

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/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',
@@ -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 });
@@ -6,6 +6,7 @@ import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError, mapGoogleError } from './_errors.js';
7
7
  import { buildReplyHeaders, composeRaw, renderMarkdown, htmlToMarkdown, HeaderInjectionError } from './gmail-mime.js';
8
8
  import { prepareLocalDest } from './_local-files.js';
9
+ import { checkOutbound } from '../outbound-allowlist.js';
9
10
  import addressparser from 'nodemailer/lib/addressparser/index.js';
10
11
  import { lookup as lookupMime } from 'mime-types';
11
12
  import { configDir } from '../config-file.js';
@@ -643,6 +644,10 @@ export function registerGmailTools(server) {
643
644
  if (finalSubject === undefined) {
644
645
  throw new GmailComposeError('E_MISSING_SUBJECT', '`subject` is required (or set replyToMessageId to derive it from the source).');
645
646
  }
647
+ // After derivation so reply-autofilled recipients are gated too.
648
+ const outbound = checkOutbound('gmail recipient', [...parseAddresses(finalTo), ...parseAddresses(finalCc ?? '')].map((a) => a.address), account);
649
+ if (outbound)
650
+ return outbound;
646
651
  const html = renderMarkdown(body, allowRawHtml === true);
647
652
  const files = await readAttachments(attachments, Buffer.byteLength(body ?? '') + Buffer.byteLength(html));
648
653
  const encoded = await composeRaw({
@@ -752,6 +757,10 @@ export function registerGmailTools(server) {
752
757
  if (finalSubject === undefined) {
753
758
  throw new GmailComposeError('E_MISSING_SUBJECT', '`subject` is required (or set replyToMessageId to derive it from the source).');
754
759
  }
760
+ // After derivation so reply-autofilled recipients are gated too.
761
+ const outbound = checkOutbound('gmail recipient', [...parseAddresses(finalTo), ...parseAddresses(finalCc ?? '')].map((a) => a.address), account);
762
+ if (outbound)
763
+ return outbound;
755
764
  const html = renderMarkdown(body, allowRawHtml === true);
756
765
  const files = await readAttachments(attachments, Buffer.byteLength(body ?? '') + Buffer.byteLength(html));
757
766
  const encoded = await composeRaw({
@@ -38,7 +38,7 @@ export const KNOWN_ERROR_SLUGS = new Set([
38
38
  'insufficient_scope', 'internal', 'invalid_client', 'invalid_client_metadata',
39
39
  'invalid_grant', 'invalid_params', 'invalid_query', 'invalid_request',
40
40
  'invalid_scope', 'network_error', 'not_found', 'rate_limited',
41
- 'reauth_required', 'too_large', 'toolset_disabled', 'unknown_api',
41
+ 'reauth_required', 'recipient_not_allowed', 'too_large', 'toolset_disabled', 'unknown_api',
42
42
  'unknown_method', 'unsupported_grant_type', 'unsupported_type',
43
43
  'untrusted_host', 'upstream_error', 'validation_error', 'write_disabled',
44
44
  ]);
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.27",
3
+ "version": "6.0.0-alpha.29",
4
+ "mcpName": "io.github.bakissation/mcp-google-multi",
4
5
  "description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
5
6
  "type": "module",
6
7
  "license": "MIT",