gogcli-mcp 4.4.0 → 4.5.1

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.
@@ -0,0 +1,447 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { createTestHarness } from '@chrischall/mcp-utils/test';
3
+ import type { McpServer, ElicitRequest, ElicitResult } from '@modelcontextprotocol/server';
4
+ import * as runner from '../../src/runner.js';
5
+ import { pos } from '../../src/argv.js';
6
+ import { resetConfirmTokenState } from '../../src/send-confirm-token.js';
7
+ import { registerChatTools } from '../../src/tools/chat.js';
8
+ import { registerAppScriptTools, projectSnapshot } from '../../src/tools/appscript.js';
9
+ import { registerClassroomTools, readCoursework, readSubmission, studentLabel } from '../../src/tools/classroom.js';
10
+ import { registerCalendarTools, eventSnapshot } from '../../src/tools/calendar.js';
11
+ import { registerDriveTools, commentMentions, commentSnapshot } from '../../src/tools/drive.js';
12
+
13
+ vi.mock('../../src/runner.js');
14
+
15
+ // ============================================================================
16
+ // The dispatch rail, second wave (fleet audit 2026-09-24 SEC-6): tools that
17
+ // notify people, grant access, run code under the account's authority or
18
+ // delete for good. Same three ways as dispatch-gates.test.ts — a client that
19
+ // can be prompted, one that cannot (refusal + hint), and the confirmToken
20
+ // fallback — plus each tool's "nothing to ask about" path stays unprompted.
21
+ // ============================================================================
22
+
23
+ const ORIGINAL_ENV = { ...process.env };
24
+
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ process.env = { ...ORIGINAL_ENV };
28
+ delete process.env.MCP_CONFIRM_MODE;
29
+ delete process.env.MCP_CONFIRM_TTL_SECONDS;
30
+ delete process.env.MCP_CONFIRM_SECRET;
31
+ process.env.GOG_ACCOUNT = 'me@example.com';
32
+ resetConfirmTokenState();
33
+ });
34
+
35
+ afterEach(() => { process.env = ORIGINAL_ENV; });
36
+
37
+ type Register = (server: McpServer) => void;
38
+ const json = (r: { content: Array<{ text?: string }> }) => JSON.parse(r.content[0]!.text as string);
39
+
40
+ async function prompted(register: Register, answer: ElicitResult = { action: 'accept', content: { confirmed: true } }) {
41
+ const seen: ElicitRequest[] = [];
42
+ const harness = await createTestHarness(register, { elicitation: async (r) => { seen.push(r); return answer; } });
43
+ const details = () => JSON.parse(seen[0]!.params.message.split('\n').slice(1).join('\n')).details;
44
+ return { harness, seen, details };
45
+ }
46
+ const unprompted = (register: Register) => createTestHarness(register);
47
+
48
+ const callsTo = (...words: string[]) => vi.mocked(runner.run).mock.calls.filter(([args]) =>
49
+ words.every((w, i) => args[i] === w));
50
+
51
+ /** Answer reads with `reads[<joined command words>]`, everything else with `{}`; a `failing` prefix rejects. */
52
+ function stubReads(reads: Record<string, string>, failing?: string) {
53
+ vi.mocked(runner.run).mockImplementation(async (args) => {
54
+ const key = (args as unknown[]).filter((a) => typeof a === 'string' && !a.startsWith('--')).join(' ');
55
+ if (failing && key.startsWith(failing)) throw new Error(`${failing} failed`);
56
+ for (const [prefix, out] of Object.entries(reads)) if (key.startsWith(prefix)) return out;
57
+ return '{"ok":true}';
58
+ });
59
+ }
60
+
61
+ describe('gog_chat_spaces_create', () => {
62
+ it('creates a member-less space without asking', async () => {
63
+ stubReads({});
64
+ const { harness, seen } = await prompted(registerChatTools);
65
+ await harness.callTool('gog_chat_spaces_create', { displayName: 'Solo' });
66
+ expect(seen).toHaveLength(0);
67
+ expect(callsTo('chat', 'spaces', 'create')).toHaveLength(1);
68
+ });
69
+
70
+ it('asks with the name and members, then creates once accepted', async () => {
71
+ stubReads({});
72
+ const { harness, details } = await prompted(registerChatTools);
73
+ await harness.callTool('gog_chat_spaces_create', { displayName: 'Launch', members: ['a@example.com', 'users/123'] });
74
+ expect(details()).toEqual({ displayName: 'Launch', members: ['a@example.com', 'users/123'] });
75
+ expect(callsTo('chat', 'spaces', 'create')[0]![0]).toEqual(
76
+ ['chat', 'spaces', 'create', pos('Launch'), '--member=a@example.com', '--member=users/123']);
77
+ });
78
+
79
+ it('creates nothing when the user declines', async () => {
80
+ const { harness } = await prompted(registerChatTools, { action: 'decline' });
81
+ const r = json(await harness.callTool('gog_chat_spaces_create', { displayName: 'Launch', members: ['a@example.com'] }));
82
+ expect(r).toMatchObject({ confirmed: false, cancelled: true, action: 'chat.space-create' });
83
+ expect(runner.run).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it('refuses a client that cannot be prompted, pointing at a member-less create', async () => {
87
+ process.env.MCP_CONFIRM_MODE = 'refuse';
88
+ const harness = await unprompted(registerChatTools);
89
+ const r = json(await harness.callTool('gog_chat_spaces_create', { displayName: 'Launch', members: ['a@example.com'] }));
90
+ expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'chat.space-create' });
91
+ expect(r.note).toMatch(/without members/);
92
+ expect(runner.run).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it('token fallback: previews the members, creates on phase 2, refuses a changed list', async () => {
96
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
97
+ stubReads({});
98
+ const harness = await unprompted(registerChatTools);
99
+ const args = { displayName: 'Launch', members: ['a@example.com'] };
100
+ const p1 = json(await harness.callTool('gog_chat_spaces_create', args));
101
+ expect(p1).toMatchObject({ status: 'confirmation-required', preview: { displayName: 'Launch', members: ['a@example.com'] } });
102
+ expect(json(await harness.callTool('gog_chat_spaces_create', { ...args, members: ['a@example.com', 'b@example.com'], confirmToken: p1.confirmToken })))
103
+ .toMatchObject({ error: 'DRAFT_CHANGED' });
104
+ await harness.callTool('gog_chat_spaces_create', { ...args, confirmToken: p1.confirmToken });
105
+ expect(callsTo('chat', 'spaces', 'create')).toHaveLength(1);
106
+ });
107
+ });
108
+
109
+ describe('gog_appscript_run_function', () => {
110
+ const PROJECT = JSON.stringify({ project: { scriptId: 'S1', title: 'Payroll sync' }, editor_url: 'https://script.google.com/d/S1/edit' });
111
+
112
+ it('reads the project and asks, naming it, the function and its arguments', async () => {
113
+ stubReads({ 'appscript get': PROJECT });
114
+ const { harness, details } = await prompted(registerAppScriptTools);
115
+ await harness.callTool('gog_appscript_run_function', { scriptId: 'S1', functionName: 'doWork', params: '["a", 1]' });
116
+ expect(details()).toEqual({
117
+ project: { scriptId: 'S1', title: 'Payroll sync' },
118
+ functionName: 'doWork',
119
+ params: ['a', 1],
120
+ devMode: false,
121
+ runsAs: 'me@example.com',
122
+ });
123
+ expect(callsTo('appscript', 'run')[0]![0]).toEqual(['appscript', 'run', pos('S1'), pos('doWork'), '--params=["a", 1]']);
124
+ });
125
+
126
+ it('still rejects malformed params before any gog call', async () => {
127
+ const { harness, seen } = await prompted(registerAppScriptTools);
128
+ const r = await harness.callTool('gog_appscript_run_function', { scriptId: 'S1', functionName: 'doWork', params: '{"a":1}' });
129
+ expect(r.isError).toBe(true);
130
+ expect(seen).toHaveLength(0);
131
+ expect(runner.run).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it('returns a failed project read without running anything', async () => {
135
+ stubReads({}, 'appscript get');
136
+ const { harness, seen } = await prompted(registerAppScriptTools);
137
+ expect((await harness.callTool('gog_appscript_run_function', { scriptId: 'S1', functionName: 'doWork' })).isError).toBe(true);
138
+ expect(seen).toHaveLength(0);
139
+ expect(callsTo('appscript', 'run')).toHaveLength(0);
140
+ });
141
+
142
+ it('refuses a client that cannot be prompted', async () => {
143
+ process.env.MCP_CONFIRM_MODE = 'refuse';
144
+ stubReads({ 'appscript get': PROJECT });
145
+ const harness = await unprompted(registerAppScriptTools);
146
+ const r = json(await harness.callTool('gog_appscript_run_function', { scriptId: 'S1', functionName: 'doWork' }));
147
+ expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'appscript.run-function' });
148
+ expect(r.note).toMatch(/Apps Script editor/);
149
+ expect(callsTo('appscript', 'run')).toHaveLength(0);
150
+ });
151
+
152
+ it('token fallback: binds the arguments — different params are DRAFT_CHANGED', async () => {
153
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
154
+ stubReads({ 'appscript get': PROJECT });
155
+ const harness = await unprompted(registerAppScriptTools);
156
+ const args = { scriptId: 'S1', functionName: 'doWork', params: '[1]', devMode: true };
157
+ const p1 = json(await harness.callTool('gog_appscript_run_function', args));
158
+ expect(p1.preview).toEqual({ project: { scriptId: 'S1', title: 'Payroll sync' }, functionName: 'doWork', params: [1], devMode: true, runsAs: 'me@example.com' });
159
+ expect(json(await harness.callTool('gog_appscript_run_function', { ...args, params: '[2]', confirmToken: p1.confirmToken })))
160
+ .toMatchObject({ error: 'DRAFT_CHANGED' });
161
+ await harness.callTool('gog_appscript_run_function', { ...args, confirmToken: p1.confirmToken });
162
+ expect(callsTo('appscript', 'run')).toHaveLength(1);
163
+ expect(callsTo('appscript', 'run')[0]![0]).toContain('--dev-mode');
164
+ });
165
+
166
+ it('token fallback: code saved between the phases (updateTime rotated) is DRAFT_CHANGED', async () => {
167
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
168
+ const at = (updateTime: string) => JSON.stringify({ project: { scriptId: 'S1', title: 'Payroll sync', updateTime } });
169
+ stubReads({ 'appscript get': at('2026-09-24T10:00:00Z') });
170
+ const harness = await unprompted(registerAppScriptTools);
171
+ const args = { scriptId: 'S1', functionName: 'doWork' };
172
+ const p1 = json(await harness.callTool('gog_appscript_run_function', args));
173
+ expect(p1.preview.project).toEqual({ scriptId: 'S1', title: 'Payroll sync' });
174
+ stubReads({ 'appscript get': at('2026-09-24T10:05:00Z') });
175
+ expect(json(await harness.callTool('gog_appscript_run_function', { ...args, confirmToken: p1.confirmToken })))
176
+ .toMatchObject({ error: 'DRAFT_CHANGED', reason: 'revision-changed' });
177
+ expect(callsTo('appscript', 'run')).toHaveLength(0);
178
+ });
179
+
180
+ it('projectSnapshot names nothing for unreadable output', () => {
181
+ expect(projectSnapshot('nope', 'S9')).toEqual({ scriptId: 'S9' });
182
+ expect(projectSnapshot('{"project":{"title":7}}', 'S9')).toEqual({ scriptId: 'S9' });
183
+ });
184
+ });
185
+
186
+ describe('gog_classroom_submissions_return', () => {
187
+ const COURSE = JSON.stringify({ course: { id: 'c1', name: 'Algebra II', section: 'Period 3' } });
188
+ const WORK = JSON.stringify({ coursework: { id: 'w1', title: 'HW 3', state: 'PUBLISHED' } });
189
+ const SUBMISSION = (updateTime = '2026-09-24T10:00:00Z') => JSON.stringify({
190
+ submission: { id: 's1', userId: '1234', state: 'TURNED_IN', draftGrade: 95, assignedGrade: 90, updateTime },
191
+ });
192
+ const STUDENT = JSON.stringify({ student: { userId: '1234', profile: { name: { fullName: 'Pat Example' }, emailAddress: 'pat@example.com' } } });
193
+ const READS = {
194
+ 'classroom courses get': COURSE,
195
+ 'classroom coursework get': WORK,
196
+ 'classroom submissions get': SUBMISSION(),
197
+ 'classroom students get': STUDENT,
198
+ };
199
+ const ARGS = { courseId: 'c1', courseworkId: 'w1', submissionId: 's1' };
200
+
201
+ it('reads the course, the coursework, the submission and the student, and asks showing them by name', async () => {
202
+ stubReads(READS);
203
+ const { harness, details } = await prompted(registerClassroomTools);
204
+ await harness.callTool('gog_classroom_submissions_return', ARGS);
205
+ expect(details()).toEqual({
206
+ course: { id: 'c1', name: 'Algebra II', section: 'Period 3' },
207
+ coursework: { id: 'w1', title: 'HW 3' },
208
+ submission: { id: 's1', student: 'Pat Example <pat@example.com>', state: 'TURNED_IN', draftGrade: 95, assignedGrade: 90 },
209
+ });
210
+ expect(callsTo('classroom', 'submissions', 'return')[0]![0]).toEqual(
211
+ ['classroom', 'submissions', 'return', pos('c1'), pos('w1'), pos('s1')]);
212
+ });
213
+
214
+ it('returns nothing when the user declines', async () => {
215
+ stubReads(READS);
216
+ const { harness } = await prompted(registerClassroomTools, { action: 'decline' });
217
+ const r = json(await harness.callTool('gog_classroom_submissions_return', ARGS));
218
+ expect(r).toMatchObject({ confirmed: false, cancelled: true, action: 'classroom.submission-return' });
219
+ expect(callsTo('classroom', 'submissions', 'return')).toHaveLength(0);
220
+ });
221
+
222
+ it.each(['classroom courses get', 'classroom coursework get', 'classroom submissions get'])(
223
+ 'returns a failed read (%s) without asking or returning', async (failing) => {
224
+ stubReads(READS, failing);
225
+ const { harness, seen } = await prompted(registerClassroomTools);
226
+ expect((await harness.callTool('gog_classroom_submissions_return', ARGS)).isError).toBe(true);
227
+ expect(seen).toHaveLength(0);
228
+ expect(callsTo('classroom', 'submissions', 'return')).toHaveLength(0);
229
+ });
230
+
231
+ it('refuses a client that cannot be prompted', async () => {
232
+ process.env.MCP_CONFIRM_MODE = 'refuse';
233
+ stubReads(READS);
234
+ const harness = await unprompted(registerClassroomTools);
235
+ const r = json(await harness.callTool('gog_classroom_submissions_return', ARGS));
236
+ expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'classroom.submission-return' });
237
+ expect(r.note).toMatch(/return it from Classroom/);
238
+ });
239
+
240
+ it('token fallback: a submission re-graded between the phases (updateTime rotated) is DRAFT_CHANGED', async () => {
241
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
242
+ stubReads(READS);
243
+ const harness = await unprompted(registerClassroomTools);
244
+ const p1 = json(await harness.callTool('gog_classroom_submissions_return', ARGS));
245
+ expect(p1.preview).toMatchObject({ coursework: { title: 'HW 3' }, submission: { student: 'Pat Example <pat@example.com>', assignedGrade: 90 } });
246
+ stubReads({ ...READS, 'classroom submissions get': SUBMISSION('2026-09-24T10:05:00Z') });
247
+ expect(json(await harness.callTool('gog_classroom_submissions_return', { ...ARGS, confirmToken: p1.confirmToken })))
248
+ .toMatchObject({ error: 'DRAFT_CHANGED', reason: 'revision-changed' });
249
+ stubReads(READS);
250
+ await harness.callTool('gog_classroom_submissions_return', { ...ARGS, confirmToken: p1.confirmToken });
251
+ expect(callsTo('classroom', 'submissions', 'return')).toHaveLength(1);
252
+ });
253
+
254
+ it('asks without a student line when the submission names no user', async () => {
255
+ stubReads({ ...READS, 'classroom submissions get': JSON.stringify({ submission: { id: 's1', state: 'TURNED_IN' } }) });
256
+ const { harness, details } = await prompted(registerClassroomTools, { action: 'decline' });
257
+ await harness.callTool('gog_classroom_submissions_return', ARGS);
258
+ expect(details().submission).toEqual({ id: 's1', state: 'TURNED_IN' });
259
+ expect(callsTo('classroom', 'students', 'get')).toHaveLength(0);
260
+ });
261
+
262
+ it('studentLabel falls back from name+email to either, then to the id', async () => {
263
+ const label = async (student: unknown) => {
264
+ vi.mocked(runner.run).mockResolvedValue(JSON.stringify({ student }));
265
+ return studentLabel('c1', 'u1', undefined);
266
+ };
267
+ expect(await label({ profile: { name: { fullName: 'Pat' } } })).toBe('Pat');
268
+ expect(await label({ profile: { emailAddress: 'p@example.com' } })).toBe('p@example.com');
269
+ expect(await label({})).toBe('u1');
270
+ vi.mocked(runner.run).mockResolvedValue('not json');
271
+ expect(await studentLabel('c1', 'u1', undefined)).toBe('u1');
272
+ vi.mocked(runner.run).mockRejectedValue(new Error('not found'));
273
+ expect(await studentLabel('c1', 'u1', undefined)).toBe('u1');
274
+ });
275
+
276
+ it('readCoursework and readSubmission name nothing for unreadable output', async () => {
277
+ vi.mocked(runner.run).mockResolvedValue('not json');
278
+ expect(await readCoursework('c9', 'w9', undefined)).toEqual({ coursework: { id: 'w9' } });
279
+ expect(await readSubmission('c9', 'w9', 's9', undefined)).toEqual({ submission: { id: 's9' } });
280
+ vi.mocked(runner.run).mockResolvedValue('{"coursework":{"title":7},"submission":{"userId":7,"state":null,"assignedGrade":"A"}}');
281
+ expect(await readCoursework('c9', 'w9', undefined)).toEqual({ coursework: { id: 'w9' } });
282
+ expect(await readSubmission('c9', 'w9', 's9', undefined)).toEqual({ submission: { id: 's9' } });
283
+ });
284
+ });
285
+
286
+ describe('gog_calendar_delete', () => {
287
+ const EVENT = (overrides: Record<string, unknown> = {}) => JSON.stringify({
288
+ event: {
289
+ id: 'e1',
290
+ etag: '"v1"',
291
+ summary: 'Budget review',
292
+ start: { dateTime: '2026-09-30T14:00:00-04:00' },
293
+ end: { dateTime: '2026-09-30T15:00:00-04:00' },
294
+ organizer: { email: 'me@example.com' },
295
+ attendees: [{ email: 'me@example.com', self: true }, { email: 'boss@example.com' }],
296
+ ...overrides,
297
+ },
298
+ });
299
+ const ARGS = { calendarId: 'primary', eventId: 'e1' };
300
+
301
+ it('deletes a guest-free, one-off event without asking', async () => {
302
+ stubReads({ 'calendar event': EVENT({ attendees: [{ email: 'me@example.com', self: true }] }) });
303
+ const { harness, seen } = await prompted(registerCalendarTools);
304
+ await harness.callTool('gog_calendar_delete', ARGS);
305
+ expect(seen).toHaveLength(0);
306
+ expect(callsTo('calendar', 'delete')[0]![0]).toEqual(['calendar', 'delete', pos('primary'), pos('e1'), '--force']);
307
+ });
308
+
309
+ it('asks before deleting an event other people are on, showing it as it stands', async () => {
310
+ stubReads({ 'calendar event': EVENT() });
311
+ const { harness, details } = await prompted(registerCalendarTools);
312
+ await harness.callTool('gog_calendar_delete', ARGS);
313
+ expect(details()).toEqual({
314
+ calendarId: 'primary',
315
+ eventId: 'e1',
316
+ event: {
317
+ summary: 'Budget review',
318
+ start: '2026-09-30T14:00:00-04:00',
319
+ end: '2026-09-30T15:00:00-04:00',
320
+ organizer: 'me@example.com',
321
+ guests: ['boss@example.com'],
322
+ },
323
+ scope: 'this event',
324
+ });
325
+ expect(callsTo('calendar', 'delete')).toHaveLength(1);
326
+ });
327
+
328
+ it.each([
329
+ ['a recurrence rule', { recurrence: ['RRULE:FREQ=WEEKLY'] }],
330
+ ['a recurringEventId', { recurringEventId: 'series1' }],
331
+ ])('asks before deleting a recurring series (%s), even guest-free, because --scope defaults to the whole series', async (_what, extra) => {
332
+ stubReads({ 'calendar event': EVENT({ attendees: undefined, ...extra }) });
333
+ const { harness, details } = await prompted(registerCalendarTools, { action: 'decline' });
334
+ await harness.callTool('gog_calendar_delete', ARGS);
335
+ expect(details()).toMatchObject({ event: { guests: [], recurring: true }, scope: 'the whole recurring series' });
336
+ expect(callsTo('calendar', 'delete')).toHaveLength(0);
337
+ });
338
+
339
+ it('returns a failed event read without deleting', async () => {
340
+ stubReads({}, 'calendar event');
341
+ const { harness } = await prompted(registerCalendarTools);
342
+ expect((await harness.callTool('gog_calendar_delete', ARGS)).isError).toBe(true);
343
+ expect(callsTo('calendar', 'delete')).toHaveLength(0);
344
+ });
345
+
346
+ it('refuses a client that cannot be prompted', async () => {
347
+ process.env.MCP_CONFIRM_MODE = 'refuse';
348
+ stubReads({ 'calendar event': EVENT() });
349
+ const harness = await unprompted(registerCalendarTools);
350
+ const r = json(await harness.callTool('gog_calendar_delete', ARGS));
351
+ expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'calendar.delete' });
352
+ expect(r.note).toMatch(/delete it from Google Calendar/);
353
+ expect(callsTo('calendar', 'delete')).toHaveLength(0);
354
+ });
355
+
356
+ it('token fallback: an event edited elsewhere between the phases (etag rotated) is DRAFT_CHANGED', async () => {
357
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
358
+ stubReads({ 'calendar event': EVENT() });
359
+ const harness = await unprompted(registerCalendarTools);
360
+ const p1 = json(await harness.callTool('gog_calendar_delete', ARGS));
361
+ expect(p1.status).toBe('confirmation-required');
362
+ stubReads({ 'calendar event': EVENT({ etag: '"v2"' }) });
363
+ expect(json(await harness.callTool('gog_calendar_delete', { ...ARGS, confirmToken: p1.confirmToken })))
364
+ .toMatchObject({ error: 'DRAFT_CHANGED', reason: 'revision-changed' });
365
+ stubReads({ 'calendar event': EVENT() });
366
+ await harness.callTool('gog_calendar_delete', { ...ARGS, confirmToken: p1.confirmToken });
367
+ expect(callsTo('calendar', 'delete')).toHaveLength(1);
368
+ });
369
+
370
+ it('eventSnapshot marks a series only when the event says so', () => {
371
+ expect(eventSnapshot(EVENT()).recurring).toBeUndefined();
372
+ expect(eventSnapshot(EVENT({ recurrence: [] })).recurring).toBeUndefined();
373
+ expect(eventSnapshot(EVENT({ recurringEventId: 'x' })).recurring).toBe(true);
374
+ });
375
+ });
376
+
377
+ describe('gog_drive_delete with permanent=true', () => {
378
+ const FILE = JSON.stringify({ file: { id: 'f1', name: 'Payroll 2026.xlsx', mimeType: 'application/vnd.google-apps.spreadsheet' } });
379
+
380
+ it('trashes without reading or asking', async () => {
381
+ stubReads({ 'drive get': FILE });
382
+ const { harness, seen } = await prompted(registerDriveTools);
383
+ await harness.callTool('gog_drive_delete', { fileId: 'f1' });
384
+ expect(seen).toHaveLength(0);
385
+ expect(callsTo('drive', 'get')).toHaveLength(0);
386
+ expect(callsTo('drive', 'delete')[0]![0]).toEqual(['drive', 'delete', pos('f1'), '--force']);
387
+ });
388
+
389
+ it('reads the file and asks by name before a permanent delete', async () => {
390
+ stubReads({ 'drive get': FILE });
391
+ const { harness, details, seen } = await prompted(registerDriveTools);
392
+ await harness.callTool('gog_drive_delete', { fileId: 'f1', permanent: true });
393
+ expect(seen[0]!.params.message).toMatch(/PERMANENTLY/);
394
+ expect(details()).toEqual({
395
+ file: { id: 'f1', name: 'Payroll 2026.xlsx', mimeType: 'application/vnd.google-apps.spreadsheet' },
396
+ permanent: true,
397
+ });
398
+ expect(callsTo('drive', 'delete')[0]![0]).toEqual(['drive', 'delete', pos('f1'), '--permanent', '--force']);
399
+ });
400
+
401
+ it('returns a failed read without deleting', async () => {
402
+ stubReads({}, 'drive get');
403
+ const { harness } = await prompted(registerDriveTools);
404
+ expect((await harness.callTool('gog_drive_delete', { fileId: 'f1', permanent: true })).isError).toBe(true);
405
+ expect(callsTo('drive', 'delete')).toHaveLength(0);
406
+ });
407
+
408
+ it('refuses a client that cannot be prompted, pointing at the trash', async () => {
409
+ process.env.MCP_CONFIRM_MODE = 'refuse';
410
+ stubReads({ 'drive get': FILE });
411
+ const harness = await unprompted(registerDriveTools);
412
+ const r = json(await harness.callTool('gog_drive_delete', { fileId: 'f1', permanent: true }));
413
+ expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'drive.delete-permanent' });
414
+ expect(r.note).toMatch(/trash/);
415
+ expect(callsTo('drive', 'delete')).toHaveLength(0);
416
+ });
417
+
418
+ it('token fallback: binds the file as read — a renamed file is DRAFT_CHANGED', async () => {
419
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
420
+ stubReads({ 'drive get': FILE });
421
+ const harness = await unprompted(registerDriveTools);
422
+ const args = { fileId: 'f1', permanent: true };
423
+ const p1 = json(await harness.callTool('gog_drive_delete', args));
424
+ expect(p1.preview).toEqual({ file: { id: 'f1', name: 'Payroll 2026.xlsx', mimeType: 'application/vnd.google-apps.spreadsheet' }, permanent: true });
425
+ stubReads({ 'drive get': JSON.stringify({ file: { id: 'f1', name: 'Renamed' } }) });
426
+ expect(json(await harness.callTool('gog_drive_delete', { ...args, confirmToken: p1.confirmToken }))).toMatchObject({ error: 'DRAFT_CHANGED' });
427
+ stubReads({ 'drive get': FILE });
428
+ await harness.callTool('gog_drive_delete', { ...args, confirmToken: p1.confirmToken });
429
+ expect(callsTo('drive', 'delete')).toHaveLength(1);
430
+ });
431
+
432
+ it('commentSnapshot reads a comment and tolerates unreadable output', () => {
433
+ expect(commentSnapshot(JSON.stringify({ comment: { id: 'c1', content: 'Please fix', author: { displayName: 'Alice', emailAddress: 'alice@example.com' }, resolved: false } })))
434
+ .toEqual({ author: 'Alice <alice@example.com>', content: 'Please fix', resolved: false });
435
+ expect(commentSnapshot(JSON.stringify({ comment: { content: 'x', author: { displayName: 'Bob' } } }))).toEqual({ author: 'Bob', content: 'x' });
436
+ expect(commentSnapshot(JSON.stringify({ comment: { author: { emailAddress: 'b@example.com' }, resolved: 'yes' } }))).toEqual({ author: 'b@example.com' });
437
+ expect(commentSnapshot(JSON.stringify({ content: 'bare', author: { displayName: 'Cy' } }))).toEqual({ author: 'Cy', content: 'bare' });
438
+ expect(commentSnapshot('null')).toEqual({});
439
+ expect(commentSnapshot('nope')).toEqual({});
440
+ });
441
+
442
+ it('commentMentions finds +/@ mentions once each, ignoring bare addresses', () => {
443
+ expect(commentMentions('+alice@example.com please check with @bob@example.co.uk and +alice@example.com'))
444
+ .toEqual(['alice@example.com', 'bob@example.co.uk']);
445
+ expect(commentMentions('mail carol@example.com about it')).toEqual([]);
446
+ });
447
+ });
@@ -7,7 +7,7 @@ import { resetConfirmTokenState } from '../../src/send-confirm-token.js';
7
7
  import { CONFIRM_ACTION_INSTRUCTION } from '../../src/dispatch-confirmation.js';
