gogcli-mcp 4.2.5 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +1340 -262
  4. package/dist/lib.js +1381 -260
  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/dispatch-confirmation.ts +277 -0
  14. package/src/file-roots.ts +66 -0
  15. package/src/gmail-dispatch-guard.ts +70 -30
  16. package/src/gmail-results.ts +21 -2
  17. package/src/lib.ts +15 -0
  18. package/src/run-path-guard.ts +118 -0
  19. package/src/runner.ts +86 -18
  20. package/src/send-confirm-token.ts +167 -0
  21. package/src/tools/api.ts +60 -7
  22. package/src/tools/appscript.ts +12 -8
  23. package/src/tools/auth.ts +13 -3
  24. package/src/tools/calendar.ts +178 -15
  25. package/src/tools/chat.ts +94 -18
  26. package/src/tools/classroom.ts +110 -28
  27. package/src/tools/contacts.ts +5 -3
  28. package/src/tools/docs.ts +8 -6
  29. package/src/tools/drive.ts +96 -20
  30. package/src/tools/gmail.ts +175 -24
  31. package/src/tools/sheets.ts +10 -8
  32. package/src/tools/slides.ts +14 -9
  33. package/src/tools/tasks.ts +7 -5
  34. package/src/tools/utils.ts +52 -6
  35. package/tests/arg-guard.test.ts +80 -0
  36. package/tests/attachment-root.test.ts +130 -0
  37. package/tests/attachments.test.ts +8 -0
  38. package/tests/blob-upload.test.ts +4 -3
  39. package/tests/file-roots.test.ts +101 -0
  40. package/tests/gmail-dispatch-guard.test.ts +270 -11
  41. package/tests/gmail-results.test.ts +35 -1
  42. package/tests/run-path-guard.test.ts +142 -0
  43. package/tests/runner.test.ts +136 -0
  44. package/tests/send-confirm-token.test.ts +200 -0
  45. package/tests/tools/api.test.ts +74 -8
  46. package/tests/tools/appscript.test.ts +34 -8
  47. package/tests/tools/auth.test.ts +44 -17
  48. package/tests/tools/calendar.test.ts +33 -28
  49. package/tests/tools/chat.test.ts +50 -21
  50. package/tests/tools/classroom.test.ts +43 -39
  51. package/tests/tools/contacts.test.ts +3 -2
  52. package/tests/tools/dispatch-gates.test.ts +396 -0
  53. package/tests/tools/docs.test.ts +44 -15
  54. package/tests/tools/drive.test.ts +110 -20
  55. package/tests/tools/gmail-confirm-token.test.ts +274 -0
  56. package/tests/tools/gmail.test.ts +227 -29
  57. package/tests/tools/run-tool-examples.test.ts +69 -0
  58. package/tests/tools/run-vets.test.ts +131 -0
  59. package/tests/tools/sheets.test.ts +16 -15
  60. package/tests/tools/slides.test.ts +47 -11
  61. package/tests/tools/tasks.test.ts +7 -6
  62. package/tests/tools/utils.test.ts +32 -31
  63. package/vitest.config.ts +5 -0
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { createTestHarness } from '@chrischall/mcp-utils/test';
3
+ import * as runner from '../../src/runner.js';
4
+ import { registerChatTools, vetChatRun } from '../../src/tools/chat.js';
5
+ import { registerDriveTools, vetDriveRun } from '../../src/tools/drive.js';
6
+ import { registerClassroomTools, vetClassroomRun } from '../../src/tools/classroom.js';
7
+ import { registerCalendarTools, vetCalendarRun } from '../../src/tools/calendar.js';
8
+ import { refusedApiCall } from '../../src/tools/api.js';
9
+
10
+ vi.mock('../../src/runner.js');
11
+
12
+ // ============================================================================
13
+ // The escape hatches must not be a way around the dispatch rail (#400): every
14
+ // action a dedicated tool asks the user about is refused by gog_<service>_run
15
+ // and gog_api_call, under every alias gog accepts for it.
16
+ // ============================================================================
17
+
18
+ beforeEach(() => vi.clearAllMocks());
19
+
20
+ describe('vetChatRun', () => {
21
+ it.each([
22
+ ['messages', ['send', 'spaces/A', '--text=hi']],
23
+ ['messages', ['create', 'spaces/A']],
24
+ ['messages', ['post', 'spaces/A']],
25
+ ['dm', ['send', 'a@example.com']],
26
+ ['DM', ['--color', 'never', 'POST', 'a@example.com']],
27
+ ])('refuses %s %j', (sub, args) => {
28
+ expect(vetChatRun(sub, args)).toMatch(/gog_chat_(messages|dm)_send.*asks the user/);
29
+ });
30
+
31
+ it.each([
32
+ ['messages', ['list', 'spaces/A']],
33
+ ['messages', ['react', 'spaces/A/messages/B', '--emoji=👍']],
34
+ ['dm', ['space', 'a@example.com']],
35
+ ['spaces', ['send']],
36
+ ['messages', ['search', '--query=send']],
37
+ ])('allows %s %j', (sub, args) => {
38
+ expect(vetChatRun(sub, args)).toBeUndefined();
39
+ });
40
+ });
41
+
42
+ describe('vetDriveRun', () => {
43
+ it.each(['share', 'SHARE'])('refuses %s', (sub) => {
44
+ expect(vetDriveRun(sub, ['f1', '--to=anyone'])).toMatch(/gog_drive_share.*asks the user/);
45
+ });
46
+
47
+ it.each(['permissions', 'unshare', 'copy'])('allows %s', (sub) => {
48
+ expect(vetDriveRun(sub, ['f1'])).toBeUndefined();
49
+ });
50
+ });
51
+
52
+ describe('vetClassroomRun', () => {
53
+ it.each([
54
+ ['announcements', ['create', 'c1', '--text=x']],
55
+ ['announcement', ['add', 'c1']],
56
+ ['ann', ['new', 'c1']],
57
+ ['invitations', ['create', 'c1', 'u1']],
58
+ ['invitation', ['add', 'c1', 'u1']],
59
+ ['invites', ['NEW', 'c1', 'u1']],
60
+ ])('refuses %s %j', (sub, args) => {
61
+ expect(vetClassroomRun(sub, args)).toMatch(/gog_classroom_(announcements|invitations)_create.*asks the user/);
62
+ });
63
+
64
+ it.each([
65
+ ['announcements', ['list', 'c1']],
66
+ ['invitations', ['accept', 'i1']],
67
+ ['guardians', ['create', 'c1']],
68
+ ['courses', ['create']],
69
+ ])('allows %s %j', (sub, args) => {
70
+ expect(vetClassroomRun(sub, args)).toBeUndefined();
71
+ });
72
+ });
73
+
74
+ describe('vetCalendarRun', () => {
75
+ it.each(['create', 'add', 'new', 'update', 'edit', 'set', 'respond', 'rsvp', 'reply', 'Create'])('refuses %s', (sub) => {
76
+ expect(vetCalendarRun(sub, ['primary'])).toMatch(/gog_calendar_(create|update|respond).*asks the user/);
77
+ });
78
+
79
+ it.each(['events', 'freebusy', 'calendars', 'create-calendar', 'focus-time'])('allows %s', (sub) => {
80
+ expect(vetCalendarRun(sub, [])).toBeUndefined();
81
+ });
82
+ });
83
+
84
+ describe('the vets are wired into the run tools', () => {
85
+ it.each([
86
+ [registerChatTools, 'gog_chat_run', { subcommand: 'messages', args: ['send', 'spaces/A', '--text=hi'] }],
87
+ [registerDriveTools, 'gog_drive_run', { subcommand: 'share', args: ['f1', '--to=anyone'] }],
88
+ [registerClassroomTools, 'gog_classroom_run', { subcommand: 'ann', args: ['create', 'c1', '--text=x'] }],
89
+ [registerCalendarTools, 'gog_calendar_run', { subcommand: 'rsvp', args: ['primary', 'e1', '--status=accepted'] }],
90
+ ] as const)('%#: %s refuses without spawning gog', async (register, tool, args) => {
91
+ const harness = await createTestHarness(register);
92
+ const result = await harness.callTool(tool, args);
93
+ expect(result.isError).toBe(true);
94
+ expect(result.content[0]!.text).toMatch(/asks the user to confirm/);
95
+ expect(runner.run).not.toHaveBeenCalled();
96
+ });
97
+ });
98
+
99
+ describe('refusedApiCall — the dispatch rail\'s other escape hatch', () => {
100
+ it.each([
101
+ ['chat', 'spaces.messages.create', 'gog_chat_messages_send'],
102
+ ['chat', 'chat.spaces.messages.create', 'gog_chat_messages_send'],
103
+ ['drive', 'permissions.create', 'gog_drive_share'],
104
+ ['drive', 'permissions.update', 'gog_drive_share'],
105
+ ['classroom', 'courses.announcements.create', 'gog_classroom_announcements_create'],
106
+ ['classroom', 'invitations.create', 'gog_classroom_invitations_create'],
107
+ ['calendar', 'events.insert', 'gog_calendar_create'],
108
+ ['Calendar', ' events.patch ', 'gog_calendar_update'],
109
+ ['calendar', 'events.update', 'gog_calendar_update'],
110
+ ['calendar', 'events.import', 'gog_calendar_create'],
111
+ ['calendar', 'events.quickAdd', 'gog_calendar_create'],
112
+ ])('refuses %s %s', (api, method, tool) => {
113
+ expect(refusedApiCall(api, method)).toContain(tool);
114
+ });
115
+
116
+ it.each([
117
+ ['chat', 'spaces.messages.list'],
118
+ ['drive', 'permissions.list'],
119
+ ['drive', 'permissions.delete'],
120
+ ['classroom', 'courses.announcements.list'],
121
+ ['calendar', 'events.list'],
122
+ ['calendar', 'events.delete'],
123
+ ['sheets', 'spreadsheets.values.update'],
124
+ ])('allows %s %s', (api, method) => {
125
+ expect(refusedApiCall(api, method)).toBeUndefined();
126
+ });
127
+
128
+ it('still refuses Gmail sends', () => {
129
+ expect(refusedApiCall('gmail', 'users.messages.send')).toMatch(/gog_gmail_send/);
130
+ });
131
+ });
@@ -3,6 +3,7 @@ import { McpServer } from '@modelcontextprotocol/server';
3
3
  import { registerSheetsTools } from '../../src/tools/sheets.js';
