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
@@ -3,6 +3,25 @@ import { z } from 'zod';
3
3
  import { viewParam, resolveView } from '@chrischall/mcp-utils';
4
4
  import { accountParam, runOrDiagnose, registerRunTool, pageTokenParam, pageAliasParam, resolvePageToken } from './utils.js';
5
5
  import { annotateTruncatedList } from '../pagination.js';
6
+ import { pos } from '../argv.js';
7
+ import type { GogArg } from '../runner.js';
8
+ import { CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, gatedElsewhere, requireDispatchConfirmation, resultText } from '../dispatch-confirmation.js';
9
+
10
+ // gog's spellings (internal/cmd/calendar.go). The run tool cannot tell whether
11
+ // an event has guests, so it refuses these outright; the dedicated tools ask
12
+ // only when someone else would see the change.
13
+ const CALENDAR_GATED: Record<string, string> = {
14
+ create: 'gog_calendar_create', add: 'gog_calendar_create', new: 'gog_calendar_create',
15
+ update: 'gog_calendar_update', edit: 'gog_calendar_update', set: 'gog_calendar_update',
16
+ respond: 'gog_calendar_respond', rsvp: 'gog_calendar_respond', reply: 'gog_calendar_respond',
17
+ };
18
+
19
+ /** gog_calendar_run must not make the changes gog_calendar_create/update/respond would ask about. */
20
+ export function vetCalendarRun(subcommand: string, _args: readonly string[]): string | undefined {
21
+ const sub = subcommand.toLowerCase();
22
+ const tool = Object.hasOwn(CALENDAR_GATED, sub) ? CALENDAR_GATED[sub] : undefined;
23
+ return tool ? gatedElsewhere(`gog calendar ${sub}`, 'gog_calendar_run', 'can change what guests see', tool) : undefined;
24
+ }
6
25
 
7
26
  // Reminder params, shared by create and update (gog >= 0.38.0 for
8
27
  // --no-reminders). An event's reminders are one of THREE states, and the two
@@ -18,6 +37,72 @@ import { annotateTruncatedList } from '../pagination.js';
18
37
  // the flag (leave whatever the event already has) and from --no-reminders
19
38
  // (override the calendar with silence). An empty array is how a JSON caller
20
39
  // says it, since there is no way to send a bare flag with no value.
