gogcli-mcp 4.2.5 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +714 -216
  4. package/dist/lib.js +750 -216
  5. package/manifest.json +9 -2
  6. package/package.json +2 -2
  7. package/server.json +2 -2
  8. package/src/arg-guard.ts +68 -0
  9. package/src/argv.ts +27 -0
  10. package/src/attachment-root.ts +111 -0
  11. package/src/attachments.ts +1 -0
  12. package/src/blob-upload.ts +4 -2
  13. package/src/file-roots.ts +66 -0
  14. package/src/gmail-dispatch-guard.ts +56 -16
  15. package/src/gmail-results.ts +21 -2
  16. package/src/lib.ts +6 -0
  17. package/src/run-path-guard.ts +118 -0
  18. package/src/runner.ts +86 -18
  19. package/src/tools/api.ts +39 -7
  20. package/src/tools/appscript.ts +12 -8
  21. package/src/tools/auth.ts +13 -3
  22. package/src/tools/calendar.ts +10 -8
  23. package/src/tools/chat.ts +16 -13
  24. package/src/tools/classroom.ts +27 -25
  25. package/src/tools/contacts.ts +5 -3
  26. package/src/tools/docs.ts +8 -6
  27. package/src/tools/drive.ts +42 -18
  28. package/src/tools/gmail.ts +71 -11
  29. package/src/tools/sheets.ts +10 -8
  30. package/src/tools/slides.ts +14 -9
  31. package/src/tools/tasks.ts +7 -5
  32. package/src/tools/utils.ts +52 -6
  33. package/tests/arg-guard.test.ts +80 -0
  34. package/tests/attachment-root.test.ts +130 -0
  35. package/tests/attachments.test.ts +8 -0
  36. package/tests/blob-upload.test.ts +4 -3
  37. package/tests/file-roots.test.ts +101 -0
  38. package/tests/gmail-dispatch-guard.test.ts +46 -0
  39. package/tests/gmail-results.test.ts +35 -1
  40. package/tests/run-path-guard.test.ts +142 -0
  41. package/tests/runner.test.ts +136 -0
  42. package/tests/tools/api.test.ts +74 -8
  43. package/tests/tools/appscript.test.ts +34 -8
  44. package/tests/tools/auth.test.ts +44 -17
  45. package/tests/tools/calendar.test.ts +29 -27
  46. package/tests/tools/chat.test.ts +46 -20
  47. package/tests/tools/classroom.test.ts +39 -38
  48. package/tests/tools/contacts.test.ts +3 -2
  49. package/tests/tools/docs.test.ts +44 -15
  50. package/tests/tools/drive.test.ts +106 -19
  51. package/tests/tools/gmail.test.ts +227 -29
  52. package/tests/tools/run-tool-examples.test.ts +69 -0
  53. package/tests/tools/sheets.test.ts +16 -15
  54. package/tests/tools/slides.test.ts +47 -11
  55. package/tests/tools/tasks.test.ts +7 -6
  56. package/tests/tools/utils.test.ts +32 -31
  57. package/vitest.config.ts +5 -0
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { registerClassroomTools } from '../../src/tools/classroom.js';
3
3
  import * as runner from '../../src/runner.js';
4
4
  import { createTestHarness, type TestHarness } from '@chrischall/mcp-utils/test';
5
+ import { pos } from '../../src/argv.js';
5
6
 
6
7
  vi.mock('../../src/runner.js');
7
8
 
@@ -51,7 +52,7 @@ describe('gog_classroom_courses_list', () => {
51
52
  describe('gog_classroom_courses_get', () => {
52
53
  it('calls run with courseId', async () => {
53
54
  await harness.callTool('gog_classroom_courses_get', { courseId: 'c1' });
54
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'courses', 'get', 'c1'], { account: undefined });
55
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'courses', 'get', pos('c1')], { account: undefined });
55
56
  });
56
57
 