4
4
  import * as runner from '../../src/runner.js';
5
5
  import { createTestHarness } from '@chrischall/mcp-utils/test';
6
+ import { pos } from '../../src/argv.js';
6
7
 
7
8
  vi.mock('../../src/runner.js');
8
9
 
@@ -15,7 +16,7 @@ describe('gog_sheets_get', () => {
15
16
  vi.mocked(runner.run).mockResolvedValue('{"values":[["a","b"]]}');
16
17
  const harness = await setupHandlers();
17
18
  const result = await harness.callTool('gog_sheets_get', { spreadsheetId: 'sid', range: 'Sheet1!A1:B2' });
18
- expect(runner.run).toHaveBeenCalledWith(['sheets', 'get', 'sid', 'Sheet1!A1:B2'], { account: undefined });
19
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'get', pos('sid'), pos('Sheet1!A1:B2')], { account: undefined });
19
20
  expect(result.content[0].text).toBe('{"values":[["a","b"]]}');
20
21
  });
21
22
 
@@ -23,7 +24,7 @@ describe('gog_sheets_get', () => {
23
24
  vi.mocked(runner.run).mockResolvedValue('{}');
24
25
  const harness = await setupHandlers();
25
26
  await harness.callTool('gog_sheets_get', { spreadsheetId: 'sid', range: 'A1', account: 'other@gmail.com' });
26
- expect(runner.run).toHaveBeenCalledWith(['sheets', 'get', 'sid', 'A1'], { account: 'other@gmail.com' });
27
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'get', pos('sid'), pos('A1')], { account: 'other@gmail.com' });
27
28
  });