8
8
  import { registerChatTools } from '../../src/tools/chat.js';
9
9
  import { registerDriveTools, shareTargetMeta } from '../../src/tools/drive.js';
10
- import { registerClassroomTools, readCourse } from '../../src/tools/classroom.js';
10
+ import { registerClassroomTools, readCourse, readClassroomWork } from '../../src/tools/classroom.js';
11
11
  import { registerCalendarTools, eventSnapshot, parseAttendees } from '../../src/tools/calendar.js';
12
12
 
13
13
  vi.mock('../../src/runner.js');
@@ -24,9 +24,9 @@ const ORIGINAL_ENV = { ...process.env };
24
24
  beforeEach(() => {
25
25
  vi.clearAllMocks();
26
26
  process.env = { ...ORIGINAL_ENV };
27
- delete process.env.GOG_SEND_CONFIRM_FALLBACK;
28
- delete process.env.GOG_CONFIRM_TTL_SECONDS;
29
- delete process.env.GOG_CONFIRM_SECRET;
27
+ delete process.env.MCP_CONFIRM_MODE;
28
+ delete process.env.MCP_CONFIRM_TTL_SECONDS;
29
+ delete process.env.MCP_CONFIRM_SECRET;
30
30
  process.env.GOG_ACCOUNT = 'me@example.com';
31
31
  resetConfirmTokenState();
32
32
  });