57
58
  it('returns error text on failure', async () => {
@@ -64,20 +65,20 @@ describe('gog_classroom_courses_get', () => {
64
65
  describe('gog_classroom_students_list', () => {
65
66
  it('calls run with courseId only', async () => {
66
67
  await harness.callTool('gog_classroom_students_list', { courseId: 'c1' });
67
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'students', 'list', 'c1'], { account: undefined });
68
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'students', 'list', pos('c1')], { account: undefined });
68
69
  });
69
70
 
70
71
  it('passes all listing flags', async () => {
71
72
  await harness.callTool('gog_classroom_students_list', { courseId: 'c1', max: 20, page: 'tok', all: true });
72
73
  expect(runner.run).toHaveBeenCalledWith(
73
- ['classroom', 'students', 'list', 'c1', '--max=20', '--page=tok', '--all'],
74
+ ['classroom', 'students', 'list', pos('c1'), '--max=20', '--page=tok', '--all'],
74
75
  { account: undefined },
75
76
  );
76
77
  });
77
78
 
78
79
  it('omits --all when false', async () => {
79
80
  await harness.callTool('gog_classroom_students_list', { courseId: 'c1', all: false });
80
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'students', 'list', 'c1'], { account: undefined });
81
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'students', 'list', pos('c1')], { account: undefined });
81
82
  });
82
83
 
83
84
  it('returns error text on failure', async () => {
@@ -90,7 +91,7 @@ describe('gog_classroom_students_list', () => {
90
91
  describe('gog_classroom_students_get', () => {
91
92
  it('calls run with courseId and userId', async () => {
92
93
  await harness.callTool('gog_classroom_students_get', { courseId: 'c1', userId: 'u1' });
93
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'students', 'get', 'c1', 'u1'], { account: undefined });
94
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'students', 'get', pos('c1'), pos('u1')], { account: undefined });
94
95
  });
95
96
 
96
97
  it('returns error text on failure', async () => {
@@ -103,20 +104,20 @@ describe('gog_classroom_students_get', () => {
103
104
  describe('gog_classroom_teachers_list', () => {
104
105
  it('calls run with courseId only', async () => {
105
106
  await harness.callTool('gog_classroom_teachers_list', { courseId: 'c1' });
106
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'teachers', 'list', 'c1'], { account: undefined });
107
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'teachers', 'list', pos('c1')], { account: undefined });
107
108
  });
108
109
 
109
110
  it('passes all listing flags', async () => {
110
111
  await harness.callTool('gog_classroom_teachers_list', { courseId: 'c1', max: 20, page: 'tok', all: true });
111
112
  expect(runner.run).toHaveBeenCalledWith(
112
- ['classroom', 'teachers', 'list', 'c1', '--max=20', '--page=tok', '--all'],
113
+ ['classroom', 'teachers', 'list', pos('c1'), '--max=20', '--page=tok', '--all'],
113
114
  { account: undefined },
114
115
  );
115
116
  });
116
117
 
117
118
  it('omits --all when false', async () => {
118
119
  await harness.callTool('gog_classroom_teachers_list', { courseId: 'c1', all: false });
119
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'teachers', 'list', 'c1'], { account: undefined });
120
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'teachers', 'list', pos('c1')], { account: undefined });
120
121
  });
121
122
 
122
123
  it('returns error text on failure', async () => {
@@ -129,7 +130,7 @@ describe('gog_classroom_teachers_list', () => {
129
130
  describe('gog_classroom_teachers_get', () => {
130
131
  it('calls run with courseId and userId', async () => {
131
132
  await harness.callTool('gog_classroom_teachers_get', { courseId: 'c1', userId: 'u1' });
132
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'teachers', 'get', 'c1', 'u1'], { account: undefined });
133
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'teachers', 'get', pos('c1'), pos('u1')], { account: undefined });
133
134
  });
134
135
 
135
136
  it('returns error text on failure', async () => {
@@ -142,20 +143,20 @@ describe('gog_classroom_teachers_get', () => {
142
143
  describe('gog_classroom_roster', () => {
143
144
  it('calls run with courseId only', async () => {
144
145
  await harness.callTool('gog_classroom_roster', { courseId: 'c1' });
145
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'roster', 'c1'], { account: undefined });
146
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'roster', pos('c1')], { account: undefined });
146
147
  });
147
148
 
148
149
  it('passes all flags', async () => {
149
150
  await harness.callTool('gog_classroom_roster', { courseId: 'c1', students: true, teachers: true, max: 50, page: 'tok', all: true });
150
151
  expect(runner.run).toHaveBeenCalledWith(
151
- ['classroom', 'roster', 'c1', '--students', '--teachers', '--max=50', '--page=tok', '--all'],
152
+ ['classroom', 'roster', pos('c1'), '--students', '--teachers', '--max=50', '--page=tok', '--all'],
152
153
  { account: undefined },
153
154
  );
154
155
  });
155
156
 
156
157
  it('omits boolean flags when false', async () => {
157
158
  await harness.callTool('gog_classroom_roster', { courseId: 'c1', students: false, teachers: false, all: false });
158
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'roster', 'c1'], { account: undefined });
159
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'roster', pos('c1')], { account: undefined });
159
160
  });