28
29
 
29
30
  it('appends auth list on failure when auth list succeeds', async () => {
@@ -59,7 +60,7 @@ describe('gog_sheets_update', () => {
59
60
  const values = [['hello', 'world']];
60
61
  await harness.callTool('gog_sheets_update', { spreadsheetId: 'sid', range: 'A1:B1', values });
61
62
  expect(runner.run).toHaveBeenCalledWith(
62
- ['sheets', 'update', 'sid', 'A1:B1', `--values-json=${JSON.stringify(values)}`],
63
+ ['sheets', 'update', pos('sid'), pos('A1:B1'), `--values-json=${JSON.stringify(values)}`],
63
64
  { account: undefined },
64
65
  );
65
66
  });
@@ -92,7 +93,7 @@ describe('gog_sheets_update', () => {
92
93
  const values = [['x']];
93
94
  await harness.callTool('gog_sheets_update', { spreadsheetId: 'sid', range: 'A1', values, dry_run: true });
94
95
  expect(runner.run).toHaveBeenCalledWith(
95
- ['sheets', 'update', 'sid', 'A1', `--values-json=${JSON.stringify(values)}`, '--dry-run'],
96
+ ['sheets', 'update', pos('sid'), pos('A1'), `--values-json=${JSON.stringify(values)}`, '--dry-run'],
96
97
  { account: undefined },
97
98
  );
98
99
  });
@@ -110,7 +111,7 @@ describe('gog_sheets_update', () => {
110
111
  const values = [['=1+1']];
111
112
  await harness.callTool('gog_sheets_update', { spreadsheetId: 'sid', range: 'A1', values, fail_on_formula_error: true });
112
113
  expect(runner.run).toHaveBeenCalledWith(
113
- ['sheets', 'update', 'sid', 'A1', `--values-json=${JSON.stringify(values)}`, '--fail-on-formula-error'],
114
+ ['sheets', 'update', pos('sid'), pos('A1'), `--values-json=${JSON.stringify(values)}`, '--fail-on-formula-error'],
114
115
  { account: undefined },
115
116
  );
116
117
  });
@@ -125,7 +126,7 @@ describe('gog_sheets_update fail_if_not_empty guard', () => {
125
126
  });
126
127
  // Only the read happened — no update call.
127
128
  expect(runner.run).toHaveBeenCalledTimes(1);
128
- expect(runner.run).toHaveBeenCalledWith(['sheets', 'get', 'sid', 'A1:A1'], { account: undefined });
129
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'get', pos('sid'), pos('A1:A1')], { account: undefined });
129
130
  expect(result.content[0].text).toContain('Write aborted');
