mcp-google-multi 6.0.1 → 6.1.0-beta.1

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.
@@ -5,18 +5,25 @@ import { accountArgLive } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError, invalidParams, mapGoogleError } from './_errors.js';
7
7
  import { buildReplyHeaders, composeRaw, renderMarkdown, htmlToMarkdown, HeaderInjectionError } from './gmail-mime.js';
8
- import { hostFilesRefused, prepareLocalDest } from './_local-files.js';
9
- import { checkOutbound } from '../outbound-allowlist.js';
8
+ import { hostFilesRefused, prepareLocalDest, cleanupPartialWrite, assertLocalPathAllowed, localFileErrorResult, LocalFileError } from './_local-files.js';
9
+ import { checkOutbound, outboundDeniedEnvelope, resolveOutboundAllowlist } from '../outbound-allowlist.js';
10
10
  import addressparser from 'nodemailer/lib/addressparser/index.js';
11
11
  import { lookup as lookupMime } from 'mime-types';
12
- import { configDir } from '../config-file.js';
13
- import { getTokenDir } from '../accounts.js';
14
12
  import { sliceClean } from '../trim.js';
15
13
  import * as path from 'path';
16
14
  import * as fs from 'fs';
17
15
  function getHeader(headers, name) {
18
16
  return headers?.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? '';
19
17
  }