40
+ // ============================================================================
41
+ // GUEST-VISIBLE CHANGES go through the dispatch rail. gog sends no invitation
42
+ // email here (its --send-updates defaults to none and these tools never pass
43
+ // it), but a guest still sees the event appear, move or change on their own
44
+ // calendar, and an organizer sees a response. A change nobody else can see — a
45
+ // guest-free event, or a reminder, which is per-user — is not gated.
46
+ // ============================================================================
47
+
48
+ /** The emails in an attendees flag value (`a@x.com;optional,b@y.com;resource`), resources excluded. */
49
+ export function parseAttendees(value: string | undefined): string[] {
50
+ if (!value) return [];
51
+ return value.split(',')
52
+ .map((entry) => entry.split(';'))
53
+ .filter((parts) => !parts.slice(1).some((m) => m.trim().toLowerCase() === 'resource'))
54
+ .map((parts) => parts[0]!.trim())
55
+ .filter(Boolean);
56
+ }
57
+
58
+ type EventAttendee = { email?: unknown; self?: unknown; resource?: unknown };
59
+ type EventTime = { dateTime?: unknown; date?: unknown };
60
+
61
+ function timeOf(t: EventTime | undefined): string | undefined {
62
+ const v = t?.dateTime ?? t?.date;
63
+ return typeof v === 'string' ? v : undefined;
64
+ }
65
+
66
+ /** What a calendar dispatch's prompt shows about the event as it stands now. */
67
+ export function eventSnapshot(raw: string): {
68
+ summary?: string;
69
+ start?: string;
70
+ end?: string;
71
+ organizer?: string;
72
+ guests: string[];
73
+ etag?: string;
74
+ } {
75
+ let event: {
76
+ summary?: unknown; start?: EventTime; end?: EventTime; etag?: unknown;
77
+ organizer?: { email?: unknown }; attendees?: EventAttendee[];
78
+ } | undefined;
79
+ try {
80
+ event = (JSON.parse(raw) as { event?: typeof event } | null)?.event;
81
+ } catch {
82
+ event = undefined;
83
+ }
84
+ const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
85
+ return {
86
+ summary: str(event?.summary),
87
+ start: timeOf(event?.start),
88
+ end: timeOf(event?.end),
89
+ organizer: str(event?.organizer?.email),
90
+ // Guests are the OTHER people: not this account's own attendee row, not rooms.
91
+ guests: (Array.isArray(event?.attendees) ? event.attendees : [])
92
+ .filter((a) => a.self !== true && a.resource !== true && typeof a.email === 'string')
93
+ .map((a) => a.email as string),
94
+ etag: str(event?.etag),
95
+ };
96
+ }
97
+
98
+ async function readEvent(calendarId: string, eventId: string, account: string | undefined) {
99
+ const got = await runOrDiagnose(['calendar', 'event', pos(calendarId), pos(eventId)], { account });
100
+ if (got.isError) return { error: got };
101
+ return { event: eventSnapshot(resultText(got)) };
102
+ }
103
+
104
+ const CALENDAR_CONFIRM_LABEL = 'Confirm that this change, which other people will see, should be made now.';
105
+
21
106
  const reminderParams = {
22
107
  reminders: z.array(z.string()).max(5).optional().describe(
23
108
  'Reminders as method:duration, e.g. ["popup:30m", "email:1d"]. Method is popup or email; duration accepts m/h/d '
@@ -33,7 +118,7 @@ const reminderParams = {
33
118
  // The one place the three states become argv. Kept together so create and
34
119
  // update cannot drift apart on the empty-array case.
35
120
  function pushReminderFlags(
36
- args: string[],
121
+ args: GogArg[],
37
122
  p: { reminders?: string[]; noReminders?: boolean },
38
123
  ): void {
39
124
  if (p.noReminders) {
@@ -107,8 +192,8 @@ export function registerCalendarTools(server: McpServer): void {
107
192
  account: accountParam,
108
193
  }),
109
194
  }, async ({ calendarId, from, to, days, today, query, max, pageToken, page, all, eventTypes, timezone, view, account }) => {
110
- const args = ['calendar', 'events'];
111
- if (calendarId) args.push(calendarId);
195
+ const args: GogArg[] = ['calendar', 'events'];
196
+ if (calendarId) args.push(pos(calendarId));
112
197
  if (from) args.push(`--from=${from}`);
113
198
  if (to) args.push(`--to=${to}`);
114
199
  if (days !== undefined) args.push(`--days=${days}`);
@@ -141,13 +226,16 @@ export function registerCalendarTools(server: McpServer): void {
141
226
  account: accountParam,
142
227
  }),
143
228
  }, async ({ calendarId, eventId, timezone, account }) => {
144
- const args = ['calendar', 'event', calendarId, eventId];
229
+ const args: GogArg[] = ['calendar', 'event', pos(calendarId), pos(eventId)];
145
230
  if (timezone) args.push(`--timezone=${timezone}`);
146
231
  return runOrDiagnose(args, { account });
147
232
  });
148
233
 
149
234
  server.registerTool('gog_calendar_create', {
150
- description: 'Create a calendar event. Set withZoom=true to attach a Zoom meeting (requires Zoom S2S OAuth setup via gog_zoom_auth_setup; the join URL + meeting ID + passcode are appended to the event description — Google rejects native conference card writes from non-Workspace-Marketplace OAuth clients).',
235
+ description: 'Create a calendar event. Set withZoom=true to attach a Zoom meeting (requires Zoom S2S OAuth setup via gog_zoom_auth_setup; the join URL + meeting ID + passcode are appended to the event description — Google rejects native conference card writes from non-Workspace-Marketplace OAuth clients). '
236
+ + 'With attendees, the event lands on other people\'s calendars (no invitation email is sent), so the MCP host is '
237
+ + 'asked to show the user a confirmation prompt with the guests, time and details first; a guest-free event is '
238
+ + 'created without asking.' + CONFIRM_FALLBACK_DESCRIPTION,
151
239
  annotations: { destructiveHint: false },
152
240
  inputSchema: z.object({
153
241
  calendarId: z.string().describe('Calendar ID (use "primary" for the default calendar)'),
@@ -162,9 +250,10 @@ export function registerCalendarTools(server: McpServer): void {
162
250
  withZoom: z.boolean().optional().describe('Create a Zoom video conference for this event (requires Zoom S2S OAuth setup)'),
163
251
  ...reminderParams,
164
252
  account: accountParam,
253
+ confirmToken: confirmTokenParam,
165
254
  }),
166
- }, async ({ calendarId, summary, from, to, description, location, attendees, allDay, timezone, withZoom, reminders, noReminders, account }) => {
167
- const args = ['calendar', 'create', calendarId, `--summary=${summary}`, `--from=${from}`, `--to=${to}`];
255
+ }, async ({ calendarId, summary, from, to, description, location, attendees, allDay, timezone, withZoom, reminders, noReminders, account, confirmToken }, ctx) => {
256
+ const args: GogArg[] = ['calendar', 'create', pos(calendarId), `--summary=${summary}`, `--from=${from}`, `--to=${to}`];
168
257
  if (description) args.push(`--description=${description}`);
169
258
  if (location) args.push(`--location=${location}`);
170
259
  if (attendees) args.push(`--attendees=${attendees}`);
@@ -172,11 +261,33 @@ export function registerCalendarTools(server: McpServer): void {
172
261
  if (timezone) args.push(`--timezone=${timezone}`);
173
262
  if (withZoom) args.push('--with-zoom');
174
263
  pushReminderFlags(args, { reminders, noReminders });
264
+ const guests = parseAttendees(attendees);
265
+ if (guests.length > 0) {
266
+ const event = { calendarId, summary, from, to, allDay: Boolean(allDay), timezone, location, description, guests, attendees, withZoom: Boolean(withZoom) };
267
+ const confirmation = await requireDispatchConfirmation(ctx, {
268
+ action: 'calendar.create',
269
+ message: 'Review and confirm this event, which will appear on the guests\' calendars:',
270
+ confirmationLabel: CALENDAR_CONFIRM_LABEL,
271
+ details: event,
272
+ unsupportedNote: 'Create it without attendees instead; the user can add the guests in Google Calendar.',
273
+ fallback: {
274
+ tool: 'gog_calendar_create',
275
+ account,
276
+ confirmToken,
277
+ subject: () => ({ target: calendarId, payload: event, preview: event }),
278
+ },
279
+ });
280
+ if (confirmation) return confirmation;
281
+ }
175
282
  return runOrDiagnose(args, { account });
176
283
  });
177
284
 
178
285
  server.registerTool('gog_calendar_update', {
179
- description: 'Update an existing calendar event. Zoom: withZoom adds a Zoom meeting, regenerateZoom replaces the existing one, removeZoom strips it. removeMeet clears the event\'s Google Meet conference data (e.g. before attaching another provider). Conference flags are independent — use one per call.',
286
+ description: 'Update an existing calendar event. Zoom: withZoom adds a Zoom meeting, regenerateZoom replaces the existing one, removeZoom strips it. removeMeet clears the event\'s Google Meet conference data (e.g. before attaching another provider). Conference flags are independent — use one per call. '
287
+ + 'A change guests can see (time, title, place, description, attendees, attachments, conferencing) on an event '
288
+ + 'that has guests — or gains them — reads the event and asks the MCP host to show the user a confirmation prompt '
289
+ + 'with the event as it stands and the change; no invitation email is sent. Reminder-only changes and guest-free '
290
+ + 'events are updated without asking.' + CONFIRM_FALLBACK_DESCRIPTION,
180
291
  annotations: { destructiveHint: false },
181
292
  inputSchema: z.object({
182
293
  calendarId: z.string().describe('Calendar ID'),
@@ -195,9 +306,10 @@ export function registerCalendarTools(server: McpServer): void {
195
306
  removeMeet: z.boolean().optional().describe('Remove the event\'s Google Meet video conference (clears conference data only)'),
196
307
  ...reminderParams,
197
308
  account: accountParam,
309
+ confirmToken: confirmTokenParam,
198
310
  }),
199
- }, async ({ calendarId, eventId, summary, from, to, description, location, attendees, addAttendees, attachments, withZoom, regenerateZoom, removeZoom, removeMeet, reminders, noReminders, account }) => {
200
- const args = ['calendar', 'update', calendarId, eventId];
311
+ }, async ({ calendarId, eventId, summary, from, to, description, location, attendees, addAttendees, attachments, withZoom, regenerateZoom, removeZoom, removeMeet, reminders, noReminders, account, confirmToken }, ctx) => {
312
+ const args: GogArg[] = ['calendar', 'update', pos(calendarId), pos(eventId)];
201
313
  if (summary !== undefined) args.push(`--summary=${summary}`);
202
314
  if (from !== undefined) args.push(`--from=${from}`);
203
315
  if (to !== undefined) args.push(`--to=${to}`);
@@ -211,6 +323,36 @@ export function registerCalendarTools(server: McpServer): void {
211
323
  if (removeZoom) args.push('--remove-zoom');
212
324
  if (removeMeet) args.push('--remove-meet');
213
325
  pushReminderFlags(args, { reminders, noReminders });
326
+ // Every field guests can see; reminders are per-user and are not on it.
327
+ const changes = Object.fromEntries(Object.entries({
328
+ summary, from, to, description, location, attendees, addAttendees, attachments,
329
+ withZoom, regenerateZoom, removeZoom, removeMeet,
330
+ }).filter(([, v]) => v !== undefined && v !== false));
331
+ if (Object.keys(changes).length > 0) {
332
+ const read = await readEvent(calendarId, eventId, account);
333
+ if (read.error) return read.error;
334
+ const { etag, ...current } = read.event;
335
+ const added = [...parseAttendees(attendees), ...parseAttendees(addAttendees)];
336
+ if (current.guests.length > 0 || added.length > 0) {
337
+ const view = { calendarId, eventId, current, changes };
338
+ const confirmation = await requireDispatchConfirmation(ctx, {
339
+ action: 'calendar.update',
340
+ message: 'Review and confirm this change to an event other people are on:',
341
+ confirmationLabel: CALENDAR_CONFIRM_LABEL,
342
+ details: view,
343
+ unsupportedNote: 'Ask the user to make this change in Google Calendar.',
344
+ fallback: {
345
+ tool: 'gog_calendar_update',
346
+ account,
347
+ confirmToken,
348
+ // The etag rotates on any edit, so an event changed elsewhere between
349
+ // the two phases is DRAFT_CHANGED rather than edited over.
350
+ subject: () => ({ target: `${calendarId}/${eventId}`, revision: etag, payload: view, preview: view }),
351
+ },
352
+ });
353
+ if (confirmation) return confirmation;
354
+ }
355
+ }
214
356
  return runOrDiagnose(args, { account });
215
357
  });
216
358
 
@@ -225,11 +367,13 @@ export function registerCalendarTools(server: McpServer): void {
225
367
  }, async ({ calendarId, eventId, account }) => {
226
368
  // gog gates this delete behind a confirmation; the runner injects
227
369
  // --no-input, so without --force it refuses at runtime.
228
- return runOrDiagnose(['calendar', 'delete', calendarId, eventId, '--force'], { account });
370
+ return runOrDiagnose(['calendar', 'delete', pos(calendarId), pos(eventId), '--force'], { account });
229
371
  });
230
372
 
231
373
  server.registerTool('gog_calendar_respond', {
232
- description: 'Respond to a calendar event invitation.',
374
+ description: 'Respond to a calendar event invitation. The organizer sees the response, so this reads the event and '
375
+ + 'asks the MCP host to show the user a confirmation prompt with the event, organizer, response and comment; '
376
+ + 'nothing is recorded unless they accept.' + CONFIRM_FALLBACK_DESCRIPTION,
233
377
  annotations: { destructiveHint: true },
234
378
  inputSchema: z.object({
235
379
  calendarId: z.string().describe('Calendar ID'),
@@ -237,12 +381,31 @@ export function registerCalendarTools(server: McpServer): void {
237
381
  status: z.enum(['accepted', 'declined', 'tentative']).describe('Response status'),
238
382
  comment: z.string().optional().describe('Optional comment to include with response'),
239
383
  account: accountParam,
384
+ confirmToken: confirmTokenParam,
240
385
  }),
241
- }, async ({ calendarId, eventId, status, comment, account }) => {
242
- const args = ['calendar', 'respond', calendarId, eventId, `--status=${status}`];
386
+ }, async ({ calendarId, eventId, status, comment, account, confirmToken }, ctx) => {
387
+ const args: GogArg[] = ['calendar', 'respond', pos(calendarId), pos(eventId), `--status=${status}`];
243
388
  if (comment) args.push(`--comment=${comment}`);
389
+ const read = await readEvent(calendarId, eventId, account);
390
+ if (read.error) return read.error;
391
+ const { etag, ...event } = read.event;
392
+ const view = { calendarId, eventId, event, response: status, ...(comment ? { comment } : {}) };
393
+ const confirmation = await requireDispatchConfirmation(ctx, {
394
+ action: 'calendar.respond',
395
+ message: 'Review and confirm this response, which the organizer will see:',
396
+ confirmationLabel: 'Confirm that this response should be recorded now.',
397
+ details: view,
398
+ unsupportedNote: 'Ask the user to respond from Google Calendar.',
399
+ fallback: {
400
+ tool: 'gog_calendar_respond',
401
+ account,
402
+ confirmToken,
403
+ subject: () => ({ target: `${calendarId}/${eventId}`, revision: etag, payload: view, preview: view }),
404
+ },
405
+ });
406
+ if (confirmation) return confirmation;
244
407
  return runOrDiagnose(args, { account });
245
408
  });
246
409
 
247
- registerRunTool(server, { service: 'calendar', examples: '"calendars", "freebusy"' });
410
+ registerRunTool(server, { service: 'calendar', examples: '"calendars", "freebusy"', vet: vetCalendarRun });
248
411
  }
package/src/tools/chat.ts CHANGED
@@ -9,6 +9,40 @@ import {
9
9
  } from './utils.js';
10
10
  import type { GogArg } from '../runner.js';
11
11
  import { attachInlineParam, inlineAttachmentArgs } from '../attachments.js';
12
+ import { pos } from '../argv.js';
13
+ import { confinePaths } from '../file-roots.js';
14
+ import {
15
+ attachmentDetails,
16
+ attachmentNames,
17
+ attachmentPreview,
18
+ bodyPreview,
19
+ CONFIRM_FALLBACK_DESCRIPTION,
20
+ confirmTokenParam,
21
+ gatedElsewhere,
22
+ hasCommandWord,
23
+ requireDispatchConfirmation,
24
+ senderPreview,
25
+ } from '../dispatch-confirmation.js';
26
+
27
+ // gog's spellings of `send` under `messages` and `dm` (internal/cmd/chat_messages.go, chat_dm.go).
28
+ const CHAT_SEND_WORDS = new Set(['send', 'create', 'post']);
29
+
30
+ /** gog_chat_run must not post what gog_chat_messages_send / gog_chat_dm_send would ask about. */
31
+ export function vetChatRun(subcommand: string, args: readonly string[]): string | undefined {
32
+ const sub = subcommand.toLowerCase();
33
+ if (sub !== 'messages' && sub !== 'dm') return undefined;
34
+ const word = hasCommandWord(args, CHAT_SEND_WORDS);
35
+ if (!word) return undefined;
36
+ return gatedElsewhere(`gog chat ${sub} ${word.toLowerCase()}`, 'gog_chat_run', 'posts a message',
37
+ sub === 'dm' ? 'gog_chat_dm_send' : 'gog_chat_messages_send');
38
+ }
39
+
40
+ // A Chat post lands in other people's view the moment it is sent and cannot be
41
+ // unsent here, so both send tools go through the dispatch rail like Gmail does.
42
+ const CHAT_CONFIRM = {
43
+ message: 'Review and confirm this Google Chat message:',
44
+ confirmationLabel: 'Confirm that this Chat message should be posted now.',
45
+ } as const;
12
46
 
13
47
  // Google Chat (gog >= 0.38.0 for the mention/reaction metadata in
14
48
  // `messages list`, >= 0.39.0 for `messages search`; the rest is older).
@@ -54,7 +88,7 @@ export function registerChatTools(server: McpServer): void {
54
88
  account: accountParam,
55
89
  }),
56
90
  }, async ({ max, pageToken, page, all, account }) => {
57
- const args = ['chat', 'spaces', 'list'];
91
+ const args: GogArg[] = ['chat', 'spaces', 'list'];
58
92
  pushPaginationFlags(args, { max, pageToken, page, all });
59
93
  return runOrDiagnose(args, { account });
60
94
  });
@@ -72,7 +106,7 @@ export function registerChatTools(server: McpServer): void {
72
106
  account: accountParam,
73
107
  }),