130
131
  expect(result.content[0].text).toContain('A1:A1');
131
132
  expect(result.content[0].text).toContain('1 cell');
@@ -142,7 +143,7 @@ describe('gog_sheets_update fail_if_not_empty guard', () => {
142
143
  });
143
144
  expect(runner.run).toHaveBeenCalledTimes(2);
144
145
  expect(vi.mocked(runner.run).mock.calls[1]![0]).toEqual(
145
- ['sheets', 'update', 'sid', 'A1', `--values-json=${JSON.stringify(values)}`],
146
+ ['sheets', 'update', pos('sid'), pos('A1'), `--values-json=${JSON.stringify(values)}`],
146
147
  );
147
148
  expect(result.content[0].text).toBe('{"updatedCells":1}');
148
149
  });
@@ -156,7 +157,7 @@ describe('gog_sheets_update fail_if_not_empty guard', () => {
156
157
  spreadsheetId: 'sid', range: 'Sheet1!A1',
157
158
  values: [['a', 'b'], ['c', 'd'], ['e', 'f']], fail_if_not_empty: true,
158
159
  });
159
- expect(vi.mocked(runner.run).mock.calls[0]![0]).toEqual(['sheets', 'get', 'sid', 'Sheet1!A1:B3']);
160
+ expect(vi.mocked(runner.run).mock.calls[0]![0]).toEqual(['sheets', 'get', pos('sid'), pos('Sheet1!A1:B3')]);
160
161
  });
161
162
 
