gogcli-mcp 4.2.4 → 4.3.0
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +714 -216
- package/dist/lib.js +751 -217
- package/manifest.json +9 -2
- package/mint.yaml +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/arg-guard.ts +68 -0
- package/src/argv.ts +27 -0
- package/src/attachment-root.ts +111 -0
- package/src/attachments.ts +1 -0
- package/src/blob-upload.ts +4 -2
- package/src/file-roots.ts +66 -0
- package/src/gmail-dispatch-guard.ts +56 -16
- package/src/gmail-results.ts +21 -2
- package/src/lib.ts +6 -0
- package/src/run-path-guard.ts +118 -0
- package/src/runner.ts +87 -19
- package/src/tools/api.ts +39 -7
- package/src/tools/appscript.ts +12 -8
- package/src/tools/auth.ts +13 -3
- package/src/tools/calendar.ts +10 -8
- package/src/tools/chat.ts +16 -13
- package/src/tools/classroom.ts +27 -25
- package/src/tools/contacts.ts +5 -3
- package/src/tools/docs.ts +8 -6
- package/src/tools/drive.ts +42 -18
- package/src/tools/gmail.ts +71 -11
- package/src/tools/sheets.ts +10 -8
- package/src/tools/slides.ts +14 -9
- package/src/tools/tasks.ts +7 -5
- package/src/tools/utils.ts +52 -6
- package/tests/arg-guard.test.ts +80 -0
- package/tests/attachment-root.test.ts +130 -0
- package/tests/attachments.test.ts +8 -0
- package/tests/blob-upload.test.ts +4 -3
- package/tests/file-roots.test.ts +101 -0
- package/tests/gmail-dispatch-guard.test.ts +46 -0
- package/tests/gmail-results.test.ts +35 -1
- package/tests/run-path-guard.test.ts +142 -0
- package/tests/runner.test.ts +136 -0
- package/tests/tools/api.test.ts +74 -8
- package/tests/tools/appscript.test.ts +34 -8
- package/tests/tools/auth.test.ts +44 -17
- package/tests/tools/calendar.test.ts +29 -27
- package/tests/tools/chat.test.ts +46 -20
- package/tests/tools/classroom.test.ts +39 -38
- package/tests/tools/contacts.test.ts +3 -2
- package/tests/tools/docs.test.ts +44 -15
- package/tests/tools/drive.test.ts +106 -19
- package/tests/tools/gmail.test.ts +227 -29
- package/tests/tools/run-tool-examples.test.ts +69 -0
- package/tests/tools/sheets.test.ts +16 -15
- package/tests/tools/slides.test.ts +47 -11
- package/tests/tools/tasks.test.ts +7 -6
- package/tests/tools/utils.test.ts +32 -31
- package/vitest.config.ts +5 -0
package/src/tools/gmail.ts
CHANGED
|
@@ -5,7 +5,9 @@ import { finalizeGmailSearch, fetchGmailPages } from '../gmail-results.js';
|
|
|
5
5
|
import type { GogArg } from '../runner.js';
|
|
6
6
|
import { attachInlineParam, inlineAttachmentArgs } from '../attachments.js';
|
|
7
7
|
import type { InlineAttachmentInput } from '../attachments.js';
|
|
8
|
-
import { extractEmails, logGmailDispatch, replyDispatchOp, requireGmailDispatchConfirmation, resultText } from '../gmail-dispatch-guard.js';
|
|
8
|
+
import { attachmentNames, bodyPreview, extractEmails, logGmailDispatch, replyDispatchOp, requireGmailDispatchConfirmation, resultText } from '../gmail-dispatch-guard.js';
|
|
9
|
+
import { pos } from '../argv.js';
|
|
10
|
+
import { confinePath, confinePaths } from '../file-roots.js';
|
|
9
11
|
|
|
10
12
|
// gmail reply / reply-all share an identical flag set (gog 0.27+); they differ
|
|
11
13
|
// only in the subcommand and default recipient set (reply → sender; reply-all
|
|
@@ -24,7 +26,7 @@ export const replySchema = {
|
|
|
24
26
|
remove: z.array(z.string()).optional().describe('Remove these recipients from all fields (repeatable) — e.g. to drop someone from a reply-all.'),
|
|
25
27
|
subject: z.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
|
|
26
28
|
noQuote: z.boolean().optional().describe('Do not include the original message quoted below the reply (default: the original is quoted)'),
|
|
27
|
-
attach: z.array(z.string()).optional().describe('File paths to attach (repeatable), resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on a hosted deployment (e.g. mcp-host) these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension.'),
|
|
29
|
+
attach: z.array(z.string()).optional().describe('File paths to attach (repeatable), resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on a hosted deployment (e.g. mcp-host) these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension. Must be inside the server\'s GOG_FILE_ROOTS directories (default ~/gogcli-mcp-files).'),
|
|
28
30
|
attachInline: attachInlineParam,
|
|
29
31
|
from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
|
|
30
32
|
autoFromAddressedAlias: z.boolean().optional().describe('When from is omitted, send from the verified send-as alias the original message was addressed TO, instead of the account\'s primary address — so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set.'),
|
|
@@ -53,8 +55,18 @@ export type ReplyFlags = {
|
|
|
53
55
|
signatureFile?: string;
|
|
54
56
|
};
|
|
55
57
|
|
|
58
|
+
// Every server path a reply/draft names must sit inside GOG_FILE_ROOTS: each is
|
|
59
|
+
// read on the gog host and mailed out, so unconfined it is a file-exfiltration
|
|
60
|
+
// primitive (audit SEC-3). Checked before anything else touches gog.
|
|
61
|
+
export function confineReplyPaths(f: Pick<ReplyFlags, 'attach' | 'bodyHtmlFile' | 'signatureFile'>): void {
|
|
62
|
+
confinePaths(f.attach, 'attach');
|
|
63
|
+
if (f.bodyHtmlFile) confinePath(f.bodyHtmlFile, 'bodyHtmlFile');
|
|
64
|
+
if (f.signatureFile) confinePath(f.signatureFile, 'signatureFile');
|
|
65
|
+
}
|
|
66
|
+
|
|
56
67
|
export function appendReplyFlags(args: GogArg[], f: ReplyFlags): void {
|
|
57
68
|
assertNotBoth('bodyHtml', 'bodyHtmlFile', f.bodyHtml, f.bodyHtmlFile);
|
|
69
|
+
confineReplyPaths(f);
|
|
58
70
|
if (f.body) args.push(payloadArg('body', 'body-file', f.body));
|
|
59
71
|
if (f.bodyHtml) args.push(payloadArg('body-html', 'body-html-file', f.bodyHtml, 'html'));
|
|
60
72
|
else if (f.bodyHtmlFile) args.push(`--body-html-file=${f.bodyHtmlFile}`);
|
|
@@ -150,7 +162,7 @@ async function sendReply(
|
|
|
150
162
|
flags: ReplyFlags,
|
|
151
163
|
ctx: ServerContext,
|
|
152
164
|
) {
|
|
153
|
-
const metaResult = await runOrDiagnose(['gmail', 'get', messageId, '--format=metadata'], { account });
|
|
165
|
+
const metaResult = await runOrDiagnose(['gmail', 'get', pos(messageId), '--format=metadata'], { account });
|
|
154
166
|
if (metaResult.isError) return metaResult;
|
|
155
167
|
const headers = parseMetadataHeaders(resultText(metaResult));
|
|
156
168
|
const recipients = computeReplyRecipients(kind, headers, flags);
|
|
@@ -161,16 +173,51 @@ async function sendReply(
|
|
|
161
173
|
subject: flags.subject || (headers.subject ? `Re: ${headers.subject}` : undefined),
|
|
162
174
|
quoting: !flags.noQuote,
|
|
163
175
|
bodyLength: (flags.body ?? flags.bodyHtml ?? '').length,
|
|
176
|
+
bodyPreview: bodyPreview(flags.body ?? flags.bodyHtml),
|
|
177
|
+
bodyHtmlFile: flags.bodyHtmlFile,
|
|
164
178
|
attachmentCount: (flags.attach?.length ?? 0) + (flags.attachInline?.length ?? 0),
|
|
179
|
+
attachments: attachmentNames(flags.attach, flags.attachInline),
|
|
165
180
|
});
|
|
166
181
|
if (confirmation) return confirmation;
|
|
167
|
-
const args: GogArg[] = ['gmail', kind, messageId];
|
|
182
|
+
const args: GogArg[] = ['gmail', kind, pos(messageId)];
|
|
168
183
|
appendReplyFlags(args, flags);
|
|
169
184
|
const result = await runOrDiagnose(args, { account });
|
|
170
185
|
if (!result.isError) logGmailDispatch(toolName, recipients, account);
|
|
171
186
|
return result;
|
|
172
187
|
}
|
|
173
188
|
|
|
189
|
+
// What --gmail-no-send does NOT cover (verified on gog 0.41.0): a bulk
|
|
190
|
+
// auto-reply, and the settings that route future mail to someone else —
|
|
191
|
+
// forwarding addresses, auto-forwarding, filters (which can forward) and
|
|
192
|
+
// delegates (which grant another account the mailbox). Each has a dedicated,
|
|
193
|
+
// reviewable tool; none may ride the escape hatch.
|
|
194
|
+
//
|
|
195
|
+
// gog accepts these both under `settings` AND one level up, as
|
|
196
|
+
// `gog gmail filters|forwarding|autoforward|delegates ...` (left out of
|
|
197
|
+
// `gog schema`, but they reach Google), so both spellings are refused.
|
|
198
|
+
const GMAIL_RUN_BLOCKED_SETTINGS = new Set(['forwarding', 'autoforward', 'filters', 'delegates']);
|
|
199
|
+
|
|
200
|
+
export function vetGmailRun(subcommand: string, args: readonly string[]): string | undefined {
|
|
201
|
+
if (subcommand === 'autoreply') {
|
|
202
|
+
return 'gog gmail autoreply sends mail and is not available through gog_gmail_run. Use gog_gmail_autoreply, which asks the user to confirm.';
|
|
203
|
+
}
|
|
204
|
+
if (GMAIL_RUN_BLOCKED_SETTINGS.has(subcommand)) {
|
|
205
|
+
return `gog gmail ${subcommand} can forward or hand over mail and is not available through gog_gmail_run. Use the dedicated gog_gmail_* tool instead.`;
|
|
206
|
+
}
|
|
207
|
+
if (subcommand === 'settings') {
|
|
208
|
+
// kong lets flags precede the command word, and a global flag can take its
|
|
209
|
+
// value as the next token (`settings --color never filters ...`), so the
|
|
210
|
+
// word is not necessarily args[0]. Refuse it wherever it appears; a
|
|
211
|
+
// legitimate settings call carrying one of these words as a value is rare
|
|
212
|
+
// and has a dedicated tool anyway.
|
|
213
|
+
const blocked = args.find((a) => GMAIL_RUN_BLOCKED_SETTINGS.has(a.toLowerCase()));
|
|
214
|
+
if (blocked) {
|
|
215
|
+
return `gog gmail settings ${blocked} can forward or hand over mail and is not available through gog_gmail_run. Use the dedicated gog_gmail_* tool instead.`;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
174
221
|
export function registerGmailTools(server: McpServer): void {
|
|
175
222
|
server.registerTool('gog_gmail_search', {
|
|
176
223
|
description: 'Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread"). The query is passed verbatim to Gmail; a bare name token (from:alison) matches per Gmail\'s own heuristics, a full address (from:alison@example.com) is exact. To match a contact across several addresses, OR them: from:(a@x.com OR b@y.com). '
|
|
@@ -189,7 +236,7 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
189
236
|
account: accountParam,
|
|
190
237
|
}),
|
|
191
238
|
}, async ({ query, max, pageToken, page, maxPages, all, fromContact, account }) => {
|
|
192
|
-
const args = ['gmail', 'search', query];
|
|
239
|
+
const args: GogArg[] = ['gmail', 'search', pos(query)];
|
|
193
240
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
194
241
|
if (all) args.push('--all');
|
|
195
242
|
if (fromContact) args.push(`--from-contact=${fromContact}`);
|
|
@@ -226,7 +273,7 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
226
273
|
account: accountParam,
|
|
227
274
|
}),
|
|
228
275
|
}, async ({ messageId, format, sanitizeContent, account }) => {
|
|
229
|
-
const args = ['gmail', 'get', messageId];
|
|
276
|
+
const args: GogArg[] = ['gmail', 'get', pos(messageId)];
|
|
230
277
|
if (format) args.push(`--format=${format}`);
|
|
231
278
|
if (sanitizeContent) args.push('--sanitize-content');
|
|
232
279
|
return runOrDiagnose(args, { account });
|
|
@@ -234,7 +281,7 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
234
281
|
|
|
235
282
|
server.registerTool('gog_gmail_send', {
|
|
236
283
|
description:
|
|
237
|
-
'SENDS MAIL — asks the MCP host to show a confirmation prompt with the recipients, subject,
|
|
284
|
+
'SENDS MAIL — asks the MCP host to show a confirmation prompt with the recipients, subject, a preview of the body and the attachment names. '
|
|
238
285
|
+ 'Mail is sent only after the user accepts that prompt. '
|
|
239
286
|
+ 'Two ways to attach a file: `attach` takes paths READ ON THE GOG SERVER, and '
|
|
240
287
|
+ '`attachInline` takes the bytes themselves. Use attachInline unless you know the file exists on '
|
|
@@ -255,11 +302,12 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
255
302
|
replyToMessageId: z.string().optional().describe('Message ID to thread this message against — sets In-Reply-To/References only. It does NOT quote the original (pass quote for that), inherit its recipients, or prefix the subject with "Re:". For an actual reply use gog_gmail_reply.'),
|
|
256
303
|
threadId: z.string().optional().describe('Thread ID to thread this message within. Same caveat as replyToMessageId: threading only, no quote and no inherited subject or recipients.'),
|
|
257
304
|
quote: z.boolean().optional().describe('Include the original message quoted below the body. Requires replyToMessageId or threadId. gog quotes by DEFAULT on gmail reply but never on gmail send, so without this a threaded send arrives with the original nowhere in it.'),
|
|
258
|
-
attach: z.array(z.string()).optional().describe('File paths to attach (repeatable), resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on a hosted deployment (e.g. mcp-host) these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Each file is read on the server, base64-encoded with a MIME type inferred from its extension, and added as a multipart attachment.'),
|
|
305
|
+
attach: z.array(z.string()).optional().describe('File paths to attach (repeatable), resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on a hosted deployment (e.g. mcp-host) these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Each file is read on the server, base64-encoded with a MIME type inferred from its extension, and added as a multipart attachment. Must be inside the server\'s GOG_FILE_ROOTS directories (default ~/gogcli-mcp-files).'),
|
|
259
306
|
attachInline: attachInlineParam,
|
|
260
307
|
account: accountParam,
|
|
261
308
|
}),
|
|
262
309
|
}, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account }, ctx) => {
|
|
310
|
+
confinePaths(attach, 'attach');
|
|
263
311
|
// Built (and validated — inlineAttachmentArgs throws on bad base64 or an
|
|
264
312
|
// oversize file) BEFORE the confirmation request, on both paths: a prompt that
|
|
265
313
|
// skipped this would tell a caller "looks fine, send it" about an
|
|
@@ -289,9 +337,11 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
289
337
|
const confirmation = requireGmailDispatchConfirmation(ctx, 'gmail.send', {
|
|
290
338
|
to, cc, bcc, recipients, recipientCount: recipients.length, subject,
|
|
291
339
|
bodyLength: body.length,
|
|
340
|
+
bodyPreview: bodyPreview(body),
|
|
292
341
|
threaded: Boolean(replyToMessageId || threadId),
|
|
293
342
|
quoting: Boolean(quote),
|
|
294
343
|
attachmentCount: (attach?.length ?? 0) + (attachInline?.length ?? 0),
|
|
344
|
+
attachments: attachmentNames(attach, attachInline),
|
|
295
345
|
});
|
|
296
346
|
if (confirmation) return confirmation;
|
|
297
347
|
const result = await runOrDiagnose(args, { account });
|
|
@@ -317,7 +367,7 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
317
367
|
// ==========================================================================
|
|
318
368
|
server.registerTool('gog_gmail_reply', {
|
|
319
369
|
description:
|
|
320
|
-
'SENDS MAIL — asks the MCP host to show a confirmation prompt with the resolved recipient, subject,
|
|
370
|
+
'SENDS MAIL — asks the MCP host to show a confirmation prompt with the resolved recipient, subject, a preview of the body and the attachment names. '
|
|
321
371
|
+ 'Mail is sent only after the user accepts that prompt. To STAGE a reply instead '
|
|
322
372
|
+ 'of sending it, use gog_gmail_drafts_reply (gogcli-mcp-gmail only), which never needs confirmation. '
|
|
323
373
|
+ 'Reply to a Gmail message (goes to the original sender only). USE THIS, not gog_gmail_send, whenever you are '
|
|
@@ -331,12 +381,13 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
331
381
|
inputSchema: sendReplySchema,
|
|
332
382
|
}, async ({ messageId, account, ...flags }, ctx) => {
|
|
333
383
|
assertNotBoth('bodyHtml', 'bodyHtmlFile', flags.bodyHtml, flags.bodyHtmlFile);
|
|
384
|
+
confineReplyPaths(flags);
|
|
334
385
|
return sendReply('reply', 'gog_gmail_reply', messageId, account, flags, ctx);
|
|
335
386
|
});
|
|
336
387
|
|
|
337
388
|
server.registerTool('gog_gmail_reply_all', {
|
|
338
389
|
description:
|
|
339
|
-
'SENDS MAIL — asks the MCP host to show a confirmation prompt with the resolved recipients, subject,
|
|
390
|
+
'SENDS MAIL — asks the MCP host to show a confirmation prompt with the resolved recipients, subject, a preview of the body and the attachment names. '
|
|
340
391
|
+ 'Mail is sent only after the user accepts that prompt. To STAGE a '
|
|
341
392
|
+ 'reply-all instead of sending it, use gog_gmail_drafts_reply_all (gogcli-mcp-gmail only), which never needs '
|
|
342
393
|
+ 'confirmation. '
|
|
@@ -347,8 +398,17 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
347
398
|
inputSchema: sendReplySchema,
|
|
348
399
|
}, async ({ messageId, account, ...flags }, ctx) => {
|
|
349
400
|
assertNotBoth('bodyHtml', 'bodyHtmlFile', flags.bodyHtml, flags.bodyHtmlFile);
|
|
401
|
+
confineReplyPaths(flags);
|
|
350
402
|
return sendReply('reply-all', 'gog_gmail_reply_all', messageId, account, flags, ctx);
|
|
351
403
|
});
|
|
352
404
|
|
|
353
|
-
registerRunTool(server, {
|
|
405
|
+
registerRunTool(server, {
|
|
406
|
+
service: 'gmail',
|
|
407
|
+
examples: '"archive", "mark-read", "labels"',
|
|
408
|
+
// gog's --gmail-no-send blocks send/reply/forward/drafts send and all of
|
|
409
|
+
// their aliases at runtime (verified on gog 0.41.0). Sending goes through the
|
|
410
|
+
// confirmed tools, never this escape hatch.
|
|
411
|
+
gmailNoSend: true,
|
|
412
|
+
vet: vetGmailRun,
|
|
413
|
+
});
|
|
354
414
|
}
|
package/src/tools/sheets.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { run } from '../runner.js';
|
|
|
4
4
|
import { rawTextResult } from '@chrischall/mcp-utils';
|
|
5
5
|
import { accountParam, runOrDiagnose, registerRunTool, diagnose } from './utils.js';
|
|
6
6
|
import { expandAnchorRange, countNonEmptyCells } from './sheets-a1.js';
|
|
7
|
+
import { pos } from '../argv.js';
|
|
8
|
+
import type { GogArg } from '../runner.js';
|
|
7
9
|
|
|
8
10
|
// Cell value type: matches what gog sheets --values-json accepts (passed
|
|
9
11
|
// straight to the Sheets API as userEnteredValue). Strings starting with
|
|
@@ -31,7 +33,7 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
31
33
|
account: accountParam,
|
|
32
34
|
}),
|
|
33
35
|
}, async ({ spreadsheetId, range, account }) => {
|
|
34
|
-
return runOrDiagnose(['sheets', 'get', spreadsheetId, range], { account });
|
|
36
|
+
return runOrDiagnose(['sheets', 'get', pos(spreadsheetId), pos(range)], { account });
|
|
35
37
|
});
|
|
36
38
|
|
|
37
39
|
server.registerTool('gog_sheets_update', {
|
|
@@ -52,7 +54,7 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
52
54
|
const readRange = expandAnchorRange(range, values.length, cols);
|
|
53
55
|
let existing: string;
|
|
54
56
|
try {
|
|
55
|
-
existing = await run(['sheets', 'get', spreadsheetId, readRange], { account });
|
|
57
|
+
existing = await run(['sheets', 'get', pos(spreadsheetId), pos(readRange)], { account });
|
|
56
58
|
} catch (err) {
|
|
57
59
|
// Couldn't read the target — refuse to write rather than risk an
|
|
58
60
|
// overwrite, and diagnose the read failure (auth/transient/etc.) the
|
|
@@ -70,7 +72,7 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
70
72
|
);
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
|
-
const args = ['sheets', 'update', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
|
|
75
|
+
const args: GogArg[] = ['sheets', 'update', pos(spreadsheetId), pos(range), `--values-json=${JSON.stringify(values)}`];
|
|
74
76
|
if (dry_run) args.push('--dry-run');
|
|
75
77
|
if (fail_on_formula_error) args.push('--fail-on-formula-error');
|
|
76
78
|
return runOrDiagnose(args, { account });
|
|
@@ -87,7 +89,7 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
87
89
|
account: accountParam,
|
|
88
90
|
}),
|
|
89
91
|
}, async ({ spreadsheetId, range, values, account, dry_run }) => {
|
|
90
|
-
const args = ['sheets', 'append', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
|
|
92
|
+
const args: GogArg[] = ['sheets', 'append', pos(spreadsheetId), pos(range), `--values-json=${JSON.stringify(values)}`];
|
|
91
93
|
if (dry_run) args.push('--dry-run');
|
|
92
94
|
return runOrDiagnose(args, { account });
|
|
93
95
|
});
|
|
@@ -102,7 +104,7 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
102
104
|
account: accountParam,
|
|
103
105
|
}),
|
|
104
106
|
}, async ({ spreadsheetId, range, account, dry_run }) => {
|
|
105
|
-
const args = ['sheets', 'clear', spreadsheetId, range];
|
|
107
|
+
const args: GogArg[] = ['sheets', 'clear', pos(spreadsheetId), pos(range)];
|
|
106
108
|
if (dry_run) args.push('--dry-run');
|
|
107
109
|
return runOrDiagnose(args, { account });
|
|
108
110
|
});
|
|
@@ -115,7 +117,7 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
115
117
|
account: accountParam,
|
|
116
118
|
}),
|
|
117
119
|
}, async ({ spreadsheetId, account }) => {
|
|
118
|
-
return runOrDiagnose(['sheets', 'metadata', spreadsheetId], { account });
|
|
120
|
+
return runOrDiagnose(['sheets', 'metadata', pos(spreadsheetId)], { account });
|
|
119
121
|
});
|
|
120
122
|
|
|
121
123
|
server.registerTool('gog_sheets_create', {
|
|
@@ -126,7 +128,7 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
126
128
|
account: accountParam,
|
|
127
129
|
}),
|
|
128
130
|
}, async ({ title, account }) => {
|
|
129
|
-
return runOrDiagnose(['sheets', 'create', title], { account });
|
|
131
|
+
return runOrDiagnose(['sheets', 'create', pos(title)], { account });
|
|
130
132
|
});
|
|
131
133
|
|
|
132
134
|
server.registerTool('gog_sheets_find_replace', {
|
|
@@ -139,7 +141,7 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
139
141
|
account: accountParam,
|
|
140
142
|
}),
|
|
141
143
|
}, async ({ spreadsheetId, find, replace, account }) => {
|
|
142
|
-
return runOrDiagnose(['sheets', 'find-replace', spreadsheetId, find, replace], { account });
|
|
144
|
+
return runOrDiagnose(['sheets', 'find-replace', pos(spreadsheetId), pos(find), pos(replace)], { account });
|
|
143
145
|
});
|
|
144
146
|
|
|
145
147
|
registerRunTool(server, { service: 'sheets', examples: '"freeze", "add-tab", "rename-tab"' });
|
package/src/tools/slides.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
|
|
4
|
+
import { pos } from '../argv.js';
|
|
5
|
+
import { confinePath } from '../file-roots.js';
|
|
6
|
+
import type { GogArg } from '../runner.js';
|
|
4
7
|
|
|
5
8
|
export function registerSlidesTools(server: McpServer): void {
|
|
6
9
|
server.registerTool('gog_slides_export', {
|
|
7
|
-
description: 'Export a Google Slides presentation to a local file (pdf or pptx).',
|
|
8
|
-
|
|
10
|
+
description: 'Export a Google Slides presentation to a local file (pdf or pptx). The out path must be inside the server\'s GOG_FILE_ROOTS directories.',
|
|
11
|
+
// Writes a file on the gog host and can overwrite one: not read-only.
|
|
12
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
9
13
|
inputSchema: z.object({
|
|
10
14
|
presentationId: z.string().describe('Presentation ID'),
|
|
11
15
|
out: z.string().optional().describe('Output file path'),
|
|
@@ -14,7 +18,8 @@ export function registerSlidesTools(server: McpServer): void {
|
|
|
14
18
|
account: accountParam,
|
|
15
19
|
}),
|
|
16
20
|
}, async ({ presentationId, out, format, overwrite, account }) => {
|
|
17
|
-
|
|
21
|
+
if (out) confinePath(out, 'out');
|
|
22
|
+
const args: GogArg[] = ['slides', 'export', pos(presentationId)];
|
|
18
23
|
if (out) args.push(`--out=${out}`);
|
|
19
24
|
if (format) args.push(`--format=${format}`);
|
|
20
25
|
if (overwrite) args.push('--overwrite');
|
|
@@ -29,7 +34,7 @@ export function registerSlidesTools(server: McpServer): void {
|
|
|
29
34
|
account: accountParam,
|
|
30
35
|
}),
|
|
31
36
|
}, async ({ presentationId, account }) => {
|
|
32
|
-
return runOrDiagnose(['slides', 'info', presentationId], { account });
|
|
37
|
+
return runOrDiagnose(['slides', 'info', pos(presentationId)], { account });
|
|
33
38
|
});
|
|
34
39
|
|
|
35
40
|
server.registerTool('gog_slides_create', {
|
|
@@ -42,7 +47,7 @@ export function registerSlidesTools(server: McpServer): void {
|
|
|
42
47
|
account: accountParam,
|
|
43
48
|
}),
|
|
44
49
|
}, async ({ title, parent, template, account }) => {
|
|
45
|
-
const args = ['slides', 'create', title];
|
|
50
|
+
const args: GogArg[] = ['slides', 'create', pos(title)];
|
|
46
51
|
if (parent) args.push(`--parent=${parent}`);
|
|
47
52
|
if (template) args.push(`--template=${template}`);
|
|
48
53
|
return runOrDiagnose(args, { account });
|
|
@@ -58,7 +63,7 @@ export function registerSlidesTools(server: McpServer): void {
|
|
|
58
63
|
account: accountParam,
|
|
59
64
|
}),
|
|
60
65
|
}, async ({ presentationId, title, parent, account }) => {
|
|
61
|
-
const args = ['slides', 'copy', presentationId, title];
|
|
66
|
+
const args: GogArg[] = ['slides', 'copy', pos(presentationId), pos(title)];
|
|
62
67
|
if (parent) args.push(`--parent=${parent}`);
|
|
63
68
|
return runOrDiagnose(args, { account });
|
|
64
69
|
});
|
|
@@ -71,7 +76,7 @@ export function registerSlidesTools(server: McpServer): void {
|
|
|
71
76
|
account: accountParam,
|
|
72
77
|
}),
|
|
73
78
|
}, async ({ presentationId, account }) => {
|
|
74
|
-
return runOrDiagnose(['slides', 'list-slides', presentationId], { account });
|
|
79
|
+
return runOrDiagnose(['slides', 'list-slides', pos(presentationId)], { account });
|
|
75
80
|
});
|
|
76
81
|
|
|
77
82
|
server.registerTool('gog_slides_read_slide', {
|
|
@@ -84,10 +89,10 @@ export function registerSlidesTools(server: McpServer): void {
|
|
|
84
89
|
account: accountParam,
|
|
85
90
|
}),
|
|
86
91
|
}, async ({ presentationId, slideId, detail, account }) => {
|
|
87
|
-
const args = ['slides', 'read-slide', presentationId, slideId];
|
|
92
|
+
const args: GogArg[] = ['slides', 'read-slide', pos(presentationId), pos(slideId)];
|
|
88
93
|
if (detail) args.push('--detail');
|
|
89
94
|
return runOrDiagnose(args, { account });
|
|
90
95
|
});
|
|
91
96
|
|
|
92
|
-
registerRunTool(server, { service: 'slides', examples: '"
|
|
97
|
+
registerRunTool(server, { service: 'slides', examples: '"delete-slide", "update-notes"' });
|
|
93
98
|
}
|
package/src/tools/tasks.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
|
|
4
|
+
import { pos } from '../argv.js';
|
|
5
|
+
import type { GogArg } from '../runner.js';
|
|
4
6
|
|
|
5
7
|
export function registerTasksTools(server: McpServer): void {
|
|
6
8
|
server.registerTool('gog_tasks_lists', {
|
|
@@ -21,7 +23,7 @@ export function registerTasksTools(server: McpServer): void {
|
|
|
21
23
|
account: accountParam,
|
|
22
24
|
}),
|
|
23
25
|
}, async ({ tasklistId, account }) => {
|
|
24
|
-
return runOrDiagnose(['tasks', 'list', tasklistId], { account });
|
|
26
|
+
return runOrDiagnose(['tasks', 'list', pos(tasklistId)], { account });
|
|
25
27
|
});
|
|
26
28
|
|
|
27
29
|
server.registerTool('gog_tasks_get', {
|
|
@@ -33,7 +35,7 @@ export function registerTasksTools(server: McpServer): void {
|
|
|
33
35
|
account: accountParam,
|
|
34
36
|
}),
|
|
35
37
|
}, async ({ tasklistId, taskId, account }) => {
|
|
36
|
-
return runOrDiagnose(['tasks', 'get', tasklistId, taskId], { account });
|
|
38
|
+
return runOrDiagnose(['tasks', 'get', pos(tasklistId), pos(taskId)], { account });
|
|
37
39
|
});
|
|
38
40
|
|
|
39
41
|
server.registerTool('gog_tasks_add', {
|
|
@@ -47,7 +49,7 @@ export function registerTasksTools(server: McpServer): void {
|
|
|
47
49
|
account: accountParam,
|
|
48
50
|
}),
|
|
49
51
|
}, async ({ tasklistId, title, notes, due, account }) => {
|
|
50
|
-
const args = ['tasks', 'add', tasklistId, `--title=${title}`];
|
|
52
|
+
const args: GogArg[] = ['tasks', 'add', pos(tasklistId), `--title=${title}`];
|
|
51
53
|
if (notes) args.push(`--notes=${notes}`);
|
|
52
54
|
if (due) args.push(`--due=${due}`);
|
|
53
55
|
return runOrDiagnose(args, { account });
|
|
@@ -62,7 +64,7 @@ export function registerTasksTools(server: McpServer): void {
|
|
|
62
64
|
account: accountParam,
|
|
63
65
|
}),
|
|
64
66
|
}, async ({ tasklistId, taskId, account }) => {
|
|
65
|
-
return runOrDiagnose(['tasks', 'done', tasklistId, taskId], { account });
|
|
67
|
+
return runOrDiagnose(['tasks', 'done', pos(tasklistId), pos(taskId)], { account });
|
|
66
68
|
});
|
|
67
69
|
|
|
68
70
|
server.registerTool('gog_tasks_delete', {
|
|
@@ -76,7 +78,7 @@ export function registerTasksTools(server: McpServer): void {
|
|
|
76
78
|
}, async ({ tasklistId, taskId, account }) => {
|
|
77
79
|
// gog gates this delete behind a confirmation; the runner injects
|
|
78
80
|
// --no-input, so without --force it refuses at runtime.
|
|
79
|
-
return runOrDiagnose(['tasks', 'delete', tasklistId, taskId, '--force'], { account });
|
|
81
|
+
return runOrDiagnose(['tasks', 'delete', pos(tasklistId), pos(taskId), '--force'], { account });
|
|
80
82
|
});
|
|
81
83
|
|
|
82
84
|
registerRunTool(server, { service: 'tasks', examples: '"update", "undo", "clear"' });
|
package/src/tools/utils.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { run } from '../runner.js';
|
|
|
5
5
|
import type { GogArg } from '../runner.js';
|
|
6
6
|
import { normalizeTimestamps } from '../timestamps.js';
|
|
7
7
|
import { stripConsumedPageToken } from '../pagination.js';
|
|
8
|
+
import { assertSafeForwardedArgs, assertSafeSubcommand } from '../arg-guard.js';
|
|
9
|
+
import { assertRunPathsConfined, positionalPathTool } from '../run-path-guard.js';
|
|
8
10
|
|
|
9
11
|
// Byte size at or below which a payload stays on the plain inline flag.
|
|
10
12
|
//
|
|
@@ -110,7 +112,7 @@ export const paginationParams = {
|
|
|
110
112
|
// Append pagination flags to an argv array. Mirrors the shape of
|
|
111
113
|
// paginationParams above. Use together to keep call sites concise.
|
|
112
114
|
export function pushPaginationFlags(
|
|
113
|
-
args:
|
|
115
|
+
args: GogArg[],
|
|
114
116
|
p: { max?: number; pageToken?: string; page?: string; all?: boolean },
|
|
115
117
|
): void {
|
|
116
118
|
if (p.max !== undefined) args.push(`--max=${p.max}`);
|
|
@@ -122,6 +124,14 @@ export function pushPaginationFlags(
|
|
|
122
124
|
// Register a `gog_<service>_run` escape-hatch tool. 11 services currently
|
|
123
125
|
// register an identical-shape tool; this factory keeps them in lockstep.
|
|
124
126
|
// Pass `omitAccount: true` only for auth, which doesn't take --account.
|
|
127
|
+
//
|
|
128
|
+
// THE ARGS ARE MODEL-SUPPLIED AND FORWARDED VERBATIM, so every call is vetted
|
|
129
|
+
// before gog sees it (audit SEC-1): a safety-control override
|
|
130
|
+
// (`--readonly=false`, `--disable-commands=`, `--account=…`, a bare `--`, …) is
|
|
131
|
+
// refused rather than forwarded, and the subcommand must be a plain word.
|
|
132
|
+
// `allowedSubcommands` narrows a service to a named subset (auth: never
|
|
133
|
+
// `tokens export`), `vet` lets a service refuse specific shapes, and
|
|
134
|
+
// `gmailNoSend` pins gog's own `--gmail-no-send` on.
|
|
125
135
|
export function registerRunTool(
|
|
126
136
|
server: McpServer,
|
|
127
137
|
options: {
|
|
@@ -130,11 +140,33 @@ export function registerRunTool(
|
|
|
130
140
|
omitAccount?: boolean;
|
|
131
141
|
/** Extra sentence appended to the description (used by auth to point to gog_auth_add). */
|
|
132
142
|
note?: string;
|
|
143
|
+
/** When set, only these subcommands may run; everything else is refused. */
|
|
144
|
+
allowedSubcommands?: readonly string[];
|
|
145
|
+
/** Service-specific refusal: return a reason to refuse, or undefined to allow. */
|
|
146
|
+
vet?: (subcommand: string, args: readonly string[]) => string | undefined;
|
|
147
|
+
/** Inject gog's --gmail-no-send, so no send can be smuggled through this tool. */
|
|
148
|
+
gmailNoSend?: boolean;
|
|
133
149
|
},
|
|
134
150
|
): void {
|
|
135
|
-
const { service, examples, omitAccount = false, note } = options;
|
|
151
|
+
const { service, examples, omitAccount = false, note, allowedSubcommands, vet, gmailNoSend = false } = options;
|
|
152
|
+
// The examples go straight into the schema the model reads, so every one must
|
|
153
|
+
// be a subcommand this tool actually runs. A stale example (#391: drive
|
|
154
|
+
// "upload" after #390 refused it) fails here, at registration, not in use.
|
|
155
|
+
for (const [, example] of examples.matchAll(/"([^"]+)"/g)) {
|
|
156
|
+
const refused = (allowedSubcommands && !allowedSubcommands.includes(example))
|
|
157
|
+
|| vet?.(example, [])
|
|
158
|
+
|| positionalPathTool(service, example);
|
|
159
|
+
if (refused) {
|
|
160
|
+
throw new Error(`gog_${service}_run example "${example}" is refused by the tool itself; list a subcommand it runs.`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
136
163
|
const baseDescription = `Run any gog ${service} subcommand not covered by the other tools. Run \`gog ${service} --help\` for the full list of subcommands, or \`gog ${service} <subcommand> --help\` for flags on a specific subcommand.`;
|
|
137
|
-
const
|
|
164
|
+
const restriction = allowedSubcommands
|
|
165
|
+
? ` Only these subcommands are available: ${allowedSubcommands.join(', ')}.`
|
|
166
|
+
: '';
|
|
167
|
+
const safety = ' Flags that override server safety controls (--readonly, --enable-commands, --disable-commands, --account, --access-token, --home, --client, --gmail-no-send, --no-input) and a bare "--" are refused.'
|
|
168
|
+
+ ' Local file paths (--out, --out-dir, --attach, --file, --*-file, @file JSON) must be inside GOG_FILE_ROOTS; subcommands that take a local path positionally (e.g. drive upload) are refused in favour of their dedicated tools.';
|
|
169
|
+
const description = `${baseDescription}${restriction}${safety}${note ? ` ${note}` : ''}`;
|
|
138
170
|
const inputSchema: Record<string, z.ZodTypeAny> = {
|
|
139
171
|
subcommand: z.string().describe(`The gog ${service} subcommand to run, e.g. ${examples}`),
|
|
140
172
|
args: z.array(z.string()).describe('Additional positional args and flags'),
|
|
@@ -148,7 +180,21 @@ export function registerRunTool(
|
|
|
148
180
|
inputSchema: z.object(inputSchema),
|
|
149
181
|
}, async (rawArgs) => {
|
|
150
182
|
const { subcommand, args, account } = rawArgs as { subcommand: string; args: string[]; account?: string };
|
|
151
|
-
|
|
183
|
+
try {
|
|
184
|
+
assertSafeSubcommand(subcommand);
|
|
185
|
+
assertSafeForwardedArgs(args);
|
|
186
|
+
if (allowedSubcommands && !allowedSubcommands.includes(subcommand)) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`gog ${service} ${subcommand} is not available through gog_${service}_run. Allowed: ${allowedSubcommands.join(', ')}.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const refusal = vet?.(subcommand, args);
|
|
192
|
+
if (refusal) throw new Error(refusal);
|
|
193
|
+
assertRunPathsConfined(service, subcommand, args);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return errorResult(errorText(err));
|
|
196
|
+
}
|
|
197
|
+
return runOrDiagnose([service, subcommand, ...args], gmailNoSend ? { account, gmailNoSend } : { account });
|
|
152
198
|
});
|
|
153
199
|
}
|
|
154
200
|
|
|
@@ -359,7 +405,7 @@ function isRejectedFieldMask(err: unknown): boolean {
|
|
|
359
405
|
|
|
360
406
|
async function runProjected(
|
|
361
407
|
args: GogArg[],
|
|
362
|
-
options: { account?: string; lossless?: boolean; fieldsMask?: string },
|
|
408
|
+
options: { account?: string; lossless?: boolean; fieldsMask?: string; gmailNoSend?: boolean },
|
|
363
409
|
): Promise<string> {
|
|
364
410
|
const { fieldsMask } = options;
|
|
365
411
|
if (!fieldsMask) return run(args, options);
|
|
@@ -377,7 +423,7 @@ async function runProjected(
|
|
|
377
423
|
|
|
378
424
|
export async function runOrDiagnose(
|
|
379
425
|
args: GogArg[],
|
|
380
|
-
options: { account?: string; lossless?: boolean; fieldsMask?: string; stripMedia?: boolean },
|
|
426
|
+
options: { account?: string; lossless?: boolean; fieldsMask?: string; stripMedia?: boolean; gmailNoSend?: boolean },
|
|
381
427
|
): Promise<CallToolResult> {
|
|
382
428
|
try {
|
|
383
429
|
// The single seam every tool's output passes through. Normalizing here —
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { forbiddenArgReason, assertSafeForwardedArgs, assertSafeSubcommand } from '../src/arg-guard.js';
|
|
3
|
+
|
|
4
|
+
describe('forbiddenArgReason', () => {
|
|
5
|
+
// Each of these would let a model-supplied arg override a control the
|
|
6
|
+
// operator (or this wrapper) set: gog takes the LAST value of a repeated flag,
|
|
7
|
+
// so `--readonly=false` after the injected `--readonly` turns writes back on.
|
|
8
|
+
it.each([
|
|
9
|
+
'--readonly',
|
|
10
|
+
'--readonly=false',
|
|
11
|
+
'--readonly=0',
|
|
12
|
+
'--enable-commands=gmail.send',
|
|
13
|
+
'--enable-commands-exact=gmail send',
|
|
14
|
+
'--disable-commands=',
|
|
15
|
+
'--disable-commands',
|
|
16
|
+
'--access-token=ya29.x',
|
|
17
|
+
'--home=/tmp/other',
|
|
18
|
+
'--account=attacker@example.com',
|
|
19
|
+
'--account',
|
|
20
|
+
'--acct=attacker@example.com',
|
|
21
|
+
'--client=other',
|
|
22
|
+
'--gmail-no-send=false',
|
|
23
|
+
'--no-input=false',
|
|
24
|
+
'--non-interactive=false',
|
|
25
|
+
'--noninteractive=false',
|
|
26
|
+
'--READONLY=false',
|
|
27
|
+
'--Account=attacker@example.com',
|
|
28
|
+
'-a',
|
|
29
|
+
'-aattacker@example.com',
|
|
30
|
+
'-ja',
|
|
31
|
+
'--',
|
|
32
|
+
])('rejects %j', (arg) => {
|
|
33
|
+
expect(forbiddenArgReason(arg)).toMatch(/not allowed/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it.each([
|
|
37
|
+
'msg1',
|
|
38
|
+
'--title=New',
|
|
39
|
+
'--max=5',
|
|
40
|
+
'-y',
|
|
41
|
+
'-5',
|
|
42
|
+
'has:attachment',
|
|
43
|
+
'a--readonly',
|
|
44
|
+
'',
|
|
45
|
+
// Legitimate command flags that merely share a leading word with a control
|
|
46
|
+
// (gog_zoom_auth_setup builds the first three). Matched as bare prefixes
|
|
47
|
+
// they were refused, so zoom auth setup could never run.
|
|
48
|
+
'--account-id=abc',
|
|
49
|
+
'--client-id=x',
|
|
50
|
+
'--client-secret=y',
|
|
51
|
+
'--home-dir=x',
|
|
52
|
+
'--readonly-note=x',
|
|
53
|
+
])('allows %j', (arg) => {
|
|
54
|
+
expect(forbiddenArgReason(arg)).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('names the separator specifically for a bare --', () => {
|
|
58
|
+
expect(forbiddenArgReason('--')).toMatch(/"--"/);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('assertSafeForwardedArgs', () => {
|
|
63
|
+
it('passes a clean list', () => {
|
|
64
|
+
expect(() => assertSafeForwardedArgs(['file1', '--parent=x'])).not.toThrow();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('throws naming the first offending arg', () => {
|
|
68
|
+
expect(() => assertSafeForwardedArgs(['x', '--readonly=false'])).toThrow(/--readonly=false/);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('assertSafeSubcommand', () => {
|
|
73
|
+
it.each(['archive', 'mark-read', 'labels', 'add-tab'])('allows %j', (s) => {
|
|
74
|
+
expect(() => assertSafeSubcommand(s)).not.toThrow();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it.each(['--readonly=false', '-a', '', 'Send Now', '../x'])('rejects %j', (s) => {
|
|
78
|
+
expect(() => assertSafeSubcommand(s)).toThrow(/subcommand/);
|
|
79
|
+
});
|
|
80
|
+
});
|