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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +827 -315
- package/dist/lib.js +832 -281
- package/manifest.json +7 -7
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/dispatch-confirmation.ts +102 -115
- package/src/gmail-dispatch-guard.ts +28 -21
- package/src/lib.ts +6 -2
- package/src/send-confirm-token.ts +17 -155
- package/src/tools/api.ts +74 -8
- package/src/tools/appscript.ts +63 -2
- package/src/tools/calendar.ts +83 -13
- package/src/tools/chat.ts +35 -4
- package/src/tools/classroom.ts +254 -25
- package/src/tools/docs.ts +7 -1
- package/src/tools/drive.ts +82 -10
- package/src/tools/gmail.ts +37 -1
- package/src/tools/sheets.ts +16 -1
- package/src/tools/utils.ts +10 -4
- package/tests/gmail-dispatch-guard.test.ts +60 -25
- package/tests/send-confirm-token.test.ts +17 -197
- package/tests/tools/api.test.ts +130 -5
- package/tests/tools/appscript.test.ts +6 -2
- package/tests/tools/dispatch-gates-more.test.ts +447 -0
- package/tests/tools/dispatch-gates.test.ts +74 -13
- package/tests/tools/docs.test.ts +5 -6
- package/tests/tools/drive.test.ts +2 -5
- package/tests/tools/gmail-confirm-token.test.ts +13 -11
- package/tests/tools/run-vets-more.test.ts +269 -0
- package/tests/tools/run-vets.test.ts +158 -4
- package/tests/tools/sheets.test.ts +10 -11
- package/tests/tools/utils.test.ts +38 -41
|
@@ -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.
|
|
28
|
-
delete process.env.
|
|
29
|
-
delete process.env.
|
|
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('
|
|
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.
|
|
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('
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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: '
|
|
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.
|
|
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 };
|