74
108
  }, async ({ displayName, exact, max, account }) => {
75
- const args = ['chat', 'spaces', 'find', displayName];
109
+ const args: GogArg[] = ['chat', 'spaces', 'find', pos(displayName)];
76
110
  if (exact) args.push('--exact');
77
111
  if (max !== undefined) args.push(`--max=${max}`);
78
112
  return runOrDiagnose(args, { account });
@@ -89,7 +123,7 @@ export function registerChatTools(server: McpServer): void {
89
123
  account: accountParam,
90
124
  }),
91
125
  }, async ({ displayName, members, account }) => {
92
- const args = ['chat', 'spaces', 'create', displayName];
126
+ const args: GogArg[] = ['chat', 'spaces', 'create', pos(displayName)];
93
127
  if (members) for (const member of members) args.push(`--member=${member}`);
94
128
  return runOrDiagnose(args, { account });
95
129
  });
@@ -105,7 +139,7 @@ export function registerChatTools(server: McpServer): void {
105
139
  account: accountParam,
106
140
  }),
107
141
  }, async ({ space, max, pageToken, page, all, account }) => {
108
- const args = ['chat', 'threads', 'list', space];
142
+ const args: GogArg[] = ['chat', 'threads', 'list', pos(space)];
109
143
  pushPaginationFlags(args, { max, pageToken, page, all });
110
144
  return runOrDiagnose(args, { account });
111
145
  });