160
161
 
161
162
  it('returns error text on failure', async () => {
@@ -168,7 +169,7 @@ describe('gog_classroom_roster', () => {
168
169
  describe('gog_classroom_coursework_list', () => {
169
170
  it('calls run with courseId only', async () => {
170
171
  await harness.callTool('gog_classroom_coursework_list', { courseId: 'c1' });
171
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'coursework', 'list', 'c1'], { account: undefined });
172
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'coursework', 'list', pos('c1')], { account: undefined });
172
173
  });
173
174
 
174
175
  it('passes all listing flags', async () => {
@@ -183,14 +184,14 @@ describe('gog_classroom_coursework_list', () => {
183
184
  scanPages: 5,
184
185
  });
185
186
  expect(runner.run).toHaveBeenCalledWith(
186
- ['classroom', 'coursework', 'list', 'c1', '--state=PUBLISHED', '--topic=t1', '--order-by=updateTime desc', '--max=50', '--page=tok', '--all', '--scan-pages=5'],
187
+ ['classroom', 'coursework', 'list', pos('c1'), '--state=PUBLISHED', '--topic=t1', '--order-by=updateTime desc', '--max=50', '--page=tok', '--all', '--scan-pages=5'],
187
188
  { account: undefined },
188
189
  );
189
190
  });
190
191
 
191
192
  it('omits --all when false', async () => {
192
193
  await harness.callTool('gog_classroom_coursework_list', { courseId: 'c1', all: false });
193
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'coursework', 'list', 'c1'], { account: undefined });
194
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'coursework', 'list', pos('c1')], { account: undefined });
194
195
  });
195
196
 
196
197
  it('returns error text on failure', async () => {
@@ -203,7 +204,7 @@ describe('gog_classroom_coursework_list', () => {
203
204
  describe('gog_classroom_coursework_get', () => {
204
205
  it('calls run with courseId and courseworkId', async () => {
205
206
  await harness.callTool('gog_classroom_coursework_get', { courseId: 'c1', courseworkId: 'w1' });
206
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'coursework', 'get', 'c1', 'w1'], { account: undefined });
207
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'coursework', 'get', pos('c1'), pos('w1')], { account: undefined });
207
208
  });
208
209
 
209
210
  it('returns error text on failure', async () => {
@@ -216,7 +217,7 @@ describe('gog_classroom_coursework_get', () => {
216
217
  describe('gog_classroom_submissions_list', () => {
217
218
  it('calls run with ids only', async () => {
218
219
  await harness.callTool('gog_classroom_submissions_list', { courseId: 'c1', courseworkId: 'w1' });
219
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'submissions', 'list', 'c1', 'w1'], { account: undefined });
220
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'submissions', 'list', pos('c1'), pos('w1')], { account: undefined });
220
221
  });
221
222
 
222
223
  it('passes all listing flags', async () => {
@@ -231,14 +232,14 @@ describe('gog_classroom_submissions_list', () => {
231
232
  all: true,
232
233
  });
233
234
  expect(runner.run).toHaveBeenCalledWith(
234
- ['classroom', 'submissions', 'list', 'c1', 'w1', '--state=TURNED_IN', '--late=late', '--user=u1', '--max=20', '--page=tok', '--all'],
235
+ ['classroom', 'submissions', 'list', pos('c1'), pos('w1'), '--state=TURNED_IN', '--late=late', '--user=u1', '--max=20', '--page=tok', '--all'],
235
236
  { account: undefined },
236
237
  );
237
238
  });
238
239
 
239
240
  it('omits --all when false', async () => {
240
241
  await harness.callTool('gog_classroom_submissions_list', { courseId: 'c1', courseworkId: 'w1', all: false });
241
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'submissions', 'list', 'c1', 'w1'], { account: undefined });
242
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'submissions', 'list', pos('c1'), pos('w1')], { account: undefined });
242
243
  });
243
244
 
