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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +647 -67
- package/dist/lib.js +654 -67
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/dispatch-confirmation.ts +277 -0
- package/src/gmail-dispatch-guard.ts +36 -36
- package/src/lib.ts +9 -0
- package/src/send-confirm-token.ts +167 -0
- package/src/tools/api.ts +24 -3
- package/src/tools/calendar.ts +168 -7
- package/src/tools/chat.ts +78 -5
- package/src/tools/classroom.ts +83 -3
- package/src/tools/drive.ts +55 -3
- package/src/tools/gmail.ts +111 -20
- package/tests/gmail-dispatch-guard.test.ts +227 -14
- package/tests/send-confirm-token.test.ts +200 -0
- package/tests/tools/calendar.test.ts +4 -1
- package/tests/tools/chat.test.ts +4 -1
- package/tests/tools/classroom.test.ts +4 -1
- package/tests/tools/dispatch-gates.test.ts +396 -0
- package/tests/tools/drive.test.ts +4 -1
- package/tests/tools/gmail-confirm-token.test.ts +274 -0
- package/tests/tools/run-vets.test.ts +131 -0
package/src/tools/calendar.ts
CHANGED
|
@@ -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`).
|
|
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
|
-
+
|
|
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
|
}
|
package/src/tools/classroom.ts
CHANGED
|
@@ -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
|
}
|
package/src/tools/drive.ts
CHANGED
|
@@ -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
|
}
|