@@ -128,7 +162,7 @@ export function registerChatTools(server: McpServer): void {
128
162
  account: accountParam,
129
163
  }),
130
164
  }, async ({ space, thread, unread, order, max, pageToken, page, all, account }) => {
131
- const args = ['chat', 'messages', 'list', space];
165
+ const args: GogArg[] = ['chat', 'messages', 'list', pos(space)];
132
166
  if (thread) args.push(`--thread=${thread}`);
133
167
  if (unread) args.push('--unread');
134
168
  if (order) args.push(`--order=${order}`);
@@ -163,7 +197,7 @@ export function registerChatTools(server: McpServer): void {
163
197
  account: accountParam,
164
198
  }),
165
199
  }, async ({ query, order, view, markup, max, pageToken, page, all, account }) => {
166
- const args = ['chat', 'messages', 'search', query];
200
+ const args: GogArg[] = ['chat', 'messages', 'search', pos(query)];
167
201
  if (order) args.push(`--order=${order}`);
168
202
  if (view) args.push(`--view=${view}`);
169
203
  if (markup) args.push(`--markup=${markup}`);
@@ -176,7 +210,9 @@ export function registerChatTools(server: McpServer): void {
176
210
  'Post a message to a Chat space. THIS IS IMMEDIATELY VISIBLE TO EVERYONE IN THE SPACE and cannot be unsent through '
177
211
  + 'this tool, so treat it like sending mail, not like saving a draft. Pass `thread` to reply inside an existing '
178
212
  + 'conversation (from gog_chat_threads_list or a message\'s thread field); omit it to start a new one. Text supports '
179
- + 'Chat\'s markdown-ish formatting (*bold*, _italic_, `code`).' + workspaceOnlyNote,
213
+ + 'Chat\'s markdown-ish formatting (*bold*, _italic_, `code`). Asks the MCP host to show the user a confirmation '
214
+ + 'prompt with the space, thread, text and attachments first; nothing is posted unless they accept.'
215
+ + CONFIRM_FALLBACK_DESCRIPTION + workspaceOnlyNote,
180
216
  annotations: { destructiveHint: true },
181
217
  inputSchema: z.object({
182
218
  space: spaceParam,
@@ -184,16 +220,18 @@ export function registerChatTools(server: McpServer): void {
184
220
  thread: threadParam,
185
221
  attach: z.array(z.string()).optional().describe(
186
222
  'Attachment file paths, read WHERE GOG RUNS. On a hosted or remote deployment that is not your machine — use '
187
- + 'attachInline there instead.',
223
+ + 'attachInline there instead. Must be inside the server\'s GOG_FILE_ROOTS directories (default ~/gogcli-mcp-files).',
188
224
  ),
189
225
  attachInline: attachInlineParam,
190
226
  account: accountParam,
227
+ confirmToken: confirmTokenParam,
191
228
  }),
192
- }, async ({ space, text, thread, attach, attachInline, account }) => {
229
+ }, async ({ space, text, thread, attach, attachInline, account, confirmToken }, ctx) => {
230
+ confinePaths(attach, 'attach');
193
231
  if (text === undefined && !attach?.length && !attachInline?.length) {
194
232
  throw new Error('A Chat message needs text, an attachment, or both.');
195
233
  }
196
- const args: GogArg[] = ['chat', 'messages', 'send', space];
234
+ const args: GogArg[] = ['chat', 'messages', 'send', pos(space)];
197
235
  if (text !== undefined) args.push(`--text=${text}`);
198
236
  if (thread) args.push(`--thread=${thread}`);
199
237
  if (attach) for (const path of attach) args.push(`--attach=${path}`);
@@ -201,24 +239,61 @@ export function registerChatTools(server: McpServer): void {
201
239
  // a temp file beside gog. `args` is passed so the size check sees the whole
202
240
  // request, not just the attachments.
203
241
  args.push(...inlineAttachmentArgs('attach', attachInline, args));
242
+ const confirmation = await requireDispatchConfirmation(ctx, {
243
+ ...CHAT_CONFIRM,
244
+ action: 'chat.message-send',
245
+ details: { space, thread, textPreview: bodyPreview(text), attachments: attachmentNames(attach, attachInline) },
246
+ fallback: {
247
+ tool: 'gog_chat_messages_send',
248
+ account,
249
+ confirmToken,
250
+ subject: () => {
251
+ const attachments = attachmentDetails(attach, attachInline);
252
+ const from = senderPreview(account);
253
+ return {
254
+ target: space,
255
+ payload: { from, space, thread, text, attachments },
256
+ preview: { from, space, thread, text, attachments: attachmentPreview(attachments) },
257
+ };
258
+ },
259
+ },
260
+ });
261
+ if (confirmation) return confirmation;
204
262
  return runOrDiagnose(args, { account });
205
263
  });
206
264
 
207
265
  server.registerTool('gog_chat_dm_send', {
208
266
  description:
209
267
  'Send a direct message to one person by email address, creating the DM space if this is the first message. Delivered '
210
- + 'immediately and cannot be unsent through this tool. For a room rather than a person, use gog_chat_messages_send.'
211
- + workspaceOnlyNote,
268
+ + 'immediately and cannot be unsent through this tool. For a room rather than a person, use gog_chat_messages_send. '
269
+ + 'Asks the MCP host to show the user a confirmation prompt with the recipient and text first; nothing is sent '
270
+ + 'unless they accept.' + CONFIRM_FALLBACK_DESCRIPTION + workspaceOnlyNote,
212
271
  annotations: { destructiveHint: true },
213
272
  inputSchema: z.object({
214
273
  email: z.string().describe('Recipient email address'),
215
274
  text: z.string().describe('Message text'),
216
275
  thread: threadParam,
217
276
  account: accountParam,
277
+ confirmToken: confirmTokenParam,
218
278
  }),
219
- }, async ({ email, text, thread, account }) => {
220
- const args = ['chat', 'dm', 'send', email, `--text=${text}`];
279
+ }, async ({ email, text, thread, account, confirmToken }, ctx) => {
280
+ const args: GogArg[] = ['chat', 'dm', 'send', pos(email), `--text=${text}`];
221
281
  if (thread) args.push(`--thread=${thread}`);
282
+ const confirmation = await requireDispatchConfirmation(ctx, {
283
+ ...CHAT_CONFIRM,
284
+ action: 'chat.dm-send',
285
+ details: { to: email, thread, textPreview: bodyPreview(text) },
286
+ fallback: {
287
+ tool: 'gog_chat_dm_send',
288
+ account,
289
+ confirmToken,
290
+ subject: () => {
291
+ const from = senderPreview(account);
292
+ return { target: email, payload: { from, to: email, thread, text }, preview: { from, to: email, thread, text } };
293
+ },
294
+ },
295
+ });
296
+ if (confirmation) return confirmation;
222
297
  return runOrDiagnose(args, { account });
223
298
  });
224
299
 
@@ -232,7 +307,7 @@ export function registerChatTools(server: McpServer): void {
232
307
  account: accountParam,
233
308
  }),
234
309
  }, async ({ email, account }) => {
235
- return runOrDiagnose(['chat', 'dm', 'space', email], { account });
310
+ return runOrDiagnose(['chat', 'dm', 'space', pos(email)], { account });
236
311
  });