244
245
  it('returns error text on failure', async () => {
@@ -251,7 +252,7 @@ describe('gog_classroom_submissions_list', () => {
251
252
  describe('gog_classroom_submissions_get', () => {
252
253
  it('calls run with all three ids', async () => {
253
254
  await harness.callTool('gog_classroom_submissions_get', { courseId: 'c1', courseworkId: 'w1', submissionId: 's1' });
254
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'submissions', 'get', 'c1', 'w1', 's1'], { account: undefined });
255
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'submissions', 'get', pos('c1'), pos('w1'), pos('s1')], { account: undefined });
255
256
  });
256
257
 
257
258
  it('returns error text on failure', async () => {
@@ -264,13 +265,13 @@ describe('gog_classroom_submissions_get', () => {
264
265
  describe('gog_classroom_submissions_grade', () => {
265
266
  it('calls run with ids only', async () => {
266
267
  await harness.callTool('gog_classroom_submissions_grade', { courseId: 'c1', courseworkId: 'w1', submissionId: 's1' });
267
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'submissions', 'grade', 'c1', 'w1', 's1'], { account: undefined });
268
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'submissions', 'grade', pos('c1'), pos('w1'), pos('s1')], { account: undefined });
268
269
  });
269
270
 
270
271
  it('passes --draft and --assigned when provided', async () => {
271
272
  await harness.callTool('gog_classroom_submissions_grade', { courseId: 'c1', courseworkId: 'w1', submissionId: 's1', draft: '90', assigned: '95' });
272
273
  expect(runner.run).toHaveBeenCalledWith(
273
- ['classroom', 'submissions', 'grade', 'c1', 'w1', 's1', '--draft=90', '--assigned=95'],
274
+ ['classroom', 'submissions', 'grade', pos('c1'), pos('w1'), pos('s1'), '--draft=90', '--assigned=95'],
274
275
  { account: undefined },
275
276
  );
276
277
  });
@@ -285,7 +286,7 @@ describe('gog_classroom_submissions_grade', () => {
285
286
  describe('gog_classroom_submissions_return', () => {
286
287
  it('calls run with ids', async () => {
287
288
  await harness.callTool('gog_classroom_submissions_return', { courseId: 'c1', courseworkId: 'w1', submissionId: 's1' });
288
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'submissions', 'return', 'c1', 'w1', 's1'], { account: undefined });
289
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'submissions', 'return', pos('c1'), pos('w1'), pos('s1')], { account: undefined });
289
290
  });
290
291
 
291
292
  it('returns error text on failure', async () => {
@@ -298,7 +299,7 @@ describe('gog_classroom_submissions_return', () => {
298
299
  describe('gog_classroom_submissions_turn_in', () => {
299
300
  it('calls run with ids', async () => {
300
301
  await harness.callTool('gog_classroom_submissions_turn_in', { courseId: 'c1', courseworkId: 'w1', submissionId: 's1' });
301
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'submissions', 'turn-in', 'c1', 'w1', 's1'], { account: undefined });
302
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'submissions', 'turn-in', pos('c1'), pos('w1'), pos('s1')], { account: undefined });
302
303
  });
303
304
 
304
305
  it('returns error text on failure', async () => {
@@ -311,7 +312,7 @@ describe('gog_classroom_submissions_turn_in', () => {
311
312
  describe('gog_classroom_submissions_reclaim', () => {
312
313
  it('calls run with ids', async () => {
313
314
  await harness.callTool('gog_classroom_submissions_reclaim', { courseId: 'c1', courseworkId: 'w1', submissionId: 's1' });
314
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'submissions', 'reclaim', 'c1', 'w1', 's1'], { account: undefined });
315
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'submissions', 'reclaim', pos('c1'), pos('w1'), pos('s1')], { account: undefined });
315
316
  });
316
317
 
317
318
  it('returns error text on failure', async () => {
@@ -324,20 +325,20 @@ describe('gog_classroom_submissions_reclaim', () => {
324
325
  describe('gog_classroom_announcements_list', () => {
325
326
  it('calls run with courseId only', async () => {
326
327
  await harness.callTool('gog_classroom_announcements_list', { courseId: 'c1' });
327
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'announcements', 'list', 'c1'], { account: undefined });
328
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'announcements', 'list', pos('c1')], { account: undefined });
328
329
  });
329
330
 
330
331
  it('passes all listing flags', async () => {
331
332
  await harness.callTool('gog_classroom_announcements_list', { courseId: 'c1', state: 'PUBLISHED', orderBy: 'updateTime desc', max: 20, page: 'tok', all: true });
332
333
  expect(runner.run).toHaveBeenCalledWith(
333
- ['classroom', 'announcements', 'list', 'c1', '--state=PUBLISHED', '--order-by=updateTime desc', '--max=20', '--page=tok', '--all'],
334
+ ['classroom', 'announcements', 'list', pos('c1'), '--state=PUBLISHED', '--order-by=updateTime desc', '--max=20', '--page=tok', '--all'],
334
335
  { account: undefined },
335
336
  );
336
337
  });
337
338
 
338
339
  it('omits --all when false', async () => {
339
340
  await harness.callTool('gog_classroom_announcements_list', { courseId: 'c1', all: false });
340
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'announcements', 'list', 'c1'], { account: undefined });
341
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'announcements', 'list', pos('c1')], { account: undefined });
341
342
  });
342
343
 
343
344
  it('returns error text on failure', async () => {
@@ -350,7 +351,7 @@ describe('gog_classroom_announcements_list', () => {
350
351
  describe('gog_classroom_announcements_get', () => {
351
352
  it('calls run with ids', async () => {
352
353
  await harness.callTool('gog_classroom_announcements_get', { courseId: 'c1', announcementId: 'a1' });
353
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'announcements', 'get', 'c1', 'a1'], { account: undefined });
354
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'announcements', 'get', pos('c1'), pos('a1')], { account: undefined });
354
355
  });
355
356
 
356
357
  it('returns error text on failure', async () => {
@@ -364,7 +365,7 @@ describe('gog_classroom_announcements_create', () => {
364
365
  it('calls run with required text only', async () => {
365
366
  await harness.callTool('gog_classroom_announcements_create', { courseId: 'c1', text: 'Hi' });
366
367
  expect(runner.run).toHaveBeenCalledWith(
367
- ['classroom', 'announcements', 'create', 'c1', '--text=Hi'],
368
+ ['classroom', 'announcements', 'create', pos('c1'), '--text=Hi'],
368
369
  { account: undefined },
369
370
  );
370
371
  });
@@ -372,7 +373,7 @@ describe('gog_classroom_announcements_create', () => {
372
373
  it('passes all optional flags', async () => {
373
374
  await harness.callTool('gog_classroom_announcements_create', { courseId: 'c1', text: 'Hi', state: 'DRAFT', scheduled: '2026-05-01T12:00' });
374
375
  expect(runner.run).toHaveBeenCalledWith(
375
- ['classroom', 'announcements', 'create', 'c1', '--text=Hi', '--state=DRAFT', '--scheduled=2026-05-01T12:00'],
376
+ ['classroom', 'announcements', 'create', pos('c1'), '--text=Hi', '--state=DRAFT', '--scheduled=2026-05-01T12:00'],
376
377
  { account: undefined },
377
378
  );
378
379
  });
@@ -387,20 +388,20 @@ describe('gog_classroom_announcements_create', () => {
387
388
  describe('gog_classroom_topics_list', () => {
388
389
  it('calls run with courseId only', async () => {
389
390
  await harness.callTool('gog_classroom_topics_list', { courseId: 'c1' });
390
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'topics', 'list', 'c1'], { account: undefined });
391
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'topics', 'list', pos('c1')], { account: undefined });
391
392
  });
392
393
 
393
394
  it('passes all listing flags', async () => {
394
395
  await harness.callTool('gog_classroom_topics_list', { courseId: 'c1', max: 20, page: 'tok', all: true });
395
396
  expect(runner.run).toHaveBeenCalledWith(
396
- ['classroom', 'topics', 'list', 'c1', '--max=20', '--page=tok', '--all'],
397
+ ['classroom', 'topics', 'list', pos('c1'), '--max=20', '--page=tok', '--all'],
397
398
  { account: undefined },
398
399
  );
399
400
  });
400
401
 
401
402
  it('omits --all when false', async () => {
402
403
  await harness.callTool('gog_classroom_topics_list', { courseId: 'c1', all: false });
403
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'topics', 'list', 'c1'], { account: undefined });
404
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'topics', 'list', pos('c1')], { account: undefined });
404
405
  });
405
406
 
406
407
  it('returns error text on failure', async () => {
@@ -413,7 +414,7 @@ describe('gog_classroom_topics_list', () => {
413
414
  describe('gog_classroom_topics_get', () => {
414
415
  it('calls run with ids', async () => {
415
416
  await harness.callTool('gog_classroom_topics_get', { courseId: 'c1', topicId: 't1' });
416
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'topics', 'get', 'c1', 't1'], { account: undefined });
417
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'topics', 'get', pos('c1'), pos('t1')], { account: undefined });
417
418
  });
418
419
 
419
420
  it('returns error text on failure', async () => {
@@ -452,7 +453,7 @@ describe('gog_classroom_invitations_list', () => {
452
453
  describe('gog_classroom_invitations_get', () => {
453
454
  it('calls run with invitationId', async () => {
454
455
  await harness.callTool('gog_classroom_invitations_get', { invitationId: 'i1' });
455
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'invitations', 'get', 'i1'], { account: undefined });
456
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'invitations', 'get', pos('i1')], { account: undefined });
456
457
  });
457
458
 
458
459
  it('returns error text on failure', async () => {
@@ -465,7 +466,7 @@ describe('gog_classroom_invitations_get', () => {
465
466
  describe('gog_classroom_invitations_accept', () => {
466
467
  it('calls run with invitationId', async () => {
467
468
  await harness.callTool('gog_classroom_invitations_accept', { invitationId: 'i1' });
468
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'invitations', 'accept', 'i1'], { account: undefined });
469
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'invitations', 'accept', pos('i1')], { account: undefined });
469
470
  });
470
471
 
471
472
  it('returns error text on failure', async () => {
@@ -483,7 +484,7 @@ describe('gog_classroom_profile_get', () => {
483
484
 
484
485
  it('passes userId when provided', async () => {
485
486
  await harness.callTool('gog_classroom_profile_get', { userId: 'u1' });
486
- expect(runner.run).toHaveBeenCalledWith(['classroom', 'profile', 'get', 'u1'], { account: undefined });
487
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'profile', 'get', pos('u1')], { account: undefined });
487
488
  });
488
489
 
489
490
  it('returns error text on failure', async () => {
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { registerContactsTools } from '../../src/tools/contacts.js';
3
3
  import * as runner from '../../src/runner.js';
4
4
  import { createTestHarness } from '@chrischall/mcp-utils/test';
5
+ import { pos } from '../../src/argv.js';
5
6
 
6
7
  vi.mock('../../src/runner.js');
7
8
 
@@ -14,7 +15,7 @@ describe('gog_contacts_search', () => {
14
15
  vi.mocked(runner.run).mockResolvedValue('{"connections":[]}');
15
16
  const harness = await setupHandlers();
16
17
  await harness.callTool('gog_contacts_search', { query: 'alice' });
17
- expect(runner.run).toHaveBeenCalledWith(['contacts', 'search', 'alice'], { account: undefined });
18
+ expect(runner.run).toHaveBeenCalledWith(['contacts', 'search', pos('alice')], { account: undefined });
18
19
  });
19
20
 
20
21
  it('returns error text on failure', async () => {
@@ -46,7 +47,7 @@ describe('gog_contacts_get', () => {
46
47
  vi.mocked(runner.run).mockResolvedValue('{"resourceName":"people/c123"}');
47
48
  const harness = await setupHandlers();
48
49
  await harness.callTool('gog_contacts_get', { resourceName: 'people/c123' });
49
- expect(runner.run).toHaveBeenCalledWith(['contacts', 'get', 'people/c123'], { account: undefined });
50
+ expect(runner.run).toHaveBeenCalledWith(['contacts', 'get', pos('people/c123')], { account: undefined });
50
51
  });
51
52
 
52
53
  it('returns error text on failure', async () => {
@@ -1,7 +1,8 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { registerDocsTools } from '../../src/tools/docs.js';
3
3
  import * as runner from '../../src/runner.js';
4
4
  import { createTestHarness } from '@chrischall/mcp-utils/test';
5
+ import { pos } from '../../src/argv.js';
5
6
 
6
7
  vi.mock('../../src/runner.js');
7
8
 
@@ -14,7 +15,7 @@ describe('gog_docs_info', () => {
14
15
  vi.mocked(runner.run).mockResolvedValue('{"title":"My Doc","docId":"abc"}');
15
16
  const harness = await setupHandlers();
16
17
  const result = await harness.callTool('gog_docs_info', { docId: 'abc' });
17
- expect(runner.run).toHaveBeenCalledWith(['docs', 'info', 'abc'], { account: undefined });
18
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'info', pos('abc')], { account: undefined });
18
19
  expect(result.content[0].text).toContain('My Doc');
19
20
  });
20
21
 
@@ -49,7 +50,7 @@ describe('gog_docs_cat', () => {
49
50
  vi.mocked(runner.run).mockResolvedValue('Hello world');
50
51
  const harness = await setupHandlers();
51
52
  const result = await harness.callTool('gog_docs_cat', { docId: 'abc' });
52
- expect(runner.run).toHaveBeenCalledWith(['docs', 'cat', 'abc'], { account: undefined });
53
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'cat', pos('abc')], { account: undefined });
53
54
  expect(result.content[0].text).toBe('Hello world');
54
55
  });
55
56
 
@@ -57,14 +58,14 @@ describe('gog_docs_cat', () => {
57
58
  vi.mocked(runner.run).mockResolvedValue('text');
58
59
  const harness = await setupHandlers();
59
60
  await harness.callTool('gog_docs_cat', { docId: 'abc', account: 'other@gmail.com' });
60
- expect(runner.run).toHaveBeenCalledWith(['docs', 'cat', 'abc'], { account: 'other@gmail.com' });
61
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'cat', pos('abc')], { account: 'other@gmail.com' });
61
62
  });
62
63
 
63
64
  it('appends --chips when requested', async () => {
64
65
  vi.mocked(runner.run).mockResolvedValue('Hello @person');
65
66
  const harness = await setupHandlers();
66
67
  await harness.callTool('gog_docs_cat', { docId: 'abc', chips: true });
67
- expect(runner.run).toHaveBeenCalledWith(['docs', 'cat', 'abc', '--chips'], { account: undefined });
68
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'cat', pos('abc'), '--chips'], { account: undefined });
68
69
  });
69
70
 
70
71
  it('returns error text on failure', async () => {
@@ -80,7 +81,7 @@ describe('gog_docs_create', () => {
80
81
  vi.mocked(runner.run).mockResolvedValue('{"docId":"newid","title":"Meeting Notes"}');
81
82
  const harness = await setupHandlers();
82
83
  await harness.callTool('gog_docs_create', { title: 'Meeting Notes' });
83
- expect(runner.run).toHaveBeenCalledWith(['docs', 'create', 'Meeting Notes'], { account: undefined });
84
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'create', pos('Meeting Notes')], { account: undefined });
84
85
  });
85
86
 
86
87
  it('returns error text on failure', async () => {
@@ -96,7 +97,7 @@ describe('gog_docs_write', () => {
96
97
  vi.mocked(runner.run).mockResolvedValue('{}');
97
98
  const harness = await setupHandlers();
98
99
  await harness.callTool('gog_docs_write', { docId: 'abc', text: 'Hello world' });
99
- expect(runner.run).toHaveBeenCalledWith(['docs', 'write', 'abc', '--text=Hello world'], { account: undefined });
100
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'write', pos('abc'), '--text=Hello world'], { account: undefined });
100
101
  });
101
102
 
102
103
  it('adds --append flag when append is true', async () => {
@@ -104,7 +105,7 @@ describe('gog_docs_write', () => {
104
105
  const harness = await setupHandlers();
105
106
  await harness.callTool('gog_docs_write', { docId: 'abc', text: 'More text', append: true });
106
107
  expect(runner.run).toHaveBeenCalledWith(
107
- ['docs', 'write', 'abc', '--text=More text', '--append'],
108
+ ['docs', 'write', pos('abc'), '--text=More text', '--append'],
108
109
  { account: undefined },
109
110
  );
110
111
  });
@@ -115,7 +116,7 @@ describe('gog_docs_write', () => {
115
116
  const harness = await setupHandlers();
116
117
  await harness.callTool('gog_docs_write', { docId: 'abc', text: 'T', batch: 'b1' });
117
118
  expect(runner.run).toHaveBeenCalledWith(
118
- ['docs', 'write', 'abc', '--text=T', '--batch=b1'],
119
+ ['docs', 'write', pos('abc'), '--text=T', '--batch=b1'],
119
120
  { account: undefined },
120
121
  );
121
122
  });
@@ -126,7 +127,7 @@ describe('gog_docs_write', () => {
126
127
  const harness = await setupHandlers();
127
128
  await harness.callTool('gog_docs_write', { docId: 'abc', text: 'Rewrite', checkOrphans: true });
128
129
  expect(runner.run).toHaveBeenCalledWith(
129
- ['docs', 'write', 'abc', '--text=Rewrite', '--check-orphans'],
130
+ ['docs', 'write', pos('abc'), '--text=Rewrite', '--check-orphans'],
130
131
  { account: undefined },
131
132
  );
132
133
  });
@@ -135,7 +136,7 @@ describe('gog_docs_write', () => {
135
136
  vi.mocked(runner.run).mockResolvedValue('{}');
136
137
  const harness = await setupHandlers();
137
138
  await harness.callTool('gog_docs_write', { docId: 'abc', text: 'text', append: false });
138
- expect(runner.run).toHaveBeenCalledWith(['docs', 'write', 'abc', '--text=text'], { account: undefined });
139
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'write', pos('abc'), '--text=text'], { account: undefined });
139
140
  });
140
141
 
141
142
  // gog 0.30.0 paragraph list / indentation / spacing / keep controls
@@ -148,7 +149,7 @@ describe('gog_docs_write', () => {
148
149
  keepLinesTogether: true, keepWithNext: true,
149
150
  });
150
151
  expect(runner.run).toHaveBeenCalledWith(
151
- ['docs', 'write', 'abc', '--text=List', '--bullets', '--bullet-preset=BULLET_DISC_CIRCLE_SQUARE',
152
+ ['docs', 'write', pos('abc'), '--text=List', '--bullets', '--bullet-preset=BULLET_DISC_CIRCLE_SQUARE',
152
153
  '--indent-start=18', '--indent-end=6', '--indent-first-line=36', '--space-above=4', '--space-below=8',
153
154
  '--keep-lines-together', '--keep-with-next'],
154
155
  { account: undefined },
@@ -163,7 +164,7 @@ describe('gog_docs_write', () => {
163
164
  keepLinesTogether: false, keepWithNext: false,
164
165
  });
165
166
  expect(runner.run).toHaveBeenCalledWith(
166
- ['docs', 'write', 'abc', '--text=Steps', '--ordered', '--no-bullets',
167
+ ['docs', 'write', pos('abc'), '--text=Steps', '--ordered', '--no-bullets',
167
168
  '--no-keep-lines-together', '--no-keep-with-next'],
168
169
  { account: undefined },
169
170
  );
@@ -182,7 +183,7 @@ describe('gog_docs_find_replace', () => {
182
183
  vi.mocked(runner.run).mockResolvedValue('{"occurrencesChanged":2}');
183
184
  const harness = await setupHandlers();
184
185
  await harness.callTool('gog_docs_find_replace', { docId: 'abc', find: 'foo', replace: 'bar' });
185
- expect(runner.run).toHaveBeenCalledWith(['docs', 'find-replace', 'abc', 'foo', 'bar'], { account: undefined });
186
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'find-replace', pos('abc'), pos('foo'), pos('bar')], { account: undefined });
186
187
  });
187
188
 
188
189
  it('returns error text on failure', async () => {
@@ -198,7 +199,7 @@ describe('gog_docs_structure', () => {
198
199
  vi.mocked(runner.run).mockResolvedValue('[1] Heading\n[2] Paragraph');
199
200
  const harness = await setupHandlers();
200
201
  const result = await harness.callTool('gog_docs_structure', { docId: 'abc' });
201
- expect(runner.run).toHaveBeenCalledWith(['docs', 'structure', 'abc'], { account: undefined });
202
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'structure', pos('abc')], { account: undefined });
202
203
  expect(result.content[0].text).toContain('Heading');
203
204
  });
204
205
 
@@ -210,6 +211,34 @@ describe('gog_docs_structure', () => {
210
211
  });
211
212
  });
212
213
 
214
+ // SEC-3/SEC-4 through the escape hatch: `export --out=~/.zshrc` writes
215
+ // attacker-shaped bytes over a shell profile. GOG_FILE_ROOTS binds it too.
216
+ describe('gog_docs_run path confinement', () => {
217
+ // The suite runs with GOG_FILE_ROOTS='/'; narrow it so confinement bites.
218
+ let prevRoots: string | undefined;
219
+ beforeEach(() => { prevRoots = process.env.GOG_FILE_ROOTS; process.env.GOG_FILE_ROOTS = '/nonexistent-gog-file-root'; });
220
+ afterEach(() => { process.env.GOG_FILE_ROOTS = prevRoots; });
221
+
222
+ it.each([
223
+ ['export', ['d1', '--out=~/.zshrc']],
224
+ ['export', ['d1', '--output', '~/.zshrc']],
225
+ ['insert', ['d1', '--file=/etc/passwd']],
226
+ ])('refuses docs %s %j', async (subcommand, args) => {
227
+ const harness = await setupHandlers();
228
+ const result = await harness.callTool('gog_docs_run', { subcommand, args });
229
+ expect(result.isError).toBe(true);
230
+ expect(result.content[0].text).toMatch(/outside the directories/);
231
+ expect(runner.run).not.toHaveBeenCalled();
232
+ });
233
+
234
+ it('refuses the -f short form, which would hide the path', async () => {
235
+ const harness = await setupHandlers();
236
+ const result = await harness.callTool('gog_docs_run', { subcommand: 'insert', args: ['d1', '-f', '/etc/passwd'] });
237
+ expect(result.isError).toBe(true);
238
+ expect(runner.run).not.toHaveBeenCalled();
239
+ });
240
+ });
241
+
213
242
  describe('gog_docs_run', () => {
214
243
  it('passes raw subcommand and args to runner', async () => {
215
244
  vi.mocked(runner.run).mockResolvedValue('{}');