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.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +714 -216
  4. package/dist/lib.js +751 -217
  5. package/manifest.json +9 -2
  6. package/mint.yaml +1 -1
  7. package/package.json +2 -2
  8. package/server.json +2 -2
  9. package/src/arg-guard.ts +68 -0
  10. package/src/argv.ts +27 -0
  11. package/src/attachment-root.ts +111 -0
  12. package/src/attachments.ts +1 -0
  13. package/src/blob-upload.ts +4 -2
  14. package/src/file-roots.ts +66 -0
  15. package/src/gmail-dispatch-guard.ts +56 -16
  16. package/src/gmail-results.ts +21 -2
  17. package/src/lib.ts +6 -0
  18. package/src/run-path-guard.ts +118 -0
  19. package/src/runner.ts +87 -19
  20. package/src/tools/api.ts +39 -7
  21. package/src/tools/appscript.ts +12 -8
  22. package/src/tools/auth.ts +13 -3
  23. package/src/tools/calendar.ts +10 -8
  24. package/src/tools/chat.ts +16 -13
  25. package/src/tools/classroom.ts +27 -25
  26. package/src/tools/contacts.ts +5 -3
  27. package/src/tools/docs.ts +8 -6
  28. package/src/tools/drive.ts +42 -18
  29. package/src/tools/gmail.ts +71 -11
  30. package/src/tools/sheets.ts +10 -8
  31. package/src/tools/slides.ts +14 -9
  32. package/src/tools/tasks.ts +7 -5
  33. package/src/tools/utils.ts +52 -6
  34. package/tests/arg-guard.test.ts +80 -0
  35. package/tests/attachment-root.test.ts +130 -0
  36. package/tests/attachments.test.ts +8 -0
  37. package/tests/blob-upload.test.ts +4 -3
  38. package/tests/file-roots.test.ts +101 -0
  39. package/tests/gmail-dispatch-guard.test.ts +46 -0
  40. package/tests/gmail-results.test.ts +35 -1
  41. package/tests/run-path-guard.test.ts +142 -0
  42. package/tests/runner.test.ts +136 -0
  43. package/tests/tools/api.test.ts +74 -8
  44. package/tests/tools/appscript.test.ts +34 -8
  45. package/tests/tools/auth.test.ts +44 -17
  46. package/tests/tools/calendar.test.ts +29 -27
  47. package/tests/tools/chat.test.ts +46 -20
  48. package/tests/tools/classroom.test.ts +39 -38
  49. package/tests/tools/contacts.test.ts +3 -2
  50. package/tests/tools/docs.test.ts +44 -15
  51. package/tests/tools/drive.test.ts +106 -19
  52. package/tests/tools/gmail.test.ts +227 -29
  53. package/tests/tools/run-tool-examples.test.ts +69 -0
  54. package/tests/tools/sheets.test.ts +16 -15
  55. package/tests/tools/slides.test.ts +47 -11
  56. package/tests/tools/tasks.test.ts +7 -6
  57. package/tests/tools/utils.test.ts +32 -31
  58. package/vitest.config.ts +5 -0
@@ -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, and body size. '
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, and body size. '
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, and body size. '
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, { service: 'gmail', examples: '"archive", "mark-read", "labels"' });
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
  }
@@ -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"' });
@@ -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: string[],
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 description = note ? `${baseDescription} ${note}` : baseDescription;
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
- return runOrDiagnose([service, subcommand, ...args], { account });
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
+ });