237
312
 
238
313
  server.registerTool('gog_chat_reactions_list', {
@@ -248,7 +323,7 @@ export function registerChatTools(server: McpServer): void {
248
323
  account: accountParam,
249
324
  }),
250
325
  }, async ({ message, space, max, pageToken, page, all, account }) => {
251
- const args = ['chat', 'messages', 'reactions', 'list', message];
326
+ const args: GogArg[] = ['chat', 'messages', 'reactions', 'list', pos(message)];
252
327
  if (space) args.push(`--space=${space}`);
253
328
  pushPaginationFlags(args, { max, pageToken, page, all });
254
329
  return runOrDiagnose(args, { account });
@@ -266,7 +341,7 @@ export function registerChatTools(server: McpServer): void {
266
341
  account: accountParam,
267
342
  }),
268
343
  }, async ({ message, emoji, space, account }) => {
269
- const args = ['chat', 'messages', 'reactions', 'create', message, emoji];
344
+ const args: GogArg[] = ['chat', 'messages', 'reactions', 'create', pos(message), pos(emoji)];
270
345
  if (space) args.push(`--space=${space}`);
271
346
  return runOrDiagnose(args, { account });
272
347
  });
@@ -282,12 +357,13 @@ export function registerChatTools(server: McpServer): void {
282
357
  account: accountParam,
283
358
  }),
284
359
  }, async ({ reaction, account }) => {
285
- return runOrDiagnose(['chat', 'messages', 'reactions', 'delete', reaction], { account });
360
+ return runOrDiagnose(['chat', 'messages', 'reactions', 'delete', pos(reaction)], { account });
286
361
  });
287
362
 
288
363
  registerRunTool(server, {
289
364
  service: 'chat',
290
365
  examples: '"spaces", "messages", "dm"',
366
+ vet: vetChatRun,
291
367
  note: 'Google Chat has no API for consumer accounts: every chat subcommand fails on an @gmail.com account regardless of scopes.',
292
368
  });
293
369
  }