@@ -75,15 +75,17 @@ describe('gog_chat_messages_send / gog_chat_dm_send', () => {
75
75
  });
76
76
 
77
77
  it('refuses a client that cannot be prompted, naming the fallback switch', async () => {
78
+
79
+ process.env.MCP_CONFIRM_MODE = 'refuse';
78
80
  const harness = await unprompted(registerChatTools);
79
81
  const r = json(await harness.callTool('gog_chat_messages_send', { space: 'spaces/AAA', text: 'x' }));
80
82
  expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'chat.message-send' });
81
- expect(r.note).toContain('GOG_SEND_CONFIRM_FALLBACK=token');
83
+ expect(r.note).toContain('MCP_CONFIRM_MODE=ask-user');
82
84
  expect(runner.run).not.toHaveBeenCalled();
83
85
  });
84
86
 
85
87
  it('token fallback: previews in full, posts on phase 2, refuses a changed text', async () => {
86
- process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
88
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
87
89
  stubReads({});
88
90
  const harness = await unprompted(registerChatTools);
89
91
  const args = { space: 'spaces/AAA', text: 'Ship it', attachInline: [{ filename: 'a.txt', contentBase64: Buffer.from('hey').toString('base64') }] };
@@ -137,15 +139,17 @@ describe('gog_drive_share', () => {
137
139
  });
138
140
 
139
141
  it('refuses a client that cannot be prompted, and says how the user can share it', async () => {
142
+
143
+ process.env.MCP_CONFIRM_MODE = 'refuse';
140
144
  stubReads({ 'drive get': FILE });
141
145
  const harness = await unprompted(registerDriveTools);
142
146
  const r = json(await harness.callTool('gog_drive_share', { fileId: 'f1', to: 'domain', domain: 'example.com' }));
143
147
  expect(r.note).toMatch(/share it themselves from Google Drive/);
144
- expect(r.note).toContain('GOG_SEND_CONFIRM_FALLBACK=token');
148
+ expect(r.note).toContain('MCP_CONFIRM_MODE=ask-user');
145
149
  });
146
150
 
147
151
  it('token fallback: binds the file as read — a renamed file is DRAFT_CHANGED', async () => {
148
- process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
152
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
149
153
  stubReads({ 'drive get': FILE });
150
154
  const harness = await unprompted(registerDriveTools);
151
155
  const args = { fileId: 'f1', to: 'anyone' as const };
@@ -188,7 +192,19 @@ describe('gog_classroom_announcements_create', () => {
188
192
  expect(callsTo('classroom', 'announcements', 'create')).toHaveLength(1);
189
193
  });
190
194
 
195
+ // A scheduled item is a DRAFT that publishes itself at that time, so a
196
+ // DRAFT with a schedule reaches the class too and asks like any other.
197
+ it('asks for a scheduled DRAFT, which publishes itself', async () => {
198
+ stubReads({ 'classroom courses get': COURSE });
199
+ const { harness, details } = await prompted(registerClassroomTools, { action: 'decline' });
200
+ await harness.callTool('gog_classroom_announcements_create', { courseId: 'c1', text: 'Quiz Friday', state: 'DRAFT', scheduled: '2026-09-25T08:00:00Z' });
201
+ expect(details()).toMatchObject({ publishes: 'at 2026-09-25T08:00:00Z', textPreview: 'Quiz Friday' });
202
+ expect(callsTo('classroom', 'announcements', 'create')).toHaveLength(0);
203
+ });
204
+
191
205
  it('refuses a client that cannot be prompted, pointing at DRAFT', async () => {
206
+
207
+ process.env.MCP_CONFIRM_MODE = 'refuse';
192
208
  stubReads({ 'classroom courses get': COURSE });
193
209
  const harness = await unprompted(registerClassroomTools);
194
210
  const r = json(await harness.callTool('gog_classroom_announcements_create', { courseId: 'c1', text: 'x' }));
@@ -197,7 +213,7 @@ describe('gog_classroom_announcements_create', () => {
197
213
  });
198
214
 
199
215
  it('token fallback: phase 1 previews, phase 2 posts', async () => {
200
- process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
216
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
201
217
  stubReads({ 'classroom courses get': COURSE });
202
218
  const harness = await unprompted(registerClassroomTools);
203
219
  const p1 = json(await harness.callTool('gog_classroom_announcements_create', { courseId: 'c1', text: 'Quiz Friday' }));
@@ -219,6 +235,47 @@ describe('gog_classroom_announcements_create', () => {
219
235
  vi.mocked(runner.run).mockResolvedValue('{"course":{"name":7}}');
220
236
  expect(await readCourse('c9', undefined)).toEqual({ course: { id: 'c9' } });
221
237
  });
238
+
239
+ // The item a publish-through-update reaches students with (SEC-3, fleet-audit
240
+ // #932): its text or title for the prompt, its updateTime as the token's
241
+ // revision. gog nests the payload under the API resource name.
242
+ describe('readClassroomWork', () => {
243
+ it('reads an announcement: text, state and updateTime', async () => {
244
+ // runOrDiagnose renders instants in DISPLAY_TZ (America/New_York by default).
245
+ vi.mocked(runner.run).mockResolvedValue(JSON.stringify({ announcement: { id: 'a1', text: 'Quiz Friday', state: 'DRAFT', updateTime: '2026-09-20T10:00:00Z', creatorUserId: '7' } }));
246
+ expect(await readClassroomWork('announcements', 'c1', 'a1', 'me@example.com')).toEqual({
247
+ work: { id: 'a1', text: 'Quiz Friday', state: 'DRAFT', updateTime: '2026-09-20T06:00:00-04:00' },
248
+ });
249
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'announcements', 'get', pos('c1'), pos('a1')], { account: 'me@example.com' });
250
+ });
251
+
252
+ // gog 0.41.0 writes `{"coursework": …}` (internal/cmd/classroom_coursework.go),
253
+ // not the API's `courseWork`: reading the wrong key named nothing and left
254
+ // the token fallback without a revision to bind.
255
+ it('reads coursework under gog\'s key: title, description, state and updateTime', async () => {
256
+ vi.mocked(runner.run).mockResolvedValue(JSON.stringify({ coursework: { id: 'w1', title: 'Problem set', description: 'Work problems 1-20', state: 'PUBLISHED', updateTime: '2026-09-21T10:00:00Z' } }));
257
+ expect(await readClassroomWork('coursework', 'c1', 'w1', undefined)).toEqual({
258
+ work: { id: 'w1', title: 'Problem set', description: 'Work problems 1-20', state: 'PUBLISHED', updateTime: '2026-09-21T06:00:00-04:00' },
259
+ });
260
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'coursework', 'get', pos('c1'), pos('w1')], { account: undefined });
261
+ });
262
+
263
+ it('names nothing for unreadable output or fields of the wrong type', async () => {
264
+ vi.mocked(runner.run).mockResolvedValue('not json');
265
+ expect(await readClassroomWork('announcements', 'c1', 'a1', undefined)).toEqual({ work: { id: 'a1' } });
266
+ vi.mocked(runner.run).mockResolvedValue('{"announcement":{"text":7,"state":null,"updateTime":[]}}');
267
+ expect(await readClassroomWork('announcements', 'c1', 'a1', undefined)).toEqual({ work: { id: 'a1' } });
268
+ vi.mocked(runner.run).mockResolvedValue('null');
269
+ expect(await readClassroomWork('coursework', 'c1', 'w1', undefined)).toEqual({ work: { id: 'w1' } });
270
+ });
271
+
272
+ it('passes a failed read back unchanged', async () => {
273
+ vi.mocked(runner.run).mockRejectedValue(new Error('not found'));
274
+ const got = await readClassroomWork('coursework', 'c1', 'w1', undefined);
275
+ expect(got.error?.isError).toBe(true);
276
+ expect('work' in got).toBe(false);
277
+ });
278
+ });
222
279
  });
223
280
 
224
281
  describe('calendar', () => {
@@ -264,6 +321,8 @@ describe('calendar', () => {
264
321
  });
265
322
 
266
323
  it('refuses a client that cannot be prompted, pointing at a guest-free create', async () => {
324
+
325
+ process.env.MCP_CONFIRM_MODE = 'refuse';
267
326
  const harness = await unprompted(registerCalendarTools);
268
327
  const r = json(await harness.callTool('gog_calendar_create', { calendarId: 'primary', summary: 'Sync', from: 'a', to: 'b', attendees: 'alice@example.com' }));
269
328
  expect(r.note).toMatch(/without attendees/);
@@ -271,7 +330,7 @@ describe('calendar', () => {
271
330
  });
272
331
 
273
332
  it('token fallback: previews, then creates on phase 2', async () => {
274
- process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
333
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
275
334
  stubReads({});
276
335
  const harness = await unprompted(registerCalendarTools);
277
336
  const args = { calendarId: 'primary', summary: 'Sync', from: 'a', to: 'b', attendees: 'alice@example.com' };
@@ -335,7 +394,7 @@ describe('calendar', () => {
335
394
  });
336
395
 
337
396
  it('token fallback: an event edited elsewhere between the phases (etag rotated) is DRAFT_CHANGED', async () => {
338
- process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
397
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
339
398
  stubReads({ 'calendar event': EVENT() });
340
399
  const harness = await unprompted(registerCalendarTools);
341
400
  const args = { calendarId: 'primary', eventId: 'e1', summary: 'Budget review (moved)' };
@@ -343,7 +402,7 @@ describe('calendar', () => {
343
402
  expect(p1.status).toBe('confirmation-required');
344
403
  stubReads({ 'calendar event': EVENT({ etag: '"v2"' }) });
345
404
  expect(json(await harness.callTool('gog_calendar_update', { ...args, confirmToken: p1.confirmToken })))
346
- .toMatchObject({ error: 'DRAFT_CHANGED', reason: 'message-id-rotated' });
405
+ .toMatchObject({ error: 'DRAFT_CHANGED', reason: 'revision-changed' });
347
406
  expect(callsTo('calendar', 'update')).toHaveLength(0);
348
407
  });
349
408
  });
@@ -358,6 +417,8 @@ describe('calendar', () => {
358
417
  });
359
418
 
360
419
  it('refuses a client that cannot be prompted', async () => {
420
+
421
+ process.env.MCP_CONFIRM_MODE = 'refuse';
361
422
  stubReads({ 'calendar event': EVENT() });
362
423
  const harness = await unprompted(registerCalendarTools);
363
424
  const r = json(await harness.callTool('gog_calendar_respond', { calendarId: 'primary', eventId: 'e1', status: 'accepted' }));
@@ -372,7 +433,7 @@ describe('calendar', () => {
372
433
  });
373
434
 
374
435
  it('token fallback: phase 2 records the response', async () => {
375
- process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
436
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
376
437
  stubReads({ 'calendar event': EVENT() });
377
438
  const harness = await unprompted(registerCalendarTools);
378
439
  const args = { calendarId: 'primary', eventId: 'e1', status: 'tentative' as const };