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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +1340 -262
- package/dist/lib.js +1381 -260
- package/manifest.json +9 -2
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/arg-guard.ts +68 -0
- package/src/argv.ts +27 -0
- package/src/attachment-root.ts +111 -0
- package/src/attachments.ts +1 -0
- package/src/blob-upload.ts +4 -2
- package/src/dispatch-confirmation.ts +277 -0
- package/src/file-roots.ts +66 -0
- package/src/gmail-dispatch-guard.ts +70 -30
- package/src/gmail-results.ts +21 -2
- package/src/lib.ts +15 -0
- package/src/run-path-guard.ts +118 -0
- package/src/runner.ts +86 -18
- package/src/send-confirm-token.ts +167 -0
- package/src/tools/api.ts +60 -7
- package/src/tools/appscript.ts +12 -8
- package/src/tools/auth.ts +13 -3
- package/src/tools/calendar.ts +178 -15
- package/src/tools/chat.ts +94 -18
- package/src/tools/classroom.ts +110 -28
- package/src/tools/contacts.ts +5 -3
- package/src/tools/docs.ts +8 -6
- package/src/tools/drive.ts +96 -20
- package/src/tools/gmail.ts +175 -24
- package/src/tools/sheets.ts +10 -8
- package/src/tools/slides.ts +14 -9
- package/src/tools/tasks.ts +7 -5
- package/src/tools/utils.ts +52 -6
- package/tests/arg-guard.test.ts +80 -0
- package/tests/attachment-root.test.ts +130 -0
- package/tests/attachments.test.ts +8 -0
- package/tests/blob-upload.test.ts +4 -3
- package/tests/file-roots.test.ts +101 -0
- package/tests/gmail-dispatch-guard.test.ts +270 -11
- package/tests/gmail-results.test.ts +35 -1
- package/tests/run-path-guard.test.ts +142 -0
- package/tests/runner.test.ts +136 -0
- package/tests/send-confirm-token.test.ts +200 -0
- package/tests/tools/api.test.ts +74 -8
- package/tests/tools/appscript.test.ts +34 -8
- package/tests/tools/auth.test.ts +44 -17
- package/tests/tools/calendar.test.ts +33 -28
- package/tests/tools/chat.test.ts +50 -21
- package/tests/tools/classroom.test.ts +43 -39
- package/tests/tools/contacts.test.ts +3 -2
- package/tests/tools/dispatch-gates.test.ts +396 -0
- package/tests/tools/docs.test.ts +44 -15
- package/tests/tools/drive.test.ts +110 -20
- package/tests/tools/gmail-confirm-token.test.ts +274 -0
- package/tests/tools/gmail.test.ts +227 -29
- package/tests/tools/run-tool-examples.test.ts +69 -0
- package/tests/tools/run-vets.test.ts +131 -0
- package/tests/tools/sheets.test.ts +16 -15
- package/tests/tools/slides.test.ts +47 -11
- package/tests/tools/tasks.test.ts +7 -6
- package/tests/tools/utils.test.ts +32 -31
- 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 () => {
|