18
+ // RFC 5322 permits a header field to repeat; most generators comma-join
19
+ // recipients into a single To/Cc/Bcc line, but a raw, hand-built message
20
+ // (e.g. one substituted in via gmail.users.drafts.update, which the
21
+ // outbound allowlist cannot otherwise inspect) could carry a SECOND To/Cc/
22
+ // Bcc line instead. getHeader's .find only ever sees the first match, so a
23
+ // recipient check built on it alone would miss every repeat.
24
+ function getHeaderAll(headers, name) {
25
+ return (headers ?? []).filter((h) => h.name?.toLowerCase() === name.toLowerCase()).map((h) => h.value ?? '');
26
+ }
20
27
  function pickHeaders(headers, names) {
21
28
  const out = {};
22
29
  for (const name of names)
@@ -207,7 +214,7 @@ const GMAIL_MAX_MESSAGE_BYTES = 25 * 1024 * 1024;
207
214
  const estimateEncoded = (rawBytes) => Math.ceil((rawBytes * 4) / 3);
208
215
  const attachmentSchema = z
209
216
  .array(z.object({
210
- path: z.string().describe('Absolute local path to the file to attach'),
217
+ path: z.string().describe('Path to the file to attach, on the machine running the server: confined to GOOGLE_LOCAL_FILES_DIR (default <home>/Downloads/mcp-google-multi); a relative path resolves inside it, an absolute path must already be inside it.'),
211
218
  filename: z.string().optional().describe('MIME filename; defaults to the path basename'),
212
219
  contentType: z.string().optional().describe('MIME type; defaults to a lookup on the filename'),
213
220
  }))
@@ -215,55 +222,27 @@ const attachmentSchema = z
215
222
  .describe('Files to attach (each { path, filename?, contentType? }); path must be absolute');
216
223
  /** Reads attachment files into buffers (THIS server reads them, never
217
224
  * MailComposer), enforces absolute-path + total-size guards, and derives the
218
- * MIME filename via basename so a caller name can't inject path separators. */
219
- // Deny reading inside the server's own secret dirs; mailing out master.key /
220
- // <alias>.enc / mcp-jwt.key would be account takeover (also guards against a prompt-injected attach path).
221
- function isInside(dir, target) {
222
- let d = path.resolve(dir);
223
- let t = path.resolve(target);
224
- // Windows path comparison is case-insensitive; without folding, a
225
- // drive-letter/casing difference makes path.relative report "outside".
226
- if (process.platform === 'win32') {
227
- d = d.toLowerCase();
228
- t = t.toLowerCase();
229
- }
230
- const rel = path.relative(d, t);
231
- return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
232
- }
233
- /** Deny if realTarget resolves inside `dir` (both realpath'd so a symlinked
234
- * dir or 8.3/extended-prefix form can't slip past). */
235
- async function isInsideRealDir(dir, realTarget) {
236
- let realDir;
237
- try {
238
- realDir = await fs.promises.realpath(dir);
239
- }
240
- catch {
241
- return false; // dir does not exist → target cannot be inside it
242
- }
243
- return isInside(realDir, realTarget);
244
- }
225
+ * MIME filename via basename so a caller name can't inject path separators.
226
+ * The absolute-path + config/token-dir confinement rule itself lives in
227
+ * assertLocalPathAllowed (src/tools/_local-files.ts), shared with Drive's
228
+ * upload/update/download/export so the two surfaces cannot drift apart. */
245
229
  export async function readAttachments(raw, bodyBytes) {
246
230
  if (!raw || raw.length === 0)
247
231
  return undefined;
248
- const denied = [configDir(), getTokenDir()];
249
232
  const out = [];
250
233
  let total = bodyBytes;
251
234
  for (const a of raw) {
252
- if (!path.isAbsolute(a.path)) {
253
- throw new GmailComposeError('validation_error', `attachment path must be absolute: ${path.basename(a.path)}`);
254
- }
255
- // Resolve symlinks BEFORE any confinement check, or a symlink defeats it.
235
+ // Resolve symlinks BEFORE any confinement check (inside the helper), or a
236
+ // symlink defeats it; read the resolved real path, not the caller's.
256
237
  let real;
257
238
  try {
258
- real = await fs.promises.realpath(a.path);
239
+ real = await assertLocalPathAllowed(a.path, 'read');
259
240
  }
260
- catch {
261
- throw new GmailComposeError('E_ATTACHMENT_NOT_FOUND', `attachment not found or unreadable: ${path.basename(a.path)}`);
262
- }
263
- for (const d of denied) {
264
- if (await isInsideRealDir(d, real)) {
265
- throw new GmailComposeError('E_ATTACHMENT_FORBIDDEN', `refusing to attach a file inside the server's config/token directory: ${path.basename(a.path)}`);
241
+ catch (err) {
242
+ if (err instanceof LocalFileError) {
243
+ throw new GmailComposeError('E_ATTACHMENT_FORBIDDEN', `refusing to attach this file (outside the allowed local-files root, or inside the server's config/token directory): ${path.basename(a.path)}`);
266
244
  }
245
+ throw new GmailComposeError('E_ATTACHMENT_NOT_FOUND', `attachment not found or unreadable: ${path.basename(a.path)}`);
267
246
  }
268
247
  // stat BEFORE read: reject non-regular files (a FIFO would block readFile
269
248
  // forever) and over-cap files without buffering them.
@@ -766,7 +745,7 @@ export function registerGmailTools(server, deps = {}) {
766
745
  messageId: z.string().min(1).describe('The Gmail message ID'),
767
746
  attachmentId: z.string().min(1).describe('The attachment ID from gmail_read response'),
768
747
  filename: z.string().describe('Filename to save as (e.g. report.xlsx)'),
769
- savePath: z.string().min(1).describe('Absolute DIRECTORY path to save into (created if missing, on the machine running the server), e.g. /home/user/Downloads; the file name comes from `filename`'),
748
+ savePath: z.string().min(1).describe('Destination folder to save into, on the machine running the server: confined to GOOGLE_LOCAL_FILES_DIR (default <home>/Downloads/mcp-google-multi), created if missing; a relative path resolves inside it, an absolute path must already be inside it. The file name comes from `filename`.'),
770
749
  },
771
750
  }, async ({ account, messageId, attachmentId, filename, savePath }) => {
772
751
  try {
@@ -781,13 +760,28 @@ export function registerGmailTools(server, deps = {}) {
781
760
  if (!data)
782
761
  throw new Error('No attachment data returned');
783
762
  const buffer = Buffer.from(data, 'base64url');
784
- const fullPath = prepareLocalDest(savePath, filename);
785
- await fs.promises.writeFile(fullPath, buffer, { mode: 0o600 });
763
+ const fullPath = await prepareLocalDest(savePath, filename);
764
+ try {
765
+ // wx: never silently clobber; prepareLocalDest already checked
766
+ // existence, this is the belt-and-suspenders race guard.
767
+ await fs.promises.writeFile(fullPath, buffer, { mode: 0o600, flag: 'wx' });
768
+ }
769
+ catch (err) {
770
+ // A failed download must not leave a partial file behind blocking
771
+ // a retry with local_path_exists; best-effort, original error wins.
772
+ // EEXIST means wx itself refused to open: the TOCTOU race lost to
773
+ // another call that already owns fullPath, so this call never
774
+ // wrote anything there and must not delete that other call's file.
775
+ await cleanupPartialWrite(fullPath, err);
776
+ throw err;
777
+ }
786
778
  return {
787
779
  content: [{ type: 'text', text: `Saved to ${fullPath} (${buffer.length} bytes)` }],
788
780
  };
789
781
  }
790
782
  catch (error) {
783
+ if (error instanceof LocalFileError)
784
+ return localFileErrorResult(error, account);
791
785
  const mapped = composeErrorResult(error, account);
792
786
  return mapped ?? handleGmailError(error, account);
793
787
  }
@@ -1057,6 +1051,30 @@ export function registerGmailTools(server, deps = {}) {
1057
1051
  try {
1058
1052
  const auth = await getClientFn(account);
1059
1053
  const gmail = gmailClient({ version: 'v1', auth });
1054
+ // A draft is only ever recipient-checked when it is composed
1055
+ // (gmail_create_draft). Its raw content can be replaced afterward
1056
+ // through google_api_call (gmail.users.drafts.update), which the
1057
+ // allowlist cannot inspect (raw base64 RFC 822) and so refuses
1058
+ // outright; this re-checks the draft's actual To/Cc/Bcc right
1059
+ // before send so that chain, or any other way a draft's content
1060
+ // changed after creation, cannot slip an unlisted recipient through.
1061
+ if (resolveOutboundAllowlist()) {
1062
+ // drafts.get has no metadataHeaders param (unlike messages.get);
1063
+ // format: 'full' is the only format guaranteed to include headers.
1064
+ const draft = await gmail.users.drafts.get({
1065
+ userId: 'me',
1066
+ id: draftId,
1067
+ format: 'full',
1068
+ });
1069
+ const headers = draft.data.message?.payload?.headers ?? [];
1070
+ const recipients = ['To', 'Cc', 'Bcc']
1071
+ .flatMap((name) => getHeaderAll(headers, name))
1072
+ .flatMap((value) => parseAddresses(value))
1073
+ .map((a) => a.address);
1074
+ const outbound = checkOutbound('gmail_send_draft recipient', recipients, account);
1075
+ if (outbound)
1076
+ return outbound;
1077
+ }
1060
1078
  const res = await gmail.users.drafts.send({
1061
1079
  userId: 'me',
1062
1080
  requestBody: { id: draftId },
@@ -1240,6 +1258,12 @@ export function registerGmailTools(server, deps = {}) {
1240
1258
  restrictToDomain: coerceBoolean.optional().describe('Only reply to same domain (default: false)'),
1241
1259
  },
1242
1260
  }, async ({ account, enableAutoReply, responseSubject, responseBodyPlainText, startTime, endTime, restrictToContacts, restrictToDomain }) => {
1261
+ // An unrestricted auto-reply answers every incoming sender, not just
1262
+ // allowlisted ones, so enabling it without either restriction is
1263
+ // refused outright while the allowlist is active.
1264
+ if (enableAutoReply && !restrictToContacts && !restrictToDomain && resolveOutboundAllowlist()) {
1265
+ return outboundDeniedEnvelope('gmail_set_vacation auto-reply', ['(every sender, not just allowlisted ones; set restrictToContacts or restrictToDomain)'], String(account));
1266
+ }
1243
1267
  try {
1244
1268
  const auth = await getClientFn(account);
1245
1269
  const gmail = gmailClient({ version: 'v1', auth });
@@ -1,5 +1,5 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
- import { type Policy } from '../write-control.js';
2
+ import type { Policy } from '../write-control.js';
3
3
  import { type Toolsets } from '../toolsets.js';
4
4
  import { type ExecuteDeps } from '../executor.js';
5
5
  import { type DiscoveryDeps, type DiscoveryMethod } from '../discovery-client.js';
@@ -9,4 +9,21 @@ export interface EscapeDeps extends DiscoveryDeps, ExecuteDeps {
9
9
  /** Up to three closest known ids for the unknown_method did-you-mean hint;
10
10
  * bounded distance so unrelated ids never masquerade as suggestions. */
11
11
  export declare function nearestMethodIds(methodId: string, index: DiscoveryMethod[]): string[];
12
+ export declare const EXTRA_METHOD_TOOLS: Record<string, string[]>;
13
+ export interface EscapeHatchNames {
14
+ /** Every name a deny glob is checked against: every curated tool name
15
+ * sharing this method, its generated twin, the full method-path name,
16
+ * the legacy last-segment name. Denying any one of them can only narrow
17
+ * what gets through, never widen it, so listing every curated tool here
18
+ * (even when several share one raw method) is always safe. The escape
19
+ * hatch has no allow-list of its own anymore (write-control.ts); only
20
+ * full-writes auto-allows, everything else asks a human. */
21
+ deny: string[];
22
+ /** The real tool name when exactly one exists (curated or generated),
23
+ * else the full-path name: shown in the human confirmation prompt and
24
+ * used to build a sensible message when this call is denied outright. */
25
+ primary: string;
26
+ }
27
+ /** Every name write-control might reasonably know this operation by. */
28
+ export declare function escapeHatchNames(policyService: string, methodId: string): EscapeHatchNames;
12
29
  export declare function registerEscapeTools(registry: ToolRegistry, policy: Policy, deps?: EscapeDeps): void;
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { isAllowed, writeDisabledResult } from '../write-control.js';
2
+ import { escapeHatchGate } from './_escape-hatch.js';
3
3
  import { accountArgLive } from '../accounts.js';
4
4
  import { getClient } from '../client.js';
5
5
  import { coerceJson } from './_coerce.js';
@@ -8,6 +8,7 @@ import { editDistance, SUGGEST_MAX_INPUT } from '../scope-catalog.js';
8
8
  import { executeApiMethod, jsonResult } from '../executor.js';
9
9
  import { SUPPORTED_APIS, resolveApiAliases, cudFromMethod, loadMethodIndex, searchMethods, MAX_SEARCH_QUERY, } from '../discovery-client.js';
10
10
  import { hints } from '../hint-provider.js';
11
+ import { CURATED_METHOD_TOOLS, GENERATED_METHOD_TOOLS, CUD_OVERRIDES } from './generated/method-map.js';
11
12
  // Policy/toolset namespace for each API alias must match the NAMED tools' service
12
13
  // names, or user deny globs and GOOGLE_TOOLSETS silently miss escape-hatch calls.
13
14
  const SERVICE_FOR_ALIAS = {
@@ -52,6 +53,38 @@ export function nearestMethodIds(methodId, index) {
52
53
  .slice(0, 3)
53
54
  .map((x) => x.id);
54
55
  }
56
+ // Mirrors scripts/gen-tools.ts's snakeCase: src/ and scripts/ are separate
57
+ // compilation roots (see tsconfig.scripts.json), so the convention is
58
+ // duplicated rather than imported. A drift between the two would only widen
59
+ // the escape hatch's own full-path name, never narrow it: escapeHatchNames
60
+ // below also carries the last-segment and real-tool names, so write-control
61
+ // degrades gracefully, and the parity test below would catch a drift anyway.
62
+ function snakeSegment(segment) {
63
+ return segment.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
64
+ }
65
+ // A handful of Discovery methods that DO have their own generated twin
66
+ // (so they are not in CURATED_METHOD_TOOLS, which only covers methods a
67
+ // curated tool implements in place of generating one) are ALSO called, as
68
+ // a secondary step, by an unrelated curated tool: e.g. sheets_copy_sheet_to
69
+ // also calls sheets.spreadsheets.sheets.copyTo, the same method its own
70
+ // generated twin sheets_spreadsheets_sheets_copy_to wraps. Listed here so a
71
+ // deny written against that curated tool's name reaches the escape hatch
72
+ // for this method too, not just its generated twin's name.
73
+ export const EXTRA_METHOD_TOOLS = {
74
+ 'sheets.spreadsheets.sheets.copyTo': ['sheets_copy_sheet_to'],
75
+ };
76
+ /** Every name write-control might reasonably know this operation by. */
77
+ export function escapeHatchNames(policyService, methodId) {
78
+ const curatedNames = CURATED_METHOD_TOOLS[methodId] ?? EXTRA_METHOD_TOOLS[methodId] ?? [];
79
+ const generatedName = GENERATED_METHOD_TOOLS[methodId];
80
+ const tail = methodId.split('.').slice(1);
81
+ const fullPathName = tail.length > 0 ? `${policyService}_${tail.map(snakeSegment).join('_')}` : undefined;
82
+ const lastSegment = methodId.split('.').pop() ?? methodId;
83
+ const lastSegmentName = `${policyService}_${snakeSegment(lastSegment)}`;
84
+ const deny = [...new Set([...curatedNames, ...(generatedName ? [generatedName] : []), ...(fullPathName ? [fullPathName] : []), lastSegmentName])];
85
+ const primary = curatedNames.length === 1 ? curatedNames[0] : generatedName ?? fullPathName ?? lastSegmentName;
86
+ return { deny, primary };
87
+ }
55
88
  function describeMethod(m) {
56
89
  return {
57
90
  api: m.api,
@@ -60,7 +93,7 @@ function describeMethod(m) {
60
93
  path: m.path,
61
94
  description: m.description,
62
95
  requiredParams: m.requiredParams,
63
- cud: cudFromMethod(m),
96
+ cud: CUD_OVERRIDES[m.id] ?? cudFromMethod(m),
64
97
  };
65
98
  }
66
99
  export function registerEscapeTools(registry, policy, deps = {}) {
@@ -133,9 +166,11 @@ export function registerEscapeTools(registry, policy, deps = {}) {
133
166
  });
134
167
  registry.registerMeta('google_api_call', {
135
168
  description: 'Invoke any Google Workspace REST method by Discovery id (escape hatch for operations without a ' +
136
- 'dedicated tool). Find methods with google_api_search first. Subject to the same write-control ' +
137
- 'policy as named tools. On reads, pass a `fields` query param (Google partial response) to keep ' +
138
- 'the payload small. Returns JSON only — for binary/file content (media downloads, ' +
169
+ 'dedicated tool). Find methods with google_api_search first. A write asks the connected human to ' +
170
+ 'confirm the real call (account, verb, params, a destinations-first body summary) unless ' +
171
+ 'GOOGLE_PROFILE=full-writes with no outbound allowlist; GOOGLE_READ_ONLY or a matching ' +
172
+ 'GOOGLE_WRITE_DENY still refuse outright. On reads, pass a `fields` query param (Google partial ' +
173
+ 'response) to keep the payload small. Returns JSON only; for binary/file content (media downloads, ' +
139
174
  'drive.files.export) use drive_download / drive_export instead.',
140
175
  inputSchema: {
141
176
  account: accountEnum.describe('Google account alias (omit for the default account)'),
@@ -199,16 +234,55 @@ export function registerEscapeTools(registry, policy, deps = {}) {
199
234
  account,
200
235
  }, true);
201
236
  }
237
+ // ROUND 7 (MEDIUM 3): an undeclared pathParams key has no effect on
238
+ // the actual call (expandPath only substitutes {placeholders} the
239
+ // method's own path template names), but it was still echoed into
240
+ // the confirmation prompt verbatim, letting a caller stuff fake
241
+ // prompt text into a key the method never asked for. Rejected
242
+ // outright rather than silently ignored.
243
+ const declaredPathParams = new Set(Object.entries(method.params).filter(([, p]) => p.location === 'path').map(([n]) => n));
244
+ const unknownPathParams = Object.keys(pathParams ?? {}).filter((k) => !declaredPathParams.has(k));
245
+ if (unknownPathParams.length > 0) {
246
+ return jsonResult({
247
+ error: 'invalid_params',
248
+ message: `${method.id} does not declare path param(s): ${unknownPathParams.join(', ')}.`,
249
+ hint: `Declared path params for ${method.id}: ${[...declaredPathParams].join(', ') || '(none)'}.`,
250
+ retriable: false,
251
+ account,
252
+ }, true);
253
+ }
202
254
  // The RESOLVED index id only, never the caller's methodId argument.
203
255
  registry.usageMetrics?.recordEscapeMethod(method.id);
204
- const cud = cudFromMethod(method);
256
+ // CUD_OVERRIDES corrects the handful of methods HTTP-verb inference
257
+ // misreads (MEDIUM 4: the effect is an argument, not the method name,
258
+ // e.g. a ChromeOS device "action" that can remote-wipe it); a
259
+ // generated method's own twin applies the identical override at gen
260
+ // time (scripts/gen-tools.ts), so a GOOGLE_WRITE_DENY glob keyed on
261
+ // the real write class matches here exactly as it would there.
262
+ const cud = CUD_OVERRIDES[method.id] ?? cudFromMethod(method);
205
263
  const policyService = serviceForAlias(apiKey);
206
- const lastSegment = method.id.split('.').pop() ?? method.id;
207
- // The escape hatch self-gates, so it needs the same scope signal as the registry
208
- // wrapper, or a privileged method would slip past safe-writes here while its generated twin is refused.
209
- const toolRef = { name: `${policyService}_${lastSegment}`, service: policyService, cud, scopes: method.scopes };
210
- if (cud !== 'read' && !isAllowed(toolRef, policy)) {
211
- return writeDisabledResult(toolRef, policy, account);
264
+ // Checked under every name the operation can have (real tool(s), full
265
+ // method path, legacy last-segment) because a method whose id ends in
266
+ // a generic verb, such as drive.permissions.create or
267
+ // gmail.users.settings.filters.create, would otherwise lose its
268
+ // resource and collapse onto a generic drive_create / gmail_create
269
+ // that an operator's resource-scoped GOOGLE_WRITE_DENY could not tell
270
+ // apart from an ordinary write. Deny on any name wins outright;
271
+ // everything else that reaches here either auto-allows under
272
+ // full-writes or asks a human (see escapeHatchGate).
273
+ if (cud !== 'read') {
274
+ const names = escapeHatchNames(policyService, method.id);
275
+ const gate = await escapeHatchGate(registry, names.deny, names.primary, { service: policyService, cud, scopes: method.scopes }, policy, {
276
+ account: account,
277
+ accountEmail: registry.accountSet().configs[account]?.email,
278
+ methodId: method.id,
279
+ httpMethod: method.httpMethod,
280
+ pathParams: pathParams,
281
+ queryParams: queryParams,
282
+ body,
283
+ });
284
+ if (gate)
285
+ return gate;
212
286
  }
213
287
  return executeApiMethod(method, {
214
288
  account: account,
@@ -41,7 +41,7 @@ export const KNOWN_ERROR_SLUGS = new Set([
41
41
  'invalid_scope', 'network_error', 'not_found', 'rate_limited',
42
42
  'reauth_required', 'recipient_not_allowed', 'temporarily_unavailable', 'tenant_not_found', 'too_large', 'toolset_disabled', 'unknown_api',
43
43
  'unknown_argument', 'unknown_method', 'unsupported_grant_type', 'unsupported_type',
44
- 'untrusted_host', 'upstream_error', 'validation_error', 'write_disabled',
44
+ 'untrusted_host', 'upstream_error', 'validation_error', 'write_declined', 'write_disabled', 'write_unsummarizable',
45
45
  ]);
46
46
  const RPC_CODES = new Set([-32700, -32600, -32601, -32603]);
47
47
  export function stateDir(env = process.env) {
@@ -7,17 +7,19 @@ export interface Policy {
7
7
  allow: string[];
8
8
  deny: string[];
9
9
  /**
10
- * Reserved seam (HTTP posture is "annotations only"): the dispatch
11
- * transport is threaded into the resolved policy for a future seam, but in v6 alpha it does NOT stiffen the
12
- * profile or alter any `isAllowed` verdict. "Stricter on HTTP" is achieved
13
- * entirely by the `anthropic/requiresUserInteraction` annotation on the
14
- * irreversible set, emitted identically on both transports.
10
+ * The dispatch transport, threaded into the resolved policy. Never
11
+ * alters `isAllowed`'s verdict or the profile itself: a curated tool's
12
+ * allow/deny/profile gate is identical on both transports. It DOES
13
+ * alter the escape hatch's ask step (`ToolRegistry.elicitConfirmation`,
14
+ * `src/registry.ts`): over HTTP, a write that would otherwise ask a
15
+ * human is refused instead, since the stateless HTTP transport cannot
16
+ * deliver the mid-call confirmation request (see docs/internals.md).
15
17
  * Optional so existing Policy literals (codegen/tests) need no change;
16
18
  * resolvePolicy always populates it.
17
19
  */
18
20
  transport?: Transport;
19
21
  }
20
- interface ToolRef {
22
+ export interface ToolRef {
21
23
  name: string;
22
24
  service: string;
23
25
  cud: Cud;
@@ -41,6 +43,29 @@ export type PrivilegeKind = 'push_registration' | 'privileged_scope';
41
43
  * Discovery, and a list of names would drift out of date silently. */
42
44
  export declare function privilegeOf(tool: ToolRef): PrivilegeKind | undefined;
43
45
  export declare function isAllowed(tool: ToolRef, policy: Policy): boolean;
46
+ export type EscapeHatchDecision = 'allow' | 'deny' | 'ask';
47
+ export interface EscapeHatchVerdict {
48
+ decision: EscapeHatchDecision;
49
+ /** The ref that explains the verdict: the specific name a deny glob
50
+ * matched, or `primaryName` for every other case. */
51
+ ref: ToolRef;
52
+ }
53
+ /**
54
+ * The escape hatch (google_api_call and every generated per-method tool)
55
+ * has no allow-list or privileged-write special case of its own anymore:
56
+ * an explicit deny on any name the operation is known by still wins
57
+ * (narrows what gets through, never widens it, so listing every curated
58
+ * tool sharing a raw method here is always safe); full-writes with the
59
+ * outbound allowlist off is the only automatic allow; everything else
60
+ * (the default profile, safe-writes, any allow list set, or full-writes
61
+ * with the allowlist on) asks a human via elicitation instead of
62
+ * resolving the question itself.
63
+ */
64
+ export declare function escapeHatchVerdict(denyNames: readonly string[], base: {
65
+ service: string;
66
+ cud: Cud;
67
+ scopes?: readonly string[];
68
+ }, primaryName: string, policy: Policy, outboundAllowlistActive: boolean): EscapeHatchVerdict;
44
69
  export type DenyReason = {
45
70
  rule: 'read_only';
46
71
  } | {
@@ -66,4 +91,3 @@ export declare function writeDisabledResult(tool: ToolRef, policy: Policy, accou
66
91
  isError: true;
67
92
  };
68
93
  export declare function describePolicy(policy: Policy): string;
69
- export {};
@@ -68,8 +68,11 @@ function matchesAny(tool, globs) {
68
68
  // billing, or deployed code: not a "safe" write, whatever its HTTP verb.
69
69
  const PRIVILEGED_SCOPE_RE = /\/auth\/(admin\.directory|admin\.datatransfer|cloud-identity|apps\.licensing|apps\.order|apps\.groups\.settings|apps\.groups\.migration|ediscovery|script\.projects|script\.deployments)/;
70
70
  /** Registering a push channel hands this account's activity to an external URL.
71
- * It is shaped like a create and behaves like an export, so the verb hides it. */
72
- const PUSH_REGISTRATION_RE = /(_watch|_subscriptions_create|_subscriptions_reactivate)$/;
71
+ * It is shaped like a create and behaves like an export, so the verb hides it.
72
+ * forms.forms.watches.create/renew generate as _watches_create/_watches_renew
73
+ * (plural "watches", unlike every other API's singular *.watch), so they
74
+ * need their own branch alongside the common _watch suffix. */
75
+ const PUSH_REGISTRATION_RE = /(_watch|_watches_create|_watches_renew|_subscriptions_create|_subscriptions_reactivate)$/;
73
76
  /** Why this write counts as privileged, or undefined if it does not.
74
77
  * Derived, not a hand-kept name list: the tool surface is regenerated from
75
78
  * Discovery, and a list of names would drift out of date silently. */
@@ -116,6 +119,31 @@ export function isAllowed(tool, policy) {
116
119
  return true;
117
120
  return profileAllows(policy.profile, tool);
118
121
  }
122
+ /**
123
+ * The escape hatch (google_api_call and every generated per-method tool)
124
+ * has no allow-list or privileged-write special case of its own anymore:
125
+ * an explicit deny on any name the operation is known by still wins
126
+ * (narrows what gets through, never widens it, so listing every curated
127
+ * tool sharing a raw method here is always safe); full-writes with the
128
+ * outbound allowlist off is the only automatic allow; everything else
129
+ * (the default profile, safe-writes, any allow list set, or full-writes
130
+ * with the allowlist on) asks a human via elicitation instead of
131
+ * resolving the question itself.
132
+ */
133
+ export function escapeHatchVerdict(denyNames, base, primaryName, policy, outboundAllowlistActive) {
134
+ const primaryRef = { name: primaryName, service: base.service, cud: base.cud, scopes: base.scopes };
135
+ if (base.cud === 'read')
136
+ return { decision: 'allow', ref: primaryRef };
137
+ if (policy.readOnly)
138
+ return { decision: 'deny', ref: primaryRef };
139
+ const denyRefs = denyNames.map((name) => ({ name, service: base.service, cud: base.cud, scopes: base.scopes }));
140
+ const deniedRef = denyRefs.find((ref) => matchesAny(ref, policy.deny));
141
+ if (deniedRef)
142
+ return { decision: 'deny', ref: deniedRef };
143
+ if (policy.profile === 'full-writes' && !outboundAllowlistActive)
144
+ return { decision: 'allow', ref: primaryRef };
145
+ return { decision: 'ask', ref: primaryRef };
146
+ }
119
147
  /** Which profiles would permit this tool at all. */
120
148
  function profilesAllowing(tool) {
121
149
  return PROFILES.filter((p) => profileAllows(p, tool));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.1",
3
+ "version": "6.1.0-beta.1",
4
4
  "mcpName": "io.github.bakissation/mcp-google-multi",
5
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.",
6
6
  "type": "module",
@@ -121,8 +121,8 @@ A write a profile refuses comes back as `{ error: "write_disabled", message,
121
121
  hint, retriable: false }` rather than a silent no-op or a generic failure.
122
122
  The `hint` names the exact rule that refused it (a deny pattern, the
123
123
  privileged-write rule, or the active profile) and what would need to change;
124
- read it rather than retrying the same call. This applies identically to
125
- curated tools, generated tools, and the `google_api_call` escape hatch.
124
+ read it rather than retrying the same call. This applies to curated tools.
125
+ A generated tool or `google_api_call` follows a different rule: see section 7.
126
126
 
127
127
  ## 6. Argument names are exact
128
128
 
@@ -153,10 +153,22 @@ If a service has no curated or generated tool for the operation you need
153
153
  that method id and whichever of path params, query params, or a JSON body
154
154
  it needs.
155
155
 
156
- The escape hatch runs through the same write-control policy as named tools
157
- (the read/create/update/delete class is derived from the method's HTTP verb
158
- and name), returns JSON only (binary media is refused with a pointer to
159
- `drive_download` / `drive_export`), and a response over about 100k
160
- characters may be cut by the client, so narrow a large request. Prefer a named curated or
161
- generated tool when one exists: it is more token-lean and better typed than
162
- building the call by hand.
156
+ A read through the escape hatch is never gated, same as everywhere else. A
157
+ write (the class is derived from the method's HTTP verb and name) follows a
158
+ different rule than named tools: `GOOGLE_READ_ONLY` or a matching
159
+ `GOOGLE_WRITE_DENY` still refuses outright, and `GOOGLE_PROFILE=full-writes`
160
+ (with `GOOGLE_OUTBOUND_ALLOWLIST` unset) still allows outright, but every
161
+ other case, including `GOOGLE_WRITE_ALLOW` (allow lists no longer open the
162
+ escape hatch at all), asks the connected human to confirm the real call
163
+ before it runs. Expect a write through `google_api_call` or a generated
164
+ tool (anything called by its full name directly, not through a curated
165
+ tool) to pause for that confirmation unless the deployment runs
166
+ `full-writes`; a client that cannot be asked gets back `{ error:
167
+ "elicitation_unsupported", ... }` instead, and a decline or cancel comes
168
+ back as `{ error: "write_declined", ... }`; neither is worth retrying
169
+ without changing the write-control configuration or using a curated tool
170
+ instead. The escape hatch returns JSON only (binary media is refused with a
171
+ pointer to `drive_download` / `drive_export`), and a response over about
172
+ 100k characters may be cut by the client, so narrow a large request. Prefer
173
+ a named curated or generated tool when one exists: it is more token-lean
174
+ and better typed than building the call by hand.
@@ -146,7 +146,7 @@ Download a binary file from Drive to local disk. For Google Workspace formats (D
146
146
  |---|---|---|---|
147
147
  | `account` | string | optional | Google account alias |
148
148
  | `fileId` | string | required | Google Drive file ID |
149
- | `savePath` | string | required | Absolute DIRECTORY path to save into (created if missing, on the machine running the server); the file name comes from `filename` |
149
+ | `savePath` | string | required | Destination folder to save into, on the machine running the server: confined to GOOGLE_LOCAL_FILES_DIR (default <home>/Downloads/mcp-google-multi), created if missing; a relative path resolves inside it, an absolute path must already be inside it. The file name comes from `filename`. |
150
150
  | `filename` | string | optional | Filename to save as (defaults to the file name in Drive) |
151
151
 
152
152
  ## `drive_empty_trash`
@@ -170,7 +170,7 @@ Export a Google Workspace document (Doc, Sheet, Slide) to a standard format and
170
170
  | `account` | string | optional | Google account alias |
171
171
  | `fileId` | string | required | Google Drive file ID |
172
172
  | `mimeType` | string | required | Target export MIME type (e.g. "application/pdf", "text/markdown", "application/vnd.openxmlformats-officedocument.wordprocessingml.document") |
173
- | `savePath` | string | required | Absolute DIRECTORY path to save into (created if missing, on the machine running the server); the file name comes from `filename` |
173
+ | `savePath` | string | required | Destination folder to save into, on the machine running the server: confined to GOOGLE_LOCAL_FILES_DIR (default <home>/Downloads/mcp-google-multi), created if missing; a relative path resolves inside it, an absolute path must already be inside it. The file name comes from `filename`. |
174
174
  | `filename` | string | optional | Filename to save as (defaults to the Drive name plus the extension implied by mimeType) |
175
175
 
176
176
  ## `drive_get_about`
@@ -464,7 +464,7 @@ Rename, move, or replace content of a Drive file. Any combination in one call. F
464
464
  | `fileId` | string | required | Google Drive file ID |
465
465
  | `newName` | string | optional | New filename |
466
466
  | `newParentFolderId` | string | optional | Move to this folder, named newParentFolderId here, not parentFolderId |
467
- | `localPath` | string | optional | Replace file content with this local file (path on the machine running the server) |
467
+ | `localPath` | string | optional | Replace file content with this local file, on the machine running the server: confined to GOOGLE_LOCAL_FILES_DIR (default <home>/Downloads/mcp-google-multi); a relative path resolves inside it, an absolute path must already be inside it. |
468
468
  | `mimeType` | string | optional | MIME type of the replacement file (required if localPath is provided) |
469
469
  | `convertTo` | enum | optional | When replacing content via localPath, convert the new content into this native Google Workspace type on import: "document" \| "spreadsheet" \| "presentation" \| "drawing" (full application/vnd.google-apps.* ids also accepted). |
470
470
 
@@ -479,7 +479,7 @@ Upload a file to Google Drive, from inline base64 content or (when this server e
479
479
  | `account` | string | optional | Google account alias |
480
480
  | `filename` | string | required | Name as it appears in Drive |
481
481
  | `contentBase64` | string | optional | Base64-encoded file content. Exactly one of contentBase64 or localPath is required. Decoded size is capped at 10 MB. |
482
- | `localPath` | string | optional | Absolute path of the SOURCE file on disk to upload (on the machine running the server; this is not savePath). Exactly one of contentBase64 or localPath is required. |
482
+ | `localPath` | string | optional | Path of the SOURCE file to upload, on the machine running the server (this is not savePath): confined to GOOGLE_LOCAL_FILES_DIR (default <home>/Downloads/mcp-google-multi); a relative path resolves inside it, an absolute path must already be inside it. Exactly one of contentBase64 or localPath is required. |
483
483
  | `mimeType` | string | optional | Source MIME type of the content (inferred from filename if omitted). With `convertTo`, this is the format Drive imports from. |
484
484
  | `convertTo` | enum | optional | Convert the upload into this native Google Workspace type on import: "document" \| "spreadsheet" \| "presentation" \| "drawing" (full application/vnd.google-apps.* ids also accepted). E.g. upload .md/.html/.docx/.txt with convertTo=document to get a real Google Doc. Source must be an importable format. Omit to store the file as-is. |
485
485
  | `parentFolderId` | string | optional | Parent folder ID, not parentId. Defaults to My Drive root |
@@ -106,7 +106,7 @@ Download an email attachment to local disk. Use gmail_read first to get the atta
106
106
  | `messageId` | string | required | The Gmail message ID |
107
107
  | `attachmentId` | string | required | The attachment ID from gmail_read response |
108
108
  | `filename` | string | required | Filename to save as (e.g. report.xlsx) |
109
- | `savePath` | string | required | Absolute DIRECTORY path to save into (created if missing, on the machine running the server), e.g. /home/user/Downloads; the file name comes from `filename` |
109
+ | `savePath` | string | required | Destination folder to save into, on the machine running the server: confined to GOOGLE_LOCAL_FILES_DIR (default <home>/Downloads/mcp-google-multi), created if missing; a relative path resolves inside it, an absolute path must already be inside it. The file name comes from `filename`. |
110
110
 
111
111
  ## `gmail_get_draft`
112
112