162
163
  it('aborts without writing and diagnoses the error when the verification read fails', async () => {
@@ -234,7 +235,7 @@ describe('gog_sheets_append', () => {
234
235
  const values = [['r1c1', 'r1c2'], ['r2c1', 'r2c2']];
235
236
  await harness.callTool('gog_sheets_append', { spreadsheetId: 'sid', range: 'Sheet1!A:B', values });
236
237
  expect(runner.run).toHaveBeenCalledWith(
237
- ['sheets', 'append', 'sid', 'Sheet1!A:B', `--values-json=${JSON.stringify(values)}`],
238
+ ['sheets', 'append', pos('sid'), pos('Sheet1!A:B'), `--values-json=${JSON.stringify(values)}`],
238
239
  { account: undefined },
239
240
  );
240
241
  });
@@ -252,7 +253,7 @@ describe('gog_sheets_append', () => {
252
253
  const values = [['x']];
253
254
  await harness.callTool('gog_sheets_append', { spreadsheetId: 'sid', range: 'Sheet1!A:A', values, dry_run: true });
254
255
  expect(runner.run).toHaveBeenCalledWith(
255
- ['sheets', 'append', 'sid', 'Sheet1!A:A', `--values-json=${JSON.stringify(values)}`, '--dry-run'],
256
+ ['sheets', 'append', pos('sid'), pos('Sheet1!A:A'), `--values-json=${JSON.stringify(values)}`, '--dry-run'],
256
257
  { account: undefined },
257
258
  );
258
259
  });
@@ -263,7 +264,7 @@ describe('gog_sheets_clear', () => {
263
264
  vi.mocked(runner.run).mockResolvedValue('{}');
264
265
  const harness = await setupHandlers();
265
266
  await harness.callTool('gog_sheets_clear', { spreadsheetId: 'sid', range: 'Sheet1!A1:Z100' });
266
- expect(runner.run).toHaveBeenCalledWith(['sheets', 'clear', 'sid', 'Sheet1!A1:Z100'], { account: undefined });
267
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'clear', pos('sid'), pos('Sheet1!A1:Z100')], { account: undefined });
267
268
  });
268
269
 
269
270
  it('returns error text on failure', async () => {
@@ -278,7 +279,7 @@ describe('gog_sheets_clear', () => {
278
279
  const harness = await setupHandlers();
279
280
  await harness.callTool('gog_sheets_clear', { spreadsheetId: 'sid', range: 'Sheet1!A1:Z100', dry_run: true });
280
281
  expect(runner.run).toHaveBeenCalledWith(
281
- ['sheets', 'clear', 'sid', 'Sheet1!A1:Z100', '--dry-run'],
282
+ ['sheets', 'clear', pos('sid'), pos('Sheet1!A1:Z100'), '--dry-run'],
282
283
  { account: undefined },
283
284
  );
284
285
  });
@@ -300,7 +301,7 @@ describe('gog_sheets_metadata', () => {
300
301
  vi.mocked(runner.run).mockResolvedValue('{"title":"My Sheet","sheets":[{"title":"Sheet1"}]}');
301
302
  const harness = await setupHandlers();
302
303
  const result = await harness.callTool('gog_sheets_metadata', { spreadsheetId: 'sid' });
303
- expect(runner.run).toHaveBeenCalledWith(['sheets', 'metadata', 'sid'], { account: undefined });
304
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'metadata', pos('sid')], { account: undefined });
304
305
  expect(result.content[0].text).toContain('My Sheet');
305
306
  });
306
307
 
@@ -317,7 +318,7 @@ describe('gog_sheets_create', () => {
317
318
  vi.mocked(runner.run).mockResolvedValue('{"spreadsheetId":"newid","title":"Budget 2026"}');
318
319
  const harness = await setupHandlers();
319
320
  await harness.callTool('gog_sheets_create', { title: 'Budget 2026' });
320
- expect(runner.run).toHaveBeenCalledWith(['sheets', 'create', 'Budget 2026'], { account: undefined });
321
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'create', pos('Budget 2026')], { account: undefined });
321
322
  });
322
323
 
323
324
  it('returns error text on failure', async () => {
@@ -333,7 +334,7 @@ describe('gog_sheets_find_replace', () => {
333
334
  vi.mocked(runner.run).mockResolvedValue('{"occurrencesChanged":3}');
334
335
  const harness = await setupHandlers();
335
336
  await harness.callTool('gog_sheets_find_replace', { spreadsheetId: 'sid', find: 'foo', replace: 'bar' });
336
- expect(runner.run).toHaveBeenCalledWith(['sheets', 'find-replace', 'sid', 'foo', 'bar'], { account: undefined });
337
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'find-replace', pos('sid'), pos('foo'), pos('bar')], { account: undefined });
337
338
  });
