gogcli-mcp 4.2.5 → 4.4.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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +1340 -262
  4. package/dist/lib.js +1381 -260
  5. package/manifest.json +9 -2
  6. package/package.json +2 -2
  7. package/server.json +2 -2
  8. package/src/arg-guard.ts +68 -0
  9. package/src/argv.ts +27 -0
  10. package/src/attachment-root.ts +111 -0
  11. package/src/attachments.ts +1 -0
  12. package/src/blob-upload.ts +4 -2
  13. package/src/dispatch-confirmation.ts +277 -0
  14. package/src/file-roots.ts +66 -0
  15. package/src/gmail-dispatch-guard.ts +70 -30
  16. package/src/gmail-results.ts +21 -2
  17. package/src/lib.ts +15 -0
  18. package/src/run-path-guard.ts +118 -0
  19. package/src/runner.ts +86 -18
  20. package/src/send-confirm-token.ts +167 -0
  21. package/src/tools/api.ts +60 -7
  22. package/src/tools/appscript.ts +12 -8
  23. package/src/tools/auth.ts +13 -3
  24. package/src/tools/calendar.ts +178 -15
  25. package/src/tools/chat.ts +94 -18
  26. package/src/tools/classroom.ts +110 -28
  27. package/src/tools/contacts.ts +5 -3
  28. package/src/tools/docs.ts +8 -6
  29. package/src/tools/drive.ts +96 -20
  30. package/src/tools/gmail.ts +175 -24
  31. package/src/tools/sheets.ts +10 -8
  32. package/src/tools/slides.ts +14 -9
  33. package/src/tools/tasks.ts +7 -5
  34. package/src/tools/utils.ts +52 -6
  35. package/tests/arg-guard.test.ts +80 -0
  36. package/tests/attachment-root.test.ts +130 -0
  37. package/tests/attachments.test.ts +8 -0
  38. package/tests/blob-upload.test.ts +4 -3
  39. package/tests/file-roots.test.ts +101 -0
  40. package/tests/gmail-dispatch-guard.test.ts +270 -11
  41. package/tests/gmail-results.test.ts +35 -1
  42. package/tests/run-path-guard.test.ts +142 -0
  43. package/tests/runner.test.ts +136 -0
  44. package/tests/send-confirm-token.test.ts +200 -0
  45. package/tests/tools/api.test.ts +74 -8
  46. package/tests/tools/appscript.test.ts +34 -8
  47. package/tests/tools/auth.test.ts +44 -17
  48. package/tests/tools/calendar.test.ts +33 -28
  49. package/tests/tools/chat.test.ts +50 -21
  50. package/tests/tools/classroom.test.ts +43 -39
  51. package/tests/tools/contacts.test.ts +3 -2
  52. package/tests/tools/dispatch-gates.test.ts +396 -0
  53. package/tests/tools/docs.test.ts +44 -15
  54. package/tests/tools/drive.test.ts +110 -20
  55. package/tests/tools/gmail-confirm-token.test.ts +274 -0
  56. package/tests/tools/gmail.test.ts +227 -29
  57. package/tests/tools/run-tool-examples.test.ts +69 -0
  58. package/tests/tools/run-vets.test.ts +131 -0
  59. package/tests/tools/sheets.test.ts +16 -15
  60. package/tests/tools/slides.test.ts +47 -11
  61. package/tests/tools/tasks.test.ts +7 -6
  62. package/tests/tools/utils.test.ts +32 -31
  63. package/vitest.config.ts +5 -0
@@ -5,7 +5,10 @@ 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 { attachmentDetails, attachmentNames, attachmentPreview, bodyPreview, CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, extractEmails, logGmailDispatch, replyDispatchOp, requireGmailDispatchConfirmation, resultText, senderPreview } from '../gmail-dispatch-guard.js';
9
+ import { pos } from '../argv.js';
10
+ import { hasCommandWord } from '../dispatch-confirmation.js';
11
+ import { confinePath, confinePaths } from '../file-roots.js';
9
12
 
10
13
  // gmail reply / reply-all share an identical flag set (gog 0.27+); they differ
