mcp-google-multi 6.0.0-alpha.27 → 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/dist/executor.js +6 -0
- package/dist/outbound-allowlist.d.ts +34 -0
- package/dist/outbound-allowlist.js +108 -0
- package/dist/tools/calendar.js +11 -0
- package/dist/tools/drive.js +20 -0
- package/dist/tools/gmail.js +9 -0
- package/dist/usage-metrics.js +1 -1
- package/package.json +1 -1
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
|
+
}
|
package/dist/tools/calendar.js
CHANGED
|
@@ -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.
|
package/dist/tools/drive.js
CHANGED
|
@@ -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 });
|
package/dist/tools/gmail.js
CHANGED
|
@@ -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({
|
package/dist/usage-metrics.js
CHANGED
|
@@ -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,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-google-multi",
|
|
3
|
-
"version": "6.0.0-alpha.
|
|
3
|
+
"version": "6.0.0-alpha.28",
|
|
4
4
|
"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
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|