338
339
 
339
340
  it('returns error text on failure', async () => {
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { registerSlidesTools } from '../../src/tools/slides.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_slides_export', () => {
14
15
  vi.mocked(runner.run).mockResolvedValue('{}');
15
16
  const harness = await setupHandlers();
16
17
  await harness.callTool('gog_slides_export', { presentationId: 'p1' });
17
- expect(runner.run).toHaveBeenCalledWith(['slides', 'export', 'p1'], { account: undefined });
18
+ expect(runner.run).toHaveBeenCalledWith(['slides', 'export', pos('p1')], { account: undefined });
18
19
  });
19
20
 
20
21
  it('passes --out and --format when provided', async () => {
@@ -22,7 +23,7 @@ describe('gog_slides_export', () => {
22
23
  const harness = await setupHandlers();
23
24
  await harness.callTool('gog_slides_export', { presentationId: 'p1', out: '/tmp/deck.pdf', format: 'pdf' });
24
25
  expect(runner.run).toHaveBeenCalledWith(
25
- ['slides', 'export', 'p1', '--out=/tmp/deck.pdf', '--format=pdf'],
26
+ ['slides', 'export', pos('p1'), '--out=/tmp/deck.pdf', '--format=pdf'],
26
27
  { account: undefined },
27
28
  );
28
29
  });
@@ -32,7 +33,7 @@ describe('gog_slides_export', () => {
32
33
  const harness = await setupHandlers();
33
34
  await harness.callTool('gog_slides_export', { presentationId: 'p1', out: '/tmp/deck.pptx', overwrite: true });
34
35
  expect(runner.run).toHaveBeenCalledWith(
35
- ['slides', 'export', 'p1', '--out=/tmp/deck.pptx', '--overwrite'],
36
+ ['slides', 'export', pos('p1'), '--out=/tmp/deck.pptx', '--overwrite'],
36
37
  { account: undefined },
37
38
  );
38
39
  });
@@ -50,7 +51,7 @@ describe('gog_slides_info', () => {
50
51
  vi.mocked(runner.run).mockResolvedValue('{}');
51
52
  const harness = await setupHandlers();
52
53
  await harness.callTool('gog_slides_info', { presentationId: 'p1' });
53
- expect(runner.run).toHaveBeenCalledWith(['slides', 'info', 'p1'], { account: undefined });
54
+ expect(runner.run).toHaveBeenCalledWith(['slides', 'info', pos('p1')], { account: undefined });
54
55
  });
55
56
 
56
57
  it('returns error text on failure', async () => {
@@ -66,7 +67,7 @@ describe('gog_slides_create', () => {
66
67
  vi.mocked(runner.run).mockResolvedValue('{}');
67
68
  const harness = await setupHandlers();
68
69
  await harness.callTool('gog_slides_create', { title: 'Deck' });
69
- expect(runner.run).toHaveBeenCalledWith(['slides', 'create', 'Deck'], { account: undefined });
70
+ expect(runner.run).toHaveBeenCalledWith(['slides', 'create', pos('Deck')], { account: undefined });
70
71
  });
71
72
 
72
73
  it('passes --parent and --template when provided', async () => {
@@ -74,7 +75,7 @@ describe('gog_slides_create', () => {
74
75
  const harness = await setupHandlers();
75
76
  await harness.callTool('gog_slides_create', { title: 'Deck', parent: 'folder1', template: 'tpl1' });
76
77
  expect(runner.run).toHaveBeenCalledWith(
77
- ['slides', 'create', 'Deck', '--parent=folder1', '--template=tpl1'],
78
+ ['slides', 'create', pos('Deck'), '--parent=folder1', '--template=tpl1'],
78
79
  { account: undefined },
79
80
  );
80
81
  });
@@ -93,7 +94,7 @@ describe('gog_slides_copy', () => {
93
94
  const harness = await setupHandlers();
94
95
  await harness.callTool('gog_slides_copy', { presentationId: 'p1', title: 'Copy' });
95
96
  expect(runner.run).toHaveBeenCalledWith(
96
- ['slides', 'copy', 'p1', 'Copy'],
97
+ ['slides', 'copy', pos('p1'), pos('Copy')],
97
98
  { account: undefined },
98
99
  );
99
100
  });
@@ -103,7 +104,7 @@ describe('gog_slides_copy', () => {
103
104
  const harness = await setupHandlers();
104
105
  await harness.callTool('gog_slides_copy', { presentationId: 'p1', title: 'Copy', parent: 'folder1' });
105
106
  expect(runner.run).toHaveBeenCalledWith(
106
- ['slides', 'copy', 'p1', 'Copy', '--parent=folder1'],
107
+ ['slides', 'copy', pos('p1'), pos('Copy'), '--parent=folder1'],
107
108
  { account: undefined },
108
109
  );
109
110
  });
@@ -121,7 +122,7 @@ describe('gog_slides_list_slides', () => {
121
122
  vi.mocked(runner.run).mockResolvedValue('{}');
122
123
  const harness = await setupHandlers();
123
124
  await harness.callTool('gog_slides_list_slides', { presentationId: 'p1' });
124
- expect(runner.run).toHaveBeenCalledWith(['slides', 'list-slides', 'p1'], { account: undefined });
125
+ expect(runner.run).toHaveBeenCalledWith(['slides', 'list-slides', pos('p1')], { account: undefined });
125
126
  });
126
127
 
127
128
  it('returns error text on failure', async () => {
@@ -138,7 +139,7 @@ describe('gog_slides_read_slide', () => {
138
139
  const harness = await setupHandlers();
139
140
  await harness.callTool('gog_slides_read_slide', { presentationId: 'p1', slideId: 's1' });
140
141
  expect(runner.run).toHaveBeenCalledWith(
141
- ['slides', 'read-slide', 'p1', 's1'],
142
+ ['slides', 'read-slide', pos('p1'), pos('s1')],
142
143
  { account: undefined },
143
144
  );
144
145
  });
@@ -148,7 +149,7 @@ describe('gog_slides_read_slide', () => {
148
149
  const harness = await setupHandlers();
149
150
  await harness.callTool('gog_slides_read_slide', { presentationId: 'p1', slideId: 's1', detail: true });
150
151
  expect(runner.run).toHaveBeenCalledWith(
151
- ['slides', 'read-slide', 'p1', 's1', '--detail'],
152
+ ['slides', 'read-slide', pos('p1'), pos('s1'), '--detail'],
152
153
  { account: undefined },
153
154
  );
154
155
  });
@@ -176,3 +177,38 @@ describe('gog_slides_run', () => {
176
177
  expect(result.content[0].text).toBe('Error: Run failed');
177
178
  });
178
179
  });
180
+
181
+ // SEC-3/SEC-4: every model-supplied server path must resolve inside an
182
+ // operator-configured root (GOG_FILE_ROOTS). The suite runs with '/' so the
183
+ // arg-shape tests can use any path; these narrow it.
184
+ async function withFileRoots<T>(roots: string, fn: () => Promise<T>): Promise<T> {
185
+ const prev = process.env.GOG_FILE_ROOTS;
186
+ process.env.GOG_FILE_ROOTS = roots;
187
+ try {
188
+ return await fn();
189
+ } finally {
190
+ process.env.GOG_FILE_ROOTS = prev;
191
+ }
192
+ }
193
+
194
+ describe('gog_slides_export — writes to disk', () => {
195
+ it('refuses an out path outside GOG_FILE_ROOTS', async () => {
196
+ const harness = await setupHandlers();
197
+ const result = await withFileRoots('/srv/gog-files', () => harness.callTool('gog_slides_export', {
198
+ presentationId: 'p1', out: '/Users/me/.zshrc', overwrite: true,
199
+ }));
200
+ expect(result.isError).toBe(true);
201
+ expect(result.content[0].text).toContain('out "/Users/me/.zshrc" is outside');
202
+ expect(runner.run).not.toHaveBeenCalled();
203
+ });
204
+
205
+ // SEC-4: clients auto-approve readOnlyHint tools; this one writes a file and
206
+ // can overwrite one, so the client must be allowed to ask.
207
+ it('is not advertised as read-only, and is marked destructive', async () => {
208
+ const harness = await setupHandlers();
209
+ const { tools } = await harness.client.listTools();
210
+ const tool = tools.find((t) => t.name === 'gog_slides_export')!;
211
+ expect(tool.annotations?.readOnlyHint).not.toBe(true);
212
+ expect(tool.annotations?.destructiveHint).toBe(true);
213
+ });
214
+ });
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { registerTasksTools } from '../../src/tools/tasks.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
 
@@ -30,7 +31,7 @@ describe('gog_tasks_list', () => {
30
31
  vi.mocked(runner.run).mockResolvedValue('{"items":[]}');
31
32
  const harness = await setupHandlers();
32
33
  await harness.callTool('gog_tasks_list', { tasklistId: 'list1' });
33
- expect(runner.run).toHaveBeenCalledWith(['tasks', 'list', 'list1'], { account: undefined });
34
+ expect(runner.run).toHaveBeenCalledWith(['tasks', 'list', pos('list1')], { account: undefined });
34
35
  });
35
36
 
36
37
  it('returns error text on failure', async () => {
@@ -46,7 +47,7 @@ describe('gog_tasks_get', () => {
46
47
  vi.mocked(runner.run).mockResolvedValue('{"id":"task1"}');
47
48
  const harness = await setupHandlers();
48
49
  await harness.callTool('gog_tasks_get', { tasklistId: 'list1', taskId: 'task1' });
49
- expect(runner.run).toHaveBeenCalledWith(['tasks', 'get', 'list1', 'task1'], { account: undefined });
50
+ expect(runner.run).toHaveBeenCalledWith(['tasks', 'get', pos('list1'), pos('task1')], { account: undefined });
50
51
  });
51
52
 
52
53
  it('returns error text on failure', async () => {
@@ -63,7 +64,7 @@ describe('gog_tasks_add', () => {
63
64
  const harness = await setupHandlers();
64
65
  await harness.callTool('gog_tasks_add', { tasklistId: 'list1', title: 'Buy milk' });
65
66
  expect(runner.run).toHaveBeenCalledWith(
66
- ['tasks', 'add', 'list1', '--title=Buy milk'],
67
+ ['tasks', 'add', pos('list1'), '--title=Buy milk'],
67
68
  { account: undefined },
68
69
  );
69
70
  });
@@ -73,7 +74,7 @@ describe('gog_tasks_add', () => {
73
74
  const harness = await setupHandlers();
74
75
  await harness.callTool('gog_tasks_add', { tasklistId: 'list1', title: 'Buy milk', notes: 'Whole milk', due: '2026-04-20' });
75
76
  expect(runner.run).toHaveBeenCalledWith(
76
- ['tasks', 'add', 'list1', '--title=Buy milk', '--notes=Whole milk', '--due=2026-04-20'],
77
+ ['tasks', 'add', pos('list1'), '--title=Buy milk', '--notes=Whole milk', '--due=2026-04-20'],
77
78
  { account: undefined },
78
79
  );
79
80
  });
@@ -91,7 +92,7 @@ describe('gog_tasks_done', () => {
91
92
  vi.mocked(runner.run).mockResolvedValue('{}');
92
93
  const harness = await setupHandlers();
93
94
  await harness.callTool('gog_tasks_done', { tasklistId: 'list1', taskId: 'task1' });
94
- expect(runner.run).toHaveBeenCalledWith(['tasks', 'done', 'list1', 'task1'], { account: undefined });
95
+ expect(runner.run).toHaveBeenCalledWith(['tasks', 'done', pos('list1'), pos('task1')], { account: undefined });
95
96
  });
96
97
 
97
98
  it('returns error text on failure', async () => {
@@ -107,7 +108,7 @@ describe('gog_tasks_delete', () => {
107
108
  vi.mocked(runner.run).mockResolvedValue('{}');
108
109
  const harness = await setupHandlers();
109
110
  await harness.callTool('gog_tasks_delete', { tasklistId: 'list1', taskId: 'task1' });
110
- expect(runner.run).toHaveBeenCalledWith(['tasks', 'delete', 'list1', 'task1', '--force'], { account: undefined });
111
+ expect(runner.run).toHaveBeenCalledWith(['tasks', 'delete', pos('list1'), pos('task1'), '--force'], { account: undefined });
111
112
  });
112
113
 
113
114
  it('returns error text on failure', async () => {