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.
- package/dist/compose.js +13 -1
- package/dist/env-load.d.ts +6 -0
- package/dist/env-load.js +8 -0
- package/dist/executor.js +4 -6
- package/dist/identity.js +4 -2
- package/dist/index.js +2 -1
- package/dist/outbound-allowlist.d.ts +36 -3
- package/dist/outbound-allowlist.js +80 -44
- package/dist/registry.d.ts +46 -0
- package/dist/registry.js +67 -1
- package/dist/tools/_escape-hatch.d.ts +23 -0
- package/dist/tools/_escape-hatch.js +239 -0
- package/dist/tools/_local-files.d.ts +94 -1
- package/dist/tools/_local-files.js +269 -9
- package/dist/tools/drive.js +80 -13
- package/dist/tools/generated/_shared.js +22 -0
- package/dist/tools/generated/admin.js +2 -2
- package/dist/tools/generated/method-map.d.ts +3 -0
- package/dist/tools/generated/method-map.js +156 -1
- package/dist/tools/gmail.d.ts +6 -0
- package/dist/tools/gmail.js +71 -47
- package/dist/tools/google-api.d.ts +18 -1
- package/dist/tools/google-api.js +86 -12
- package/dist/usage-metrics.js +1 -1
- package/dist/write-control.d.ts +31 -7
- package/dist/write-control.js +30 -2
- package/package.json +1 -1
- package/skills/google-workspace/SKILL.md +21 -9
- package/skills/google-workspace/reference/drive.md +4 -4
- package/skills/google-workspace/reference/gmail.md +1 -1
package/dist/tools/gmail.js
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
|
239
|
+
real = await assertLocalPathAllowed(a.path, 'read');
|
|
259
240
|
}
|
|
260
|
-
catch {
|
|
261
|
-
|
|
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('
|
|
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
|
-
|
|
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 {
|
|
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;
|
package/dist/tools/google-api.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
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.
|
|
137
|
-
'
|
|
138
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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,
|
package/dist/usage-metrics.js
CHANGED
|
@@ -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) {
|
package/dist/write-control.d.ts
CHANGED
|
@@ -7,17 +7,19 @@ export interface Policy {
|
|
|
7
7
|
allow: string[];
|
|
8
8
|
deny: string[];
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* profile
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 {};
|
package/dist/write-control.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
125
|
-
|
|
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
|
-
|
|
157
|
-
(the
|
|
158
|
-
|
|
159
|
-
`
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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 |
|
|
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 |
|
|
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
|
|
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 |
|
|
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 |
|
|
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
|
|