gogcli-mcp 4.3.0 → 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.
@@ -5,6 +5,23 @@ import { accountParam, runOrDiagnose, registerRunTool, pageTokenParam, pageAlias
5
5
  import { annotateTruncatedList } from '../pagination.js';
6
6
  import { pos } from '../argv.js';
7
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
+ }
8
25
 
9
26
  // Reminder params, shared by create and update (gog >= 0.38.0 for
10
27
  // --no-reminders). An event's reminders are one of THREE states, and the two
@@ -20,6 +37,72 @@ import type { GogArg } from '../runner.js';
20
37
  // the flag (leave whatever the event already has) and from --no-reminders
21
38
  // (override the calendar with silence). An empty array is how a JSON caller
22
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
+
23
106
  const reminderParams = {
24
107
  reminders: z.array(z.string()).max(5).optional().describe(
25
108
  'Reminders as method:duration, e.g. ["popup:30m", "email:1d"]. Method is popup or email; duration accepts m/h/d '
@@ -149,7 +232,10 @@ export function registerCalendarTools(server: McpServer): void {
149
232
  });
150
233
 
151
234
  server.registerTool('gog_calendar_create', {
152
- 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,
153
239
  annotations: { destructiveHint: false },
154
240
  inputSchema: z.object({
155
241
  calendarId: z.string().describe('Calendar ID (use "primary" for the default calendar)'),
@@ -164,8 +250,9 @@ export function registerCalendarTools(server: McpServer): void {
164
250
  withZoom: z.boolean().optional().describe('Create a Zoom video conference for this event (requires Zoom S2S OAuth setup)'),
165
251
  ...reminderParams,
166
252
  account: accountParam,
253
+ confirmToken: confirmTokenParam,
167
254
  }),
168
- }, async ({ calendarId, summary, from, to, description, location, attendees, allDay, timezone, withZoom, reminders, noReminders, account }) => {
255
+ }, async ({ calendarId, summary, from, to, description, location, attendees, allDay, timezone, withZoom, reminders, noReminders, account, confirmToken }, ctx) => {
169
256
  const args: GogArg[] = ['calendar', 'create', pos(calendarId), `--summary=${summary}`, `--from=${from}`, `--to=${to}`];
170
257
  if (description) args.push(`--description=${description}`);
171
258
  if (location) args.push(`--location=${location}`);
@@ -174,11 +261,33 @@ export function registerCalendarTools(server: McpServer): void {
174
261
  if (timezone) args.push(`--timezone=${timezone}`);
175
262
  if (withZoom) args.push('--with-zoom');
176
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
+ }
177
282
  return runOrDiagnose(args, { account });
178
283
  });
179
284
 
180
285
  server.registerTool('gog_calendar_update', {
181
- 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,
182
291
  annotations: { destructiveHint: false },
183
292
  inputSchema: z.object({
184
293
  calendarId: z.string().describe('Calendar ID'),
@@ -197,8 +306,9 @@ export function registerCalendarTools(server: McpServer): void {
197
306
  removeMeet: z.boolean().optional().describe('Remove the event\'s Google Meet video conference (clears conference data only)'),
198
307
  ...reminderParams,
199
308
  account: accountParam,
309
+ confirmToken: confirmTokenParam,
200
310
  }),
201
- }, async ({ calendarId, eventId, summary, from, to, description, location, attendees, addAttendees, attachments, withZoom, regenerateZoom, removeZoom, removeMeet, reminders, noReminders, account }) => {
311
+ }, async ({ calendarId, eventId, summary, from, to, description, location, attendees, addAttendees, attachments, withZoom, regenerateZoom, removeZoom, removeMeet, reminders, noReminders, account, confirmToken }, ctx) => {
202
312
  const args: GogArg[] = ['calendar', 'update', pos(calendarId), pos(eventId)];
203
313
  if (summary !== undefined) args.push(`--summary=${summary}`);
204
314
  if (from !== undefined) args.push(`--from=${from}`);
@@ -213,6 +323,36 @@ export function registerCalendarTools(server: McpServer): void {
213
323
  if (removeZoom) args.push('--remove-zoom');
214
324
  if (removeMeet) args.push('--remove-meet');
215
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
+ }
216
356
  return runOrDiagnose(args, { account });
217
357
  });
218
358
 
@@ -231,7 +371,9 @@ export function registerCalendarTools(server: McpServer): void {
231
371
  });
232
372
 
233
373
  server.registerTool('gog_calendar_respond', {
234
- 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,
235
377
  annotations: { destructiveHint: true },
236
378
  inputSchema: z.object({
237
379
  calendarId: z.string().describe('Calendar ID'),
@@ -239,12 +381,31 @@ export function registerCalendarTools(server: McpServer): void {
239
381
  status: z.enum(['accepted', 'declined', 'tentative']).describe('Response status'),
240
382
  comment: z.string().optional().describe('Optional comment to include with response'),
241
383
  account: accountParam,
384
+ confirmToken: confirmTokenParam,
242
385
  }),
243
- }, async ({ calendarId, eventId, status, comment, account }) => {
386
+ }, async ({ calendarId, eventId, status, comment, account, confirmToken }, ctx) => {
244
387
  const args: GogArg[] = ['calendar', 'respond', pos(calendarId), pos(eventId), `--status=${status}`];
245
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;
246
407
  return runOrDiagnose(args, { account });
247
408
  });
248
409
 
249
- registerRunTool(server, { service: 'calendar', examples: '"calendars", "freebusy"' });
410
+ registerRunTool(server, { service: 'calendar', examples: '"calendars", "freebusy"', vet: vetCalendarRun });
250
411
  }
package/src/tools/chat.ts CHANGED
@@ -11,6 +11,38 @@ import type { GogArg } from '../runner.js';
11
11
  import { attachInlineParam, inlineAttachmentArgs } from '../attachments.js';
12
12
  import { pos } from '../argv.js';
13
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;
14
46
 
15
47
  // Google Chat (gog >= 0.38.0 for the mention/reaction metadata in
16
48
  // `messages list`, >= 0.39.0 for `messages search`; the rest is older).
@@ -178,7 +210,9 @@ export function registerChatTools(server: McpServer): void {
178
210
  'Post a message to a Chat space. THIS IS IMMEDIATELY VISIBLE TO EVERYONE IN THE SPACE and cannot be unsent through '
179
211
  + 'this tool, so treat it like sending mail, not like saving a draft. Pass `thread` to reply inside an existing '
180
212
  + 'conversation (from gog_chat_threads_list or a message\'s thread field); omit it to start a new one. Text supports '
181
- + '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,
182
216
  annotations: { destructiveHint: true },
183
217
  inputSchema: z.object({
184
218
  space: spaceParam,
@@ -190,8 +224,9 @@ export function registerChatTools(server: McpServer): void {
190
224
  ),
191
225
  attachInline: attachInlineParam,
192
226
  account: accountParam,
227
+ confirmToken: confirmTokenParam,
193
228
  }),
194
- }, async ({ space, text, thread, attach, attachInline, account }) => {
229
+ }, async ({ space, text, thread, attach, attachInline, account, confirmToken }, ctx) => {
195
230
  confinePaths(attach, 'attach');
196
231
  if (text === undefined && !attach?.length && !attachInline?.length) {
197
232
  throw new Error('A Chat message needs text, an attachment, or both.');
@@ -204,24 +239,61 @@ export function registerChatTools(server: McpServer): void {
204
239
  // a temp file beside gog. `args` is passed so the size check sees the whole
205
240
  // request, not just the attachments.
206
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;
207
262
  return runOrDiagnose(args, { account });
208
263
  });
209
264
 
210
265
  server.registerTool('gog_chat_dm_send', {
211
266
  description:
212
267
  'Send a direct message to one person by email address, creating the DM space if this is the first message. Delivered '
213
- + 'immediately and cannot be unsent through this tool. For a room rather than a person, use gog_chat_messages_send.'
214
- + 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,
215
271
  annotations: { destructiveHint: true },
216
272
  inputSchema: z.object({
217
273
  email: z.string().describe('Recipient email address'),
218
274
  text: z.string().describe('Message text'),
219
275
  thread: threadParam,
220
276
  account: accountParam,
277
+ confirmToken: confirmTokenParam,
221
278
  }),
222
- }, async ({ email, text, thread, account }) => {
279
+ }, async ({ email, text, thread, account, confirmToken }, ctx) => {
223
280
  const args: GogArg[] = ['chat', 'dm', 'send', pos(email), `--text=${text}`];
224
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;
225
297
  return runOrDiagnose(args, { account });
226
298
  });
227
299
 
@@ -291,6 +363,7 @@ export function registerChatTools(server: McpServer): void {
291
363
  registerRunTool(server, {
292
364
  service: 'chat',
293
365
  examples: '"spaces", "messages", "dm"',
366
+ vet: vetChatRun,
294
367
  note: 'Google Chat has no API for consumer accounts: every chat subcommand fails on an @gmail.com account regardless of scopes.',
295
368
  });
296
369
  }
@@ -3,6 +3,57 @@ import { z } from 'zod';
3
3
  import { accountParam, runOrDiagnose, registerRunTool, pageTokenParam, pageAliasParam, resolvePageToken} from './utils.js';
4
4
  import { pos } from '../argv.js';
5
5
  import type { GogArg } from '../runner.js';
6
+ import { bodyPreview, CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, gatedElsewhere, hasCommandWord, requireDispatchConfirmation, resultText } from '../dispatch-confirmation.js';
7
+
8
+ // gog's spellings (internal/cmd/classroom.go, classroom_announcements.go, classroom_invitations.go).
9
+ const CLASSROOM_CREATE_WORDS = new Set(['create', 'add', 'new']);
10
+ const CLASSROOM_GATED: Record<string, { does: string; tool: string }> = {
11
+ announcements: { does: 'posts to a class', tool: 'gog_classroom_announcements_create' },
12
+ announcement: { does: 'posts to a class', tool: 'gog_classroom_announcements_create' },
13
+ ann: { does: 'posts to a class', tool: 'gog_classroom_announcements_create' },
14
+ invitations: { does: 'invites someone to a class', tool: 'gog_classroom_invitations_create' },
15
+ invitation: { does: 'invites someone to a class', tool: 'gog_classroom_invitations_create' },
16
+ invites: { does: 'invites someone to a class', tool: 'gog_classroom_invitations_create' },
17
+ };
18
+
19
+ /** gog_classroom_run must not post or invite what the dedicated tools would ask about. */
20
+ export function vetClassroomRun(subcommand: string, args: readonly string[]): string | undefined {
21
+ const sub = subcommand.toLowerCase();
22
+ const gated = Object.hasOwn(CLASSROOM_GATED, sub) ? CLASSROOM_GATED[sub] : undefined;
23
+ if (!gated) return undefined;
24
+ const word = hasCommandWord(args, CLASSROOM_CREATE_WORDS);
25
+ return word ? gatedElsewhere(`gog classroom ${sub} ${word.toLowerCase()}`, 'gog_classroom_run', gated.does, gated.tool) : undefined;
26
+ }
27
+
28
+ /**
29
+ * The course a Classroom dispatch reaches, for its confirmation prompt: a user
30
+ * approving "post to 123456789" cannot tell which class that is. Read on every
31
+ * call, so it is also the token fallback's phase-2 re-read. Unreadable output
32
+ * names nothing rather than throwing.
33
+ */
34
+ export async function readCourse(
35
+ courseId: string,
36
+ account: string | undefined,
37
+ // A sub-package passes the runOrDiagnose it imported from lib.js, so its own
38
+ // tests' mock of that seam covers this read too.
39
+ runner: typeof runOrDiagnose = runOrDiagnose,
40
+ ) {
41
+ const got = await runner(['classroom', 'courses', 'get', pos(courseId)], { account });
42
+ if (got.isError) return { error: got };
43
+ let course: { name?: unknown; section?: unknown } | undefined;
44
+ try {
45
+ course = (JSON.parse(resultText(got)) as { course?: typeof course } | null)?.course;
46
+ } catch {
47
+ course = undefined;
48
+ }
49
+ return {
50
+ course: {
51
+ id: courseId,
52
+ ...(typeof course?.name === 'string' ? { name: course.name } : {}),
53
+ ...(typeof course?.section === 'string' ? { section: course.section } : {}),
54
+ },
55
+ };
56
+ }
6
57
 
7
58
  export function registerClassroomTools(server: McpServer): void {
8
59
  server.registerTool('gog_classroom_courses_list', {
@@ -303,19 +354,47 @@ export function registerClassroomTools(server: McpServer): void {
303
354
  });
304
355
 
305
356
  server.registerTool('gog_classroom_announcements_create', {
306
- description: 'Create an announcement in a Google Classroom course.',
357
+ description: 'Create an announcement in a Google Classroom course. Unless state is DRAFT (which students cannot '
358
+ + 'see), this reads the course and asks the MCP host to show the user a confirmation prompt with the class, the '
359
+ + 'full text and when it publishes; nothing is posted unless they accept. To stage one without asking, pass '
360
+ + 'state DRAFT.' + CONFIRM_FALLBACK_DESCRIPTION,
307
361
  annotations: { destructiveHint: true },
308
362
  inputSchema: z.object({
309
363
  courseId: z.string().describe('Course ID'),
310
364
  text: z.string().describe('Announcement text'),
311
- state: z.enum(['PUBLISHED', 'DRAFT']).optional().describe('State'),
365
+ state: z.enum(['PUBLISHED', 'DRAFT']).optional().describe('State (DRAFT is visible only to teachers and needs no confirmation)'),
312
366
  scheduled: z.string().optional().describe('Scheduled publish time'),
313
367
  account: accountParam,
368
+ confirmToken: confirmTokenParam,
314
369
  }),
315
- }, async ({ courseId, text, state, scheduled, account }) => {
370
+ }, async ({ courseId, text, state, scheduled, account, confirmToken }, ctx) => {
316
371
  const args: GogArg[] = ['classroom', 'announcements', 'create', pos(courseId), `--text=${text}`];
317
372
  if (state) args.push(`--state=${state}`);
318
373
  if (scheduled) args.push(`--scheduled=${scheduled}`);
374
+ // A draft reaches nobody until a teacher publishes it: it is this tool's
375
+ // own staging twin, so it needs no confirmation.
376
+ if (state !== 'DRAFT') {
377
+ const read = await readCourse(courseId, account);
378
+ if (read.error) return read.error;
379
+ const publishes = scheduled ? `at ${scheduled}` : 'immediately';
380
+ const confirmation = await requireDispatchConfirmation(ctx, {
381
+ action: 'classroom.announcement-create',
382
+ message: 'Review and confirm this Classroom announcement:',
383
+ confirmationLabel: 'Confirm that this announcement should be posted to the class.',
384
+ details: { course: read.course, publishes, textPreview: bodyPreview(text) },
385
+ unsupportedNote: 'Create it with state DRAFT instead; the user can review and post it from Classroom.',
386
+ fallback: {
387
+ tool: 'gog_classroom_announcements_create',
388
+ account,
389
+ confirmToken,
390
+ subject: () => {
391
+ const view = { course: read.course, publishes, text };
392
+ return { target: courseId, payload: view, preview: view };
393
+ },
394
+ },
395
+ });
396
+ if (confirmation) return confirmation;
397
+ }
319
398
  return runOrDiagnose(args, { account });
320
399
  });
321
400
 
@@ -412,6 +491,7 @@ export function registerClassroomTools(server: McpServer): void {
412
491
  registerRunTool(server, {
413
492
  service: 'classroom',
414
493
  examples: '"guardians", "materials", "guardian-invitations"',
494
+ vet: vetClassroomRun,
415
495
  note: 'Covers anything not wrapped by the dedicated tools (guardians, guardian-invitations, materials, coursework assignees, announcement assignees, etc.).',
416
496
  });
417
497
  }
@@ -8,6 +8,14 @@ import { run, runBinary } from '../runner.js';
8
8
  import { accountParam, diagnose, runOrDiagnose, registerRunTool, pageTokenParam, pageAliasParam, resolvePageToken} from './utils.js';
9
9
  import { pos } from '../argv.js';
10
10
  import type { GogArg } from '../runner.js';
11
+ import { CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, gatedElsewhere, requireDispatchConfirmation, resultText } from '../dispatch-confirmation.js';
12
+
13
+ /** gog_drive_run must not grant access that gog_drive_share would ask about. */
14
+ export function vetDriveRun(subcommand: string, _args: readonly string[]): string | undefined {
15
+ return subcommand.toLowerCase() === 'share'
16
+ ? gatedElsewhere('gog drive share', 'gog_drive_run', 'grants access to a file', 'gog_drive_share')
17
+ : undefined;
18
+ }
11
19
 
12
20
  // A native Google Doc exports to text directly; anything else (PDF, image,
13
21
  // docx, …) is first copied WITH conversion to this type, which makes Drive run
@@ -18,6 +26,16 @@ const GOOGLE_DOC_MIME = 'application/vnd.google-apps.document';
18
26
  // under `file`; fall back to the top level if that ever changes.
19
27
  type DriveMeta = { name?: string; mimeType?: string; size?: string | number };
20
28
 
29
+ /** fileMeta for a preview: unreadable output names nothing rather than throwing. */
30
+ export function shareTargetMeta(raw: string): { name?: string; mimeType?: string } {
31
+ try {
32
+ const { name, mimeType } = fileMeta(raw);
33
+ return { name, mimeType };
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
38
+
21
39
  function fileMeta(raw: string): { name?: string; mimeType?: string; size?: number } {
22
40
  const parsed = JSON.parse(raw) as { file?: DriveMeta } & DriveMeta;
23
41
  const f = parsed.file ?? parsed;
@@ -170,7 +188,10 @@ export function registerDriveTools(server: McpServer): void {
170
188
  });
171
189
 
172
190
  server.registerTool('gog_drive_share', {
173
- description: 'Share a Google Drive file or folder.',
191
+ description: 'Share a Google Drive file or folder. Granting access is the risk even though no email is sent (gog '
192
+ + 'does not notify by default), so this reads the file and asks the MCP host to show the user a confirmation '
193
+ + 'prompt naming it, who gets access and with what role; nothing is shared unless they accept.'
194
+ + CONFIRM_FALLBACK_DESCRIPTION,
174
195
  annotations: { destructiveHint: true },
175
196
  inputSchema: z.object({
176
197
  fileId: z.string().describe('File or folder ID'),
@@ -179,8 +200,39 @@ export function registerDriveTools(server: McpServer): void {
179
200
  domain: z.string().optional().describe('Domain (required when to=domain)'),
180
201
  role: z.enum(['reader', 'writer']).optional().describe('Permission role (default: reader)'),
181
202
  account: accountParam,
203
+ confirmToken: confirmTokenParam,
182
204
  }),
183
- }, async ({ fileId, to, email, domain, role, account }) => {
205
+ }, async ({ fileId, to, email, domain, role, account, confirmToken }, ctx) => {
206
+ // Read first, on every call: a prompt that shows only an opaque id asks the
207
+ // user to approve something they cannot recognise, and on the token
208
+ // fallback's phase 2 this is the re-read the token is checked against.
209
+ const got = await runOrDiagnose(['drive', 'get', pos(fileId)], { account });
210
+ if (got.isError) return got;
211
+ const file = { id: fileId, ...shareTargetMeta(resultText(got)) };
212
+ const grant = {
213
+ file,
214
+ to,
215
+ ...(email ? { email } : {}),
216
+ ...(domain ? { domain } : {}),
217
+ role: role ?? 'reader',
218
+ publicLink: to === 'anyone',
219
+ };
220
+ const confirmation = await requireDispatchConfirmation(ctx, {
221
+ action: 'drive.share',
222
+ message: to === 'anyone'
223
+ ? 'Review and confirm making this file available to ANYONE with the link:'
224
+ : 'Review and confirm this Drive share:',
225
+ confirmationLabel: 'Confirm that this access should be granted now.',
226
+ details: grant,
227
+ unsupportedNote: 'Ask the user to share it themselves from Google Drive.',
228
+ fallback: {
229
+ tool: 'gog_drive_share',
230
+ account,
231
+ confirmToken,
232
+ subject: () => ({ target: fileId, payload: grant, preview: grant }),
233
+ },
234
+ });
235
+ if (confirmation) return confirmation;
184
236
  const args: GogArg[] = ['drive', 'share', pos(fileId), `--to=${to}`];
185
237
  if (email) args.push(`--email=${email}`);
186
238
  if (domain) args.push(`--domain=${domain}`);
@@ -314,5 +366,5 @@ export function registerDriveTools(server: McpServer): void {
314
366
  }
315
367
  });
316
368
 
317
- registerRunTool(server, { service: 'drive', examples: '"copy", "download", "permissions"' });
369
+ registerRunTool(server, { service: 'drive', examples: '"copy", "download", "permissions"', vet: vetDriveRun });
318
370
  }