11
14
  // only in the subcommand and default recipient set (reply → sender; reply-all
@@ -24,7 +27,7 @@ export const replySchema = {
24
27
  remove: z.array(z.string()).optional().describe('Remove these recipients from all fields (repeatable) — e.g. to drop someone from a reply-all.'),
25
28
  subject: z.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
26
29
  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.'),
30
+ 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
31
  attachInline: attachInlineParam,
29
32
  from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
30
33
  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 +56,18 @@ export type ReplyFlags = {
53
56
  signatureFile?: string;
54
57
  };
55
58
 
59
+ // Every server path a reply/draft names must sit inside GOG_FILE_ROOTS: each is
60
+ // read on the gog host and mailed out, so unconfined it is a file-exfiltration
61
+ // primitive (audit SEC-3). Checked before anything else touches gog.
62
+ export function confineReplyPaths(f: Pick<ReplyFlags, 'attach' | 'bodyHtmlFile' | 'signatureFile'>): void {
63
+ confinePaths(f.attach, 'attach');
64
+ if (f.bodyHtmlFile) confinePath(f.bodyHtmlFile, 'bodyHtmlFile');
65
+ if (f.signatureFile) confinePath(f.signatureFile, 'signatureFile');
66
+ }
67
+
56
68
  export function appendReplyFlags(args: GogArg[], f: ReplyFlags): void {
57
69
  assertNotBoth('bodyHtml', 'bodyHtmlFile', f.bodyHtml, f.bodyHtmlFile);
70
+ confineReplyPaths(f);
58
71
  if (f.body) args.push(payloadArg('body', 'body-file', f.body));
59
72
  if (f.bodyHtml) args.push(payloadArg('body-html', 'body-html-file', f.bodyHtml, 'html'));
60
73
  else if (f.bodyHtmlFile) args.push(`--body-html-file=${f.bodyHtmlFile}`);
@@ -86,8 +99,9 @@ export function appendReplyFlags(args: GogArg[], f: ReplyFlags): void {
86
99
  // The send-side reply/reply-all schema, distinct from the shared replySchema
87
100
  // above. gog_gmail_drafts_reply / _reply_all (gogcli-mcp-gmail) reuse
88
101
  // replySchema verbatim and must never gain a send-confirmation input — draft
89
- // tools stage mail, while send tools request confirmation through MCP.
90
- const sendReplySchema = z.object(replySchema);
102
+ // tools stage mail, while send tools request confirmation (through MCP, or the
103
+ // opt-in confirmToken fallback, which is why only THIS schema carries it).
104
+ const sendReplySchema = z.object({ ...replySchema, confirmToken: confirmTokenParam });
91
105
 
92
106
  // gog's own `reply`/`reply-all` response never echoes the resolved
93
107
  // recipients when replying to a single message (the common case: gog only
@@ -136,6 +150,62 @@ export function computeReplyRecipients(
136
150
  return emails;
137
151
  }
138
152
 
153
+ /** `message.threadId` out of `gog gmail get --format=metadata --json`, if present. */
154
+ export function metadataThreadId(raw: string): string | undefined {
155
+ try {
156
+ const threadId = (JSON.parse(raw) as { message?: { threadId?: unknown } } | null)?.message?.threadId;
157
+ return typeof threadId === 'string' ? threadId : undefined;
158
+ } catch {
159
+ return undefined;
160
+ }
161
+ }
162
+
163
+ // The token fallback's preview and bound payload for a reply. To/Cc/Bcc are the
164
+ // same approximation computeReplyRecipients makes (gog composes the final list),
165
+ // split by field so the user can see who is on Bcc.
166
+ function replyTokenSubject(
167
+ kind: 'reply' | 'reply-all',
168
+ messageId: string,
169
+ account: string | undefined,
170
+ headers: Record<string, string>,
171
+ threadId: string | undefined,
172
+ flags: ReplyFlags,
173
+ ) {
174
+ const removed = new Set(extractEmails(...(flags.remove ?? [])));
175
+ const keep = (emails: string[]) => emails.filter((e) => !removed.has(e));
176
+ const to = keep(extractEmails(headers.from, ...(kind === 'reply-all' ? [headers.to] : []), ...(flags.to ?? [])));
177
+ const cc = keep(extractEmails(...(kind === 'reply-all' ? [headers.cc] : []), ...(flags.cc ?? [])));
178
+ const bcc = keep(extractEmails(...(flags.bcc ?? [])));
179
+ const attachments = attachmentDetails(flags.attach, flags.attachInline);
180
+ const subject = flags.subject || (headers.subject ? `Re: ${headers.subject}` : undefined);
181
+ const from = flags.from ?? (flags.autoFromAddressedAlias ? 'the send-as alias the original was addressed to' : senderPreview(account));
182
+ const localFiles = attachmentDetails([flags.bodyHtmlFile, flags.signatureFile].filter((p): p is string => Boolean(p)), undefined);
183
+ return {
184
+ target: messageId,
185
+ payload: {
186
+ kind, to, cc, bcc, subject, from,
187
+ body: flags.body, bodyHtml: flags.bodyHtml, localFiles,
188
+ signature: Boolean(flags.signature), signatureFrom: flags.signatureFrom,
189
+ attachments, quote: !flags.noQuote,
190
+ threadId, inReplyTo: headers.message_id, references: headers.references,
191
+ },
192
+ preview: {
193
+ from, to, cc, bcc, subject,
194
+ body: flags.body,
195
+ ...(flags.bodyHtml ? { bodyHtml: flags.bodyHtml } : {}),
196
+ ...(flags.bodyHtmlFile ? { bodyHtmlFile: flags.bodyHtmlFile } : {}),
197
+ ...(flags.signature || flags.signatureFrom || flags.signatureFile
198
+ ? { signature: flags.signatureFile ?? flags.signatureFrom ?? 'the Gmail signature of the sending address' }
199
+ : {}),
200
+ quotesOriginal: !flags.noQuote,
201
+ attachments: attachmentPreview(attachments),
202
+ replyingToMessageId: messageId,
203
+ threadId,
204
+ inReplyTo: headers.message_id,
205
+ },
206
+ };
207
+ }
208
+
139
209
  // Shared by gog_gmail_reply and gog_gmail_reply_all: fetch the target
140
210
  // message's headers (always — the prompt needs them and so does the audit
141
211
  // log on the accepted path), then confirm and send.
@@ -149,28 +219,71 @@ async function sendReply(
149
219
  account: string | undefined,
150
220
  flags: ReplyFlags,
151
221
  ctx: ServerContext,
222
+ confirmToken?: string,
152
223
  ) {
153
- const metaResult = await runOrDiagnose(['gmail', 'get', messageId, '--format=metadata'], { account });
224
+ const metaResult = await runOrDiagnose(['gmail', 'get', pos(messageId), '--format=metadata'], { account });
154
225
  if (metaResult.isError) return metaResult;
155
- const headers = parseMetadataHeaders(resultText(metaResult));
226
+ const metaText = resultText(metaResult);
227
+ const headers = parseMetadataHeaders(metaText);
156
228
  const recipients = computeReplyRecipients(kind, headers, flags);
157
- const confirmation = requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
229
+ const confirmation = await requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
158
230
  messageId,
159
231
  recipients,
160
232
  recipientCount: recipients.length,
161
233
  subject: flags.subject || (headers.subject ? `Re: ${headers.subject}` : undefined),
162
234
  quoting: !flags.noQuote,
163
235
  bodyLength: (flags.body ?? flags.bodyHtml ?? '').length,
236
+ bodyPreview: bodyPreview(flags.body ?? flags.bodyHtml),
237
+ bodyHtmlFile: flags.bodyHtmlFile,
164
238
  attachmentCount: (flags.attach?.length ?? 0) + (flags.attachInline?.length ?? 0),
239
+ attachments: attachmentNames(flags.attach, flags.attachInline),
240
+ }, {
241
+ tool: toolName,
242
+ account,
243
+ confirmToken,
244
+ // The metadata read above IS the phase-2 re-read: it runs on every call,
245
+ // so the original's Message-ID/References are fresh here.
246
+ subject: () => replyTokenSubject(kind, messageId, account, headers, metadataThreadId(metaText), flags),
165
247
  });
166
248
  if (confirmation) return confirmation;
167
- const args: GogArg[] = ['gmail', kind, messageId];
249
+ const args: GogArg[] = ['gmail', kind, pos(messageId)];
168
250
  appendReplyFlags(args, flags);
169
251
  const result = await runOrDiagnose(args, { account });
170
252
  if (!result.isError) logGmailDispatch(toolName, recipients, account);
171
253
  return result;
172
254
  }
173
255
 
256
+ // What --gmail-no-send does NOT cover (verified on gog 0.41.0): a bulk
257
+ // auto-reply, and the settings that route future mail to someone else —
258
+ // forwarding addresses, auto-forwarding, filters (which can forward) and
259
+ // delegates (which grant another account the mailbox). Each has a dedicated,
260
+ // reviewable tool; none may ride the escape hatch.
261
+ //
262
+ // gog accepts these both under `settings` AND one level up, as
263
+ // `gog gmail filters|forwarding|autoforward|delegates ...` (left out of
264
+ // `gog schema`, but they reach Google), so both spellings are refused.
265
+ const GMAIL_RUN_BLOCKED_SETTINGS = new Set(['forwarding', 'autoforward', 'filters', 'delegates']);
266
+
267
+ export function vetGmailRun(subcommand: string, args: readonly string[]): string | undefined {
268
+ if (subcommand === 'autoreply') {
269
+ return 'gog gmail autoreply sends mail and is not available through gog_gmail_run. Use gog_gmail_autoreply, which asks the user to confirm.';
270
+ }
271
+ if (GMAIL_RUN_BLOCKED_SETTINGS.has(subcommand)) {
272
+ 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.`;
273
+ }
274
+ if (subcommand === 'settings') {
275
+ // A global flag can take its value as the next token (`settings --color
276
+ // never filters ...`), so the word is not necessarily args[0]. Refuse it
277
+ // wherever it appears; a legitimate settings call carrying one of these
278
+ // words as a value is rare and has a dedicated tool anyway.
279
+ const blocked = hasCommandWord(args, GMAIL_RUN_BLOCKED_SETTINGS);
280
+ if (blocked) {
281
+ 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.`;
282
+ }
283
+ }
284
+ return undefined;
285
+ }
286
+
174
287
  export function registerGmailTools(server: McpServer): void {
175
288
  server.registerTool('gog_gmail_search', {
176
289
  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 +302,7 @@ export function registerGmailTools(server: McpServer): void {
189
302
  account: accountParam,
190
303
  }),
191
304
  }, async ({ query, max, pageToken, page, maxPages, all, fromContact, account }) => {
192
- const args = ['gmail', 'search', query];
305
+ const args: GogArg[] = ['gmail', 'search', pos(query)];
193
306
  if (max !== undefined) args.push(`--max=${max}`);
194
307
  if (all) args.push('--all');
195
308
  if (fromContact) args.push(`--from-contact=${fromContact}`);
@@ -226,7 +339,7 @@ export function registerGmailTools(server: McpServer): void {
226
339
  account: accountParam,
227
340
  }),
228
341
  }, async ({ messageId, format, sanitizeContent, account }) => {
229
- const args = ['gmail', 'get', messageId];
342
+ const args: GogArg[] = ['gmail', 'get', pos(messageId)];
230
343
  if (format) args.push(`--format=${format}`);
231
344
  if (sanitizeContent) args.push('--sanitize-content');
232
345
  return runOrDiagnose(args, { account });
@@ -234,7 +347,7 @@ export function registerGmailTools(server: McpServer): void {
234
347
 
235
348
  server.registerTool('gog_gmail_send', {
236
349
  description:
237
- 'SENDS MAIL — asks the MCP host to show a confirmation prompt with the recipients, subject, and body size. '
350
+ '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
351
  + 'Mail is sent only after the user accepts that prompt. '
239
352
  + 'Two ways to attach a file: `attach` takes paths READ ON THE GOG SERVER, and '
240
353
  + '`attachInline` takes the bytes themselves. Use attachInline unless you know the file exists on '
@@ -244,7 +357,8 @@ export function registerGmailTools(server: McpServer): void {
244
357
  + 'and byte sizes — check it to confirm the files were embedded. '
245
358
  + 'NOT the tool for answering a message: replyToMessageId only files this in the right thread — the '
246
359
  + 'subject, recipients and body are entirely yours, and the original is not quoted unless you set '
247
- + 'quote. Use gog_gmail_reply / gog_gmail_reply_all instead, which inherit all three.',
360
+ + 'quote. Use gog_gmail_reply / gog_gmail_reply_all instead, which inherit all three.'
361
+ + CONFIRM_FALLBACK_DESCRIPTION,
248
362
  annotations: { destructiveHint: true },
249
363
  inputSchema: z.object({
250
364
  to: z.string().describe('Recipient(s), comma-separated'),
@@ -255,11 +369,13 @@ export function registerGmailTools(server: McpServer): void {
255
369
  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
370
  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
371
  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.'),
372
+ 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
373
  attachInline: attachInlineParam,
260
374
  account: accountParam,
375
+ confirmToken: confirmTokenParam,
261
376
  }),
262
- }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account }, ctx) => {
377
+ }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account, confirmToken }, ctx) => {
378
+ confinePaths(attach, 'attach');
263
379
  // Built (and validated — inlineAttachmentArgs throws on bad base64 or an
264
380
  // oversize file) BEFORE the confirmation request, on both paths: a prompt that
265
381
  // skipped this would tell a caller "looks fine, send it" about an
@@ -286,12 +402,35 @@ export function registerGmailTools(server: McpServer): void {
286
402
  args.push(...inline);
287
403
 
288
404
  const recipients = extractEmails(to, cc, bcc);
289
- const confirmation = requireGmailDispatchConfirmation(ctx, 'gmail.send', {
405
+ const confirmation = await requireGmailDispatchConfirmation(ctx, 'gmail.send', {
290
406
  to, cc, bcc, recipients, recipientCount: recipients.length, subject,
291
407
  bodyLength: body.length,
408
+ bodyPreview: bodyPreview(body),
292
409
  threaded: Boolean(replyToMessageId || threadId),
293
410
  quoting: Boolean(quote),
294
411
  attachmentCount: (attach?.length ?? 0) + (attachInline?.length ?? 0),
412
+ attachments: attachmentNames(attach, attachInline),
413
+ }, {
414
+ tool: 'gog_gmail_send',
415
+ account,
416
+ confirmToken,
417
+ // Nothing is stored between the phases: the payload IS the arguments,
418
+ // so phase 2 must repeat them and any difference is a changed send.
419
+ subject: () => {
420
+ const attachments = attachmentDetails(attach, attachInline);
421
+ const from = senderPreview(account);
422
+ return {
423
+ target: replyToMessageId ?? threadId ?? '',
424
+ payload: { from, to, cc, bcc, subject, body, attachments, threadId, inReplyTo: replyToMessageId, quote: Boolean(quote) },
425
+ preview: {
426
+ from, to, cc, bcc, subject, body,
427
+ attachments: attachmentPreview(attachments),
428
+ threadId,
429
+ inReplyTo: replyToMessageId,
430
+ quotesOriginal: Boolean(quote),
431
+ },
432
+ };
433
+ },
295
434
  });
296
435
  if (confirmation) return confirmation;
297
436
  const result = await runOrDiagnose(args, { account });
@@ -317,7 +456,7 @@ export function registerGmailTools(server: McpServer): void {
317
456
  // ==========================================================================
318
457
  server.registerTool('gog_gmail_reply', {
319
458
  description:
320
- 'SENDS MAIL — asks the MCP host to show a confirmation prompt with the resolved recipient, subject, and body size. '
459
+ '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
460
  + 'Mail is sent only after the user accepts that prompt. To STAGE a reply instead '
322
461
  + 'of sending it, use gog_gmail_drafts_reply (gogcli-mcp-gmail only), which never needs confirmation. '
323
462
  + 'Reply to a Gmail message (goes to the original sender only). USE THIS, not gog_gmail_send, whenever you are '
@@ -326,29 +465,41 @@ export function registerGmailTools(server: McpServer): void {
326
465
  + 'brand-new message, with the original nowhere in it. To answer every participant use gog_gmail_reply_all. '
327
466
  + 'The gogcli-mcp-gmail package adds two more routes with the same composition: gog_gmail_autoreply to reply '
328
467
  + 'across every message matching a query, and gog_gmail_drafts_reply to stage this exact reply as a draft '
329
- + 'instead of sending it.',
468
+ + 'instead of sending it.'
469
+ + CONFIRM_FALLBACK_DESCRIPTION,
330
470
  annotations: { destructiveHint: true },
331
471
  inputSchema: sendReplySchema,
332
- }, async ({ messageId, account, ...flags }, ctx) => {
472
+ }, async ({ messageId, account, confirmToken, ...flags }, ctx) => {
333
473
  assertNotBoth('bodyHtml', 'bodyHtmlFile', flags.bodyHtml, flags.bodyHtmlFile);
334
- return sendReply('reply', 'gog_gmail_reply', messageId, account, flags, ctx);
474
+ confineReplyPaths(flags);
475
+ return sendReply('reply', 'gog_gmail_reply', messageId, account, flags, ctx, confirmToken);
335
476
  });
336
477
 
337
478
  server.registerTool('gog_gmail_reply_all', {
338
479
  description:
339
- 'SENDS MAIL — asks the MCP host to show a confirmation prompt with the resolved recipients, subject, and body size. '
480
+ '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
481
  + 'Mail is sent only after the user accepts that prompt. To STAGE a '
341
482
  + 'reply-all instead of sending it, use gog_gmail_drafts_reply_all (gogcli-mcp-gmail only), which never needs '
342
483
  + 'confirmation. '
343
484
  + 'Reply to all participants of a Gmail message (the sender plus every To/Cc recipient). Same inherited "Re:" '
344
485
  + 'subject and quoted original as gog_gmail_reply. Use the remove flag to drop specific recipients from the '
345
- + 'reply-all.',
486
+ + 'reply-all.'
487
+ + CONFIRM_FALLBACK_DESCRIPTION,
346
488
  annotations: { destructiveHint: true },
347
489
  inputSchema: sendReplySchema,
348
- }, async ({ messageId, account, ...flags }, ctx) => {
490
+ }, async ({ messageId, account, confirmToken, ...flags }, ctx) => {
349
491
  assertNotBoth('bodyHtml', 'bodyHtmlFile', flags.bodyHtml, flags.bodyHtmlFile);
350
- return sendReply('reply-all', 'gog_gmail_reply_all', messageId, account, flags, ctx);
492
+ confineReplyPaths(flags);
493
+ return sendReply('reply-all', 'gog_gmail_reply_all', messageId, account, flags, ctx, confirmToken);
351
494
  });
352
495
 
353
- registerRunTool(server, { service: 'gmail', examples: '"archive", "mark-read", "labels"' });
496
+ registerRunTool(server, {
497
+ service: 'gmail',
498
+ examples: '"archive", "mark-read", "labels"',
499
+ // gog's --gmail-no-send blocks send/reply/forward/drafts send and all of
500
+ // their aliases at runtime (verified on gog 0.41.0). Sending goes through the
501
+ // confirmed tools, never this escape hatch.
502
+ gmailNoSend: true,
503
+ vet: vetGmailRun,
504
+ });
354
505
  }
@@ -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"' });
@@ -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
- annotations: { readOnlyHint: true },
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
- const args = ['slides', 'export', presentationId];
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: '"add-slide", "delete-slide", "update-notes"' });
97
+ registerRunTool(server, { service: 'slides', examples: '"delete-slide", "update-notes"' });
93
98
  }
@@ -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"' });