gogcli-mcp 2.8.0 → 2.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +20218 -19700
- package/dist/lib.js +15845 -23331
- package/manifest.json +33 -1
- package/package.json +7 -6
- package/server.json +2 -2
- package/src/connector-auth.ts +46 -0
- package/src/connector-runtime.ts +177 -0
- package/src/index.ts +7 -5
- package/src/lib.ts +8 -7
- package/src/runner.ts +225 -30
- package/src/server.ts +21 -25
- package/src/tools/api.ts +65 -0
- package/src/tools/auth.ts +112 -15
- package/src/tools/calendar.ts +24 -9
- package/src/tools/docs.ts +28 -3
- package/src/tools/drive.ts +124 -1
- package/src/tools/gmail.ts +7 -3
- package/src/tools/sheets.ts +6 -3
- package/src/tools/slides.ts +9 -4
- package/src/tools/tasks.ts +3 -1
- package/src/tools/utils.ts +163 -27
- package/src/worker.ts +99 -0
- package/tests/connector-auth.test.ts +28 -0
- package/tests/connector-runtime.test.ts +474 -0
- package/tests/runner-file-args-failure.test.ts +94 -0
- package/tests/runner-file-args.test.ts +232 -0
- package/tests/runner.test.ts +221 -13
- package/tests/server.test.ts +28 -28
- package/tests/tools/api.test.ts +107 -0
- package/tests/tools/auth.test.ts +187 -31
- package/tests/tools/calendar.test.ts +115 -52
- package/tests/tools/classroom.test.ts +77 -77
- package/tests/tools/contacts.test.ts +24 -24
- package/tests/tools/docs.test.ts +84 -46
- package/tests/tools/drive.test.ts +226 -55
- package/tests/tools/gmail.test.ts +61 -28
- package/tests/tools/sheets.test.ts +81 -70
- package/tests/tools/slides.test.ts +56 -36
- package/tests/tools/tasks.test.ts +33 -33
- package/tests/tools/utils.test.ts +116 -2
- package/tests/worker.test.ts +142 -0
- package/tsconfig.json +4 -1
- package/vitest.config.ts +33 -2
- package/tests/helpers/test-harness.ts +0 -27
|
@@ -1,33 +1,33 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { registerDriveTools } from '../../src/tools/drive.js';
|
|
3
3
|
import * as runner from '../../src/runner.js';
|
|
4
|
-
import {
|
|
4
|
+
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
5
5
|
|
|
6
6
|
vi.mock('../../src/runner.js');
|
|
7
7
|
|
|
8
|
-
const setupHandlers = () =>
|
|
8
|
+
const setupHandlers = () => createTestHarness(registerDriveTools);
|
|
9
9
|
|
|
10
10
|
beforeEach(() => vi.clearAllMocks());
|
|
11
11
|
|
|
12
12
|
describe('gog_drive_ls', () => {
|
|
13
13
|
it('calls run with no folderId', async () => {
|
|
14
14
|
vi.mocked(runner.run).mockResolvedValue('{"files":[]}');
|
|
15
|
-
const
|
|
16
|
-
await
|
|
15
|
+
const harness = await setupHandlers();
|
|
16
|
+
await harness.callTool('gog_drive_ls', {});
|
|
17
17
|
expect(runner.run).toHaveBeenCalledWith(['drive', 'ls'], { account: undefined });
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
it('passes folderId as --parent flag', async () => {
|
|
21
21
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
22
|
-
const
|
|
23
|
-
await
|
|
22
|
+
const harness = await setupHandlers();
|
|
23
|
+
await harness.callTool('gog_drive_ls', { folderId: 'folder1' });
|
|
24
24
|
expect(runner.run).toHaveBeenCalledWith(['drive', 'ls', '--parent=folder1'], { account: undefined });
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
it('supports max, page, query, and allDrives flags', async () => {
|
|
28
28
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
29
|
-
const
|
|
30
|
-
await
|
|
29
|
+
const harness = await setupHandlers();
|
|
30
|
+
await harness.callTool('gog_drive_ls', {
|
|
31
31
|
folderId: 'folder1',
|
|
32
32
|
max: 50,
|
|
33
33
|
page: 'tok',
|
|
@@ -41,22 +41,22 @@ describe('gog_drive_ls', () => {
|
|
|
41
41
|
|
|
42
42
|
it('passes --no-all-drives when allDrives is false', async () => {
|
|
43
43
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
44
|
-
const
|
|
45
|
-
await
|
|
44
|
+
const harness = await setupHandlers();
|
|
45
|
+
await harness.callTool('gog_drive_ls', { allDrives: false });
|
|
46
46
|
expect(runner.run).toHaveBeenCalledWith(['drive', 'ls', '--no-all-drives'], { account: undefined });
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
it('omits all-drives flag when allDrives is true (default)', async () => {
|
|
50
50
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
51
|
-
const
|
|
52
|
-
await
|
|
51
|
+
const harness = await setupHandlers();
|
|
52
|
+
await harness.callTool('gog_drive_ls', { allDrives: true });
|
|
53
53
|
expect(runner.run).toHaveBeenCalledWith(['drive', 'ls'], { account: undefined });
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
it('returns error text on failure', async () => {
|
|
57
57
|
vi.mocked(runner.run).mockRejectedValue(new Error('Ls failed'));
|
|
58
|
-
const
|
|
59
|
-
const result = await
|
|
58
|
+
const harness = await setupHandlers();
|
|
59
|
+
const result = await harness.callTool('gog_drive_ls', {});
|
|
60
60
|
expect(result.content[0].text).toBe('Error: Ls failed');
|
|
61
61
|
});
|
|
62
62
|
});
|
|
@@ -64,15 +64,15 @@ describe('gog_drive_ls', () => {
|
|
|
64
64
|
describe('gog_drive_search', () => {
|
|
65
65
|
it('calls run with query', async () => {
|
|
66
66
|
vi.mocked(runner.run).mockResolvedValue('{"files":[]}');
|
|
67
|
-
const
|
|
68
|
-
await
|
|
67
|
+
const harness = await setupHandlers();
|
|
68
|
+
await harness.callTool('gog_drive_search', { query: 'budget' });
|
|
69
69
|
expect(runner.run).toHaveBeenCalledWith(['drive', 'search', 'budget'], { account: undefined });
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
it('returns error text on failure', async () => {
|
|
73
73
|
vi.mocked(runner.run).mockRejectedValue(new Error('Search failed'));
|
|
74
|
-
const
|
|
75
|
-
const result = await
|
|
74
|
+
const harness = await setupHandlers();
|
|
75
|
+
const result = await harness.callTool('gog_drive_search', { query: 'x' });
|
|
76
76
|
expect(result.content[0].text).toBe('Error: Search failed');
|
|
77
77
|
});
|
|
78
78
|
});
|
|
@@ -80,15 +80,15 @@ describe('gog_drive_search', () => {
|
|
|
80
80
|
describe('gog_drive_get', () => {
|
|
81
81
|
it('calls run with fileId', async () => {
|
|
82
82
|
vi.mocked(runner.run).mockResolvedValue('{"id":"file1"}');
|
|
83
|
-
const
|
|
84
|
-
await
|
|
83
|
+
const harness = await setupHandlers();
|
|
84
|
+
await harness.callTool('gog_drive_get', { fileId: 'file1' });
|
|
85
85
|
expect(runner.run).toHaveBeenCalledWith(['drive', 'get', 'file1'], { account: undefined });
|
|
86
86
|
});
|
|
87
87
|
|
|
88
88
|
it('returns error text on failure', async () => {
|
|
89
89
|
vi.mocked(runner.run).mockRejectedValue(new Error('Not found'));
|
|
90
|
-
const
|
|
91
|
-
const result = await
|
|
90
|
+
const harness = await setupHandlers();
|
|
91
|
+
const result = await harness.callTool('gog_drive_get', { fileId: 'bad' });
|
|
92
92
|
expect(result.content[0].text).toBe('Error: Not found');
|
|
93
93
|
});
|
|
94
94
|
});
|
|
@@ -96,15 +96,15 @@ describe('gog_drive_get', () => {
|
|
|
96
96
|
describe('gog_drive_mkdir', () => {
|
|
97
97
|
it('calls run with folder name', async () => {
|
|
98
98
|
vi.mocked(runner.run).mockResolvedValue('{"id":"new-folder"}');
|
|
99
|
-
const
|
|
100
|
-
await
|
|
99
|
+
const harness = await setupHandlers();
|
|
100
|
+
await harness.callTool('gog_drive_mkdir', { name: 'Reports' });
|
|
101
101
|
expect(runner.run).toHaveBeenCalledWith(['drive', 'mkdir', 'Reports'], { account: undefined });
|
|
102
102
|
});
|
|
103
103
|
|
|
104
104
|
it('returns error text on failure', async () => {
|
|
105
105
|
vi.mocked(runner.run).mockRejectedValue(new Error('Mkdir failed'));
|
|
106
|
-
const
|
|
107
|
-
const result = await
|
|
106
|
+
const harness = await setupHandlers();
|
|
107
|
+
const result = await harness.callTool('gog_drive_mkdir', { name: 'Bad' });
|
|
108
108
|
expect(result.content[0].text).toBe('Error: Mkdir failed');
|
|
109
109
|
});
|
|
110
110
|
});
|
|
@@ -112,15 +112,15 @@ describe('gog_drive_mkdir', () => {
|
|
|
112
112
|
describe('gog_drive_rename', () => {
|
|
113
113
|
it('calls run with fileId and newName', async () => {
|
|
114
114
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
115
|
-
const
|
|
116
|
-
await
|
|
115
|
+
const harness = await setupHandlers();
|
|
116
|
+
await harness.callTool('gog_drive_rename', { fileId: 'file1', newName: 'Budget 2026' });
|
|
117
117
|
expect(runner.run).toHaveBeenCalledWith(['drive', 'rename', 'file1', 'Budget 2026'], { account: undefined });
|
|
118
118
|
});
|
|
119
119
|
|
|
120
120
|
it('returns error text on failure', async () => {
|
|
121
121
|
vi.mocked(runner.run).mockRejectedValue(new Error('Rename failed'));
|
|
122
|
-
const
|
|
123
|
-
const result = await
|
|
122
|
+
const harness = await setupHandlers();
|
|
123
|
+
const result = await harness.callTool('gog_drive_rename', { fileId: 'bad', newName: 'x' });
|
|
124
124
|
expect(result.content[0].text).toBe('Error: Rename failed');
|
|
125
125
|
});
|
|
126
126
|
});
|
|
@@ -128,15 +128,15 @@ describe('gog_drive_rename', () => {
|
|
|
128
128
|
describe('gog_drive_move', () => {
|
|
129
129
|
it('calls run with fileId and --parent flag', async () => {
|
|
130
130
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
131
|
-
const
|
|
132
|
-
await
|
|
131
|
+
const harness = await setupHandlers();
|
|
132
|
+
await harness.callTool('gog_drive_move', { fileId: 'file1', parentId: 'folder1' });
|
|
133
133
|
expect(runner.run).toHaveBeenCalledWith(['drive', 'move', 'file1', '--parent=folder1'], { account: undefined });
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
it('returns error text on failure', async () => {
|
|
137
137
|
vi.mocked(runner.run).mockRejectedValue(new Error('Move failed'));
|
|
138
|
-
const
|
|
139
|
-
const result = await
|
|
138
|
+
const harness = await setupHandlers();
|
|
139
|
+
const result = await harness.callTool('gog_drive_move', { fileId: 'f', parentId: 'p' });
|
|
140
140
|
expect(result.content[0].text).toBe('Error: Move failed');
|
|
141
141
|
});
|
|
142
142
|
});
|
|
@@ -144,32 +144,32 @@ describe('gog_drive_move', () => {
|
|
|
144
144
|
describe('gog_drive_delete', () => {
|
|
145
145
|
it('calls run with fileId (trash by default)', async () => {
|
|
146
146
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
147
|
-
const
|
|
148
|
-
await
|
|
149
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'file1'], { account: undefined });
|
|
147
|
+
const harness = await setupHandlers();
|
|
148
|
+
await harness.callTool('gog_drive_delete', { fileId: 'file1' });
|
|
149
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'file1', '--force'], { account: undefined });
|
|
150
150
|
});
|
|
151
151
|
|
|
152
152
|
it('appends --permanent when permanent=true', async () => {
|
|
153
153
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
154
|
-
const
|
|
155
|
-
await
|
|
154
|
+
const harness = await setupHandlers();
|
|
155
|
+
await harness.callTool('gog_drive_delete', { fileId: 'file1', permanent: true });
|
|
156
156
|
expect(runner.run).toHaveBeenCalledWith(
|
|
157
|
-
['drive', 'delete', 'file1', '--permanent'],
|
|
157
|
+
['drive', 'delete', 'file1', '--permanent', '--force'],
|
|
158
158
|
{ account: undefined },
|
|
159
159
|
);
|
|
160
160
|
});
|
|
161
161
|
|
|
162
162
|
it('omits --permanent when permanent=false', async () => {
|
|
163
163
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
164
|
-
const
|
|
165
|
-
await
|
|
166
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'file1'], { account: undefined });
|
|
164
|
+
const harness = await setupHandlers();
|
|
165
|
+
await harness.callTool('gog_drive_delete', { fileId: 'file1', permanent: false });
|
|
166
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'file1', '--force'], { account: undefined });
|
|
167
167
|
});
|
|
168
168
|
|
|
169
169
|
it('returns error text on failure', async () => {
|
|
170
170
|
vi.mocked(runner.run).mockRejectedValue(new Error('Delete failed'));
|
|
171
|
-
const
|
|
172
|
-
const result = await
|
|
171
|
+
const harness = await setupHandlers();
|
|
172
|
+
const result = await harness.callTool('gog_drive_delete', { fileId: 'bad' });
|
|
173
173
|
expect(result.content[0].text).toBe('Error: Delete failed');
|
|
174
174
|
});
|
|
175
175
|
});
|
|
@@ -177,8 +177,8 @@ describe('gog_drive_delete', () => {
|
|
|
177
177
|
describe('gog_drive_share', () => {
|
|
178
178
|
it('calls run with required args for user share', async () => {
|
|
179
179
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
180
|
-
const
|
|
181
|
-
await
|
|
180
|
+
const harness = await setupHandlers();
|
|
181
|
+
await harness.callTool('gog_drive_share', { fileId: 'file1', to: 'user', email: 'bob@example.com' });
|
|
182
182
|
expect(runner.run).toHaveBeenCalledWith(
|
|
183
183
|
['drive', 'share', 'file1', '--to=user', '--email=bob@example.com'],
|
|
184
184
|
{ account: undefined },
|
|
@@ -187,18 +187,28 @@ describe('gog_drive_share', () => {
|
|
|
187
187
|
|
|
188
188
|
it('appends domain and role when provided', async () => {
|
|
189
189
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
190
|
-
const
|
|
191
|
-
await
|
|
190
|
+
const harness = await setupHandlers();
|
|
191
|
+
await harness.callTool('gog_drive_share', { fileId: 'file1', to: 'domain', domain: 'example.com', role: 'writer' });
|
|
192
192
|
expect(runner.run).toHaveBeenCalledWith(
|
|
193
193
|
['drive', 'share', 'file1', '--to=domain', '--domain=example.com', '--role=writer'],
|
|
194
194
|
{ account: undefined },
|
|
195
195
|
);
|
|
196
196
|
});
|
|
197
197
|
|
|
198
|
+
it('appends --force for to=anyone (gog gates public sharing; runner injects --no-input)', async () => {
|
|
199
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
200
|
+
const harness = await setupHandlers();
|
|
201
|
+
await harness.callTool('gog_drive_share', { fileId: 'file1', to: 'anyone' });
|
|
202
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
203
|
+
['drive', 'share', 'file1', '--to=anyone', '--force'],
|
|
204
|
+
{ account: undefined },
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
198
208
|
it('returns error text on failure', async () => {
|
|
199
209
|
vi.mocked(runner.run).mockRejectedValue(new Error('Share failed'));
|
|
200
|
-
const
|
|
201
|
-
const result = await
|
|
210
|
+
const harness = await setupHandlers();
|
|
211
|
+
const result = await harness.callTool('gog_drive_share', { fileId: 'f', to: 'anyone' });
|
|
202
212
|
expect(result.content[0].text).toBe('Error: Share failed');
|
|
203
213
|
});
|
|
204
214
|
});
|
|
@@ -206,15 +216,176 @@ describe('gog_drive_share', () => {
|
|
|
206
216
|
describe('gog_drive_run', () => {
|
|
207
217
|
it('passes subcommand and args to runner', async () => {
|
|
208
218
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
209
|
-
const
|
|
210
|
-
await
|
|
219
|
+
const harness = await setupHandlers();
|
|
220
|
+
await harness.callTool('gog_drive_run', { subcommand: 'copy', args: ['file1', 'My Copy'] });
|
|
211
221
|
expect(runner.run).toHaveBeenCalledWith(['drive', 'copy', 'file1', 'My Copy'], { account: undefined });
|
|
212
222
|
});
|
|
213
223
|
|
|
214
224
|
it('returns error text on failure', async () => {
|
|
215
225
|
vi.mocked(runner.run).mockRejectedValue(new Error('Run failed'));
|
|
216
|
-
const
|
|
217
|
-
const result = await
|
|
226
|
+
const harness = await setupHandlers();
|
|
227
|
+
const result = await harness.callTool('gog_drive_run', { subcommand: 'copy', args: [] });
|
|
218
228
|
expect(result.content[0].text).toBe('Error: Run failed');
|
|
219
229
|
});
|
|
220
230
|
});
|
|
231
|
+
|
|
232
|
+
describe('gog_drive_extract_text', () => {
|
|
233
|
+
const PDF_META = JSON.stringify({ file: { name: 'Guest_Copy.pdf', mimeType: 'application/pdf' } });
|
|
234
|
+
const DOC_META = JSON.stringify({ file: { name: 'Notes', mimeType: 'application/vnd.google-apps.document' } });
|
|
235
|
+
|
|
236
|
+
it('OCR-converts a PDF, exports text, and deletes the temp doc', async () => {
|
|
237
|
+
vi.mocked(runner.run)
|
|
238
|
+
.mockResolvedValueOnce(PDF_META) // drive get
|
|
239
|
+
.mockResolvedValueOnce(JSON.stringify({ id: 'tmpDoc1' })) // files.copy
|
|
240
|
+
.mockResolvedValueOnce('UTOPIA OF THE SEAS itinerary') // files.export (with BOM)
|
|
241
|
+
.mockResolvedValueOnce('trashed'); // cleanup delete
|
|
242
|
+
const harness = await setupHandlers();
|
|
243
|
+
const result = await harness.callTool('gog_drive_extract_text', { fileId: 'pdf1' });
|
|
244
|
+
const j = JSON.parse(result.content[0].text as string);
|
|
245
|
+
expect(j).toMatchObject({ fileId: 'pdf1', name: 'Guest_Copy.pdf', mimeType: 'application/pdf', extractedVia: 'ocr-convert' });
|
|
246
|
+
expect(j.text).toBe('UTOPIA OF THE SEAS itinerary'); // BOM stripped
|
|
247
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
248
|
+
['api', 'call', 'drive', 'v3', 'files.copy', '--allow-write', '--force',
|
|
249
|
+
'--params={"fileId":"pdf1"}', '--body={"name":"gogcli-ocr-pdf1","mimeType":"application/vnd.google-apps.document"}'],
|
|
250
|
+
{ account: undefined },
|
|
251
|
+
);
|
|
252
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
253
|
+
['api', 'call', 'drive', 'v3', 'files.export', '--params={"fileId":"tmpDoc1","mimeType":"text/plain"}'],
|
|
254
|
+
{ account: undefined },
|
|
255
|
+
);
|
|
256
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'tmpDoc1', '--permanent', '--force'], { account: undefined });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('passes an ocrLanguage hint on the copy', async () => {
|
|
260
|
+
vi.mocked(runner.run)
|
|
261
|
+
.mockResolvedValueOnce(PDF_META)
|
|
262
|
+
.mockResolvedValueOnce(JSON.stringify({ result: { id: 'tmpDoc2' } })) // id nested under result
|
|
263
|
+
.mockResolvedValueOnce('text')
|
|
264
|
+
.mockResolvedValueOnce('ok');
|
|
265
|
+
const harness = await setupHandlers();
|
|
266
|
+
await harness.callTool('gog_drive_extract_text', { fileId: 'pdf1', ocrLanguage: 'fr' });
|
|
267
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
268
|
+
['api', 'call', 'drive', 'v3', 'files.copy', '--allow-write', '--force',
|
|
269
|
+
'--params={"fileId":"pdf1","ocrLanguage":"fr"}', '--body={"name":"gogcli-ocr-pdf1","mimeType":"application/vnd.google-apps.document"}'],
|
|
270
|
+
{ account: undefined },
|
|
271
|
+
);
|
|
272
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'tmpDoc2', '--permanent', '--force'], { account: undefined });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('exports a native Google Doc directly with no convert or temp cleanup', async () => {
|
|
276
|
+
vi.mocked(runner.run)
|
|
277
|
+
.mockResolvedValueOnce(DOC_META) // drive get
|
|
278
|
+
.mockResolvedValueOnce('doc body'); // files.export
|
|
279
|
+
const harness = await setupHandlers();
|
|
280
|
+
const result = await harness.callTool('gog_drive_extract_text', { fileId: 'doc1' });
|
|
281
|
+
const j = JSON.parse(result.content[0].text as string);
|
|
282
|
+
expect(j.extractedVia).toBe('native-export');
|
|
283
|
+
expect(j.text).toBe('doc body');
|
|
284
|
+
// no files.copy and no delete
|
|
285
|
+
const calls = vi.mocked(runner.run).mock.calls.map((c) => c[0]);
|
|
286
|
+
expect(calls.some((a) => (a as string[]).includes('files.copy'))).toBe(false);
|
|
287
|
+
expect(calls.some((a) => (a as string[])[1] === 'delete')).toBe(false);
|
|
288
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
289
|
+
['api', 'call', 'drive', 'v3', 'files.export', '--params={"fileId":"doc1","mimeType":"text/plain"}'],
|
|
290
|
+
{ account: undefined },
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('paginates with offset and maxChars', async () => {
|
|
295
|
+
vi.mocked(runner.run)
|
|
296
|
+
.mockResolvedValueOnce(DOC_META)
|
|
297
|
+
.mockResolvedValueOnce('0123456789');
|
|
298
|
+
const harness = await setupHandlers();
|
|
299
|
+
const result = await harness.callTool('gog_drive_extract_text', { fileId: 'doc1', offset: 3, maxChars: 4 });
|
|
300
|
+
const j = JSON.parse(result.content[0].text as string);
|
|
301
|
+
expect(j).toMatchObject({ totalChars: 10, offset: 3, returnedChars: 4, truncated: true, text: '3456' });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('still deletes the temp doc when extraction fails mid-flight', async () => {
|
|
305
|
+
vi.mocked(runner.run)
|
|
306
|
+
.mockResolvedValueOnce(PDF_META) // drive get
|
|
307
|
+
.mockResolvedValueOnce(JSON.stringify({ id: 'tmpDoc3' })) // files.copy
|
|
308
|
+
.mockRejectedValueOnce(new Error('export exploded')) // files.export fails
|
|
309
|
+
.mockResolvedValueOnce('user@x.com') // diagnose -> auth list
|
|
310
|
+
.mockResolvedValueOnce('deleted'); // cleanup delete
|
|
311
|
+
const harness = await setupHandlers();
|
|
312
|
+
const result = await harness.callTool('gog_drive_extract_text', { fileId: 'pdf1' });
|
|
313
|
+
expect(result.isError).toBe(true);
|
|
314
|
+
expect(result.content[0].text).toContain('export exploded');
|
|
315
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'tmpDoc3', '--permanent', '--force'], { account: undefined });
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('errors clearly when OCR conversion returns no id (and creates nothing to clean up)', async () => {
|
|
319
|
+
vi.mocked(runner.run)
|
|
320
|
+
.mockResolvedValueOnce(PDF_META) // drive get
|
|
321
|
+
.mockResolvedValueOnce('{}') // files.copy: no id
|
|
322
|
+
.mockResolvedValueOnce('user@x.com'); // diagnose -> auth list
|
|
323
|
+
const harness = await setupHandlers();
|
|
324
|
+
const result = await harness.callTool('gog_drive_extract_text', { fileId: 'pdf1' });
|
|
325
|
+
expect(result.isError).toBe(true);
|
|
326
|
+
expect(result.content[0].text).toContain('did not return a document id');
|
|
327
|
+
const calls = vi.mocked(runner.run).mock.calls.map((c) => c[0]);
|
|
328
|
+
expect(calls.some((a) => (a as string[])[1] === 'delete')).toBe(false);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('swallows a cleanup-delete failure and still returns the extracted text', async () => {
|
|
332
|
+
vi.mocked(runner.run)
|
|
333
|
+
.mockResolvedValueOnce(PDF_META) // drive get
|
|
334
|
+
.mockResolvedValueOnce(JSON.stringify({ id: 'tmpDoc4' })) // files.copy
|
|
335
|
+
.mockResolvedValueOnce('the text') // files.export
|
|
336
|
+
.mockRejectedValueOnce(new Error('delete failed')); // cleanup delete rejects
|
|
337
|
+
const harness = await setupHandlers();
|
|
338
|
+
const result = await harness.callTool('gog_drive_extract_text', { fileId: 'pdf1' });
|
|
339
|
+
expect(result.isError).toBeUndefined();
|
|
340
|
+
expect(JSON.parse(result.content[0].text as string).text).toBe('the text');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('threads the account through every step', async () => {
|
|
344
|
+
vi.mocked(runner.run)
|
|
345
|
+
.mockResolvedValueOnce(DOC_META)
|
|
346
|
+
.mockResolvedValueOnce('body');
|
|
347
|
+
const harness = await setupHandlers();
|
|
348
|
+
await harness.callTool('gog_drive_extract_text', { fileId: 'doc1', account: 'me@x.com' });
|
|
349
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'get', 'doc1'], { account: 'me@x.com' });
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe('gog_drive_read_bytes', () => {
|
|
354
|
+
it('returns file bytes as a base64 resource with a summary', async () => {
|
|
355
|
+
const blob = Buffer.from('%PDF-1.4 hello').toString('base64');
|
|
356
|
+
vi.mocked(runner.run).mockResolvedValueOnce(JSON.stringify({ file: { name: 'a.pdf', mimeType: 'application/pdf' } }));
|
|
357
|
+
vi.mocked(runner.runBinary).mockResolvedValueOnce(blob);
|
|
358
|
+
const harness = await setupHandlers();
|
|
359
|
+
const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f1' });
|
|
360
|
+
const res = result.content.find((c: { type: string }) => c.type === 'resource') as { resource: { blob: string; mimeType: string; uri: string } };
|
|
361
|
+
expect(res.resource.blob).toBe(blob);
|
|
362
|
+
expect(res.resource.mimeType).toBe('application/pdf');
|
|
363
|
+
expect(res.resource.uri).toBe('gogdrive://f1/a.pdf');
|
|
364
|
+
expect(result.content[0].text).toContain('a.pdf');
|
|
365
|
+
expect(runner.runBinary).toHaveBeenCalledWith(
|
|
366
|
+
['api', 'call', 'drive', 'v3', 'files.get', '--params={"fileId":"f1","alt":"media"}'],
|
|
367
|
+
{ account: undefined },
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('falls back to octet-stream and "file" when metadata lacks name/mime', async () => {
|
|
372
|
+
vi.mocked(runner.run).mockResolvedValueOnce('{}');
|
|
373
|
+
vi.mocked(runner.runBinary).mockResolvedValueOnce(Buffer.from('x').toString('base64'));
|
|
374
|
+
const harness = await setupHandlers();
|
|
375
|
+
const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f2' });
|
|
376
|
+
const res = result.content.find((c: { type: string }) => c.type === 'resource') as { resource: { mimeType: string; uri: string } };
|
|
377
|
+
expect(res.resource.mimeType).toBe('application/octet-stream');
|
|
378
|
+
expect(res.resource.uri).toBe('gogdrive://f2/file');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('surfaces the connector-degradation error via diagnose', async () => {
|
|
382
|
+
vi.mocked(runner.run)
|
|
383
|
+
.mockResolvedValueOnce(JSON.stringify({ file: { name: 'a.pdf', mimeType: 'application/pdf' } }))
|
|
384
|
+
.mockResolvedValueOnce('user@x.com'); // diagnose -> auth list
|
|
385
|
+
vi.mocked(runner.runBinary).mockRejectedValueOnce(new Error('Raw byte retrieval is not available over the hosted connector'));
|
|
386
|
+
const harness = await setupHandlers();
|
|
387
|
+
const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f1' });
|
|
388
|
+
expect(result.isError).toBe(true);
|
|
389
|
+
expect(result.content[0].text).toContain('not available over the hosted connector');
|
|
390
|
+
});
|
|
391
|
+
});
|
|
@@ -1,40 +1,41 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { registerGmailTools } from '../../src/tools/gmail.js';
|
|
3
3
|
import * as runner from '../../src/runner.js';
|
|
4
|
-
import {
|
|
4
|
+
import { PAYLOAD_INLINE_MAX } from '../../src/tools/utils.js';
|
|
5
|
+
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
5
6
|
|
|
6
7
|
vi.mock('../../src/runner.js');
|
|
7
8
|
|
|
8
|
-
const setupHandlers = () =>
|
|
9
|
+
const setupHandlers = () => createTestHarness(registerGmailTools);
|
|
9
10
|
|
|
10
11
|
beforeEach(() => vi.clearAllMocks());
|
|
11
12
|
|
|
12
13
|
describe('gog_gmail_search', () => {
|
|
13
14
|
it('calls run with query', async () => {
|
|
14
15
|
vi.mocked(runner.run).mockResolvedValue('{"threads":[]}');
|
|
15
|
-
const
|
|
16
|
-
await
|
|
16
|
+
const harness = await setupHandlers();
|
|
17
|
+
await harness.callTool('gog_gmail_search', { query: 'from:alice' });
|
|
17
18
|
expect(runner.run).toHaveBeenCalledWith(['gmail', 'search', 'from:alice'], { account: undefined });
|
|
18
19
|
});
|
|
19
20
|
|
|
20
21
|
it('appends --max flag when provided', async () => {
|
|
21
22
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
22
|
-
const
|
|
23
|
-
await
|
|
23
|
+
const harness = await setupHandlers();
|
|
24
|
+
await harness.callTool('gog_gmail_search', { query: 'is:unread', max: 5 });
|
|
24
25
|
expect(runner.run).toHaveBeenCalledWith(['gmail', 'search', 'is:unread', '--max=5'], { account: undefined });
|
|
25
26
|
});
|
|
26
27
|
|
|
27
28
|
it('returns error text on failure', async () => {
|
|
28
29
|
vi.mocked(runner.run).mockRejectedValue(new Error('Search failed'));
|
|
29
|
-
const
|
|
30
|
-
const result = await
|
|
30
|
+
const harness = await setupHandlers();
|
|
31
|
+
const result = await harness.callTool('gog_gmail_search', { query: 'test' });
|
|
31
32
|
expect(result.content[0].text).toBe('Error: Search failed');
|
|
32
33
|
});
|
|
33
34
|
|
|
34
35
|
it('appends --from-contact flag when provided', async () => {
|
|
35
36
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
36
|
-
const
|
|
37
|
-
await
|
|
37
|
+
const harness = await setupHandlers();
|
|
38
|
+
await harness.callTool('gog_gmail_search', { query: 'subject:invoice', fromContact: 'Alice' });
|
|
38
39
|
expect(runner.run).toHaveBeenCalledWith(['gmail', 'search', 'subject:invoice', '--from-contact=Alice'], { account: undefined });
|
|
39
40
|
});
|
|
40
41
|
});
|
|
@@ -42,22 +43,22 @@ describe('gog_gmail_search', () => {
|
|
|
42
43
|
describe('gog_gmail_get', () => {
|
|
43
44
|
it('calls run with message ID', async () => {
|
|
44
45
|
vi.mocked(runner.run).mockResolvedValue('{"id":"msg1"}');
|
|
45
|
-
const
|
|
46
|
-
await
|
|
46
|
+
const harness = await setupHandlers();
|
|
47
|
+
await harness.callTool('gog_gmail_get', { messageId: 'msg1' });
|
|
47
48
|
expect(runner.run).toHaveBeenCalledWith(['gmail', 'get', 'msg1'], { account: undefined });
|
|
48
49
|
});
|
|
49
50
|
|
|
50
51
|
it('appends --format flag when provided', async () => {
|
|
51
52
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
52
|
-
const
|
|
53
|
-
await
|
|
53
|
+
const harness = await setupHandlers();
|
|
54
|
+
await harness.callTool('gog_gmail_get', { messageId: 'msg1', format: 'metadata' });
|
|
54
55
|
expect(runner.run).toHaveBeenCalledWith(['gmail', 'get', 'msg1', '--format=metadata'], { account: undefined });
|
|
55
56
|
});
|
|
56
57
|
|
|
57
58
|
it('returns error text on failure', async () => {
|
|
58
59
|
vi.mocked(runner.run).mockRejectedValue(new Error('Not found'));
|
|
59
|
-
const
|
|
60
|
-
const result = await
|
|
60
|
+
const harness = await setupHandlers();
|
|
61
|
+
const result = await harness.callTool('gog_gmail_get', { messageId: 'bad' });
|
|
61
62
|
expect(result.content[0].text).toBe('Error: Not found');
|
|
62
63
|
});
|
|
63
64
|
});
|
|
@@ -65,8 +66,8 @@ describe('gog_gmail_get', () => {
|
|
|
65
66
|
describe('gog_gmail_send', () => {
|
|
66
67
|
it('calls run with required args', async () => {
|
|
67
68
|
vi.mocked(runner.run).mockResolvedValue('{"id":"msg2"}');
|
|
68
|
-
const
|
|
69
|
-
await
|
|
69
|
+
const harness = await setupHandlers();
|
|
70
|
+
await harness.callTool('gog_gmail_send', { to: 'bob@example.com', subject: 'Hi', body: 'Hello' });
|
|
70
71
|
expect(runner.run).toHaveBeenCalledWith(
|
|
71
72
|
['gmail', 'send', '--to=bob@example.com', '--subject=Hi', '--body=Hello'],
|
|
72
73
|
{ account: undefined },
|
|
@@ -75,8 +76,8 @@ describe('gog_gmail_send', () => {
|
|
|
75
76
|
|
|
76
77
|
it('appends optional flags when provided', async () => {
|
|
77
78
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
78
|
-
const
|
|
79
|
-
await
|
|
79
|
+
const harness = await setupHandlers();
|
|
80
|
+
await harness.callTool('gog_gmail_send', {
|
|
80
81
|
to: 'bob@example.com',
|
|
81
82
|
subject: 'Re: Hi',
|
|
82
83
|
body: 'Sure',
|
|
@@ -98,8 +99,8 @@ describe('gog_gmail_send', () => {
|
|
|
98
99
|
|
|
99
100
|
it('appends one --attach flag per file path', async () => {
|
|
100
101
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
101
|
-
const
|
|
102
|
-
await
|
|
102
|
+
const harness = await setupHandlers();
|
|
103
|
+
await harness.callTool('gog_gmail_send', {
|
|
103
104
|
to: 'bob@example.com',
|
|
104
105
|
subject: 'Evidence',
|
|
105
106
|
body: 'See attached',
|
|
@@ -115,10 +116,42 @@ describe('gog_gmail_send', () => {
|
|
|
115
116
|
);
|
|
116
117
|
});
|
|
117
118
|
|
|
119
|
+
// A body over the shared threshold cannot ride in argv (the hosted runner
|
|
120
|
+
// caps a single arg; Linux caps MAX_ARG_STRLEN at 128 KiB), so payloadArg
|
|
121
|
+
// swaps it for a file arg the executor materializes as a temp file.
|
|
122
|
+
it('routes an oversize body to --body-file, leaving the other flags inline', async () => {
|
|
123
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
124
|
+
const harness = await setupHandlers();
|
|
125
|
+
const big = 'x'.repeat(PAYLOAD_INLINE_MAX + 1);
|
|
126
|
+
await harness.callTool('gog_gmail_send', {
|
|
127
|
+
to: 'bob@example.com', subject: 'Long', body: big, cc: 'carol@example.com',
|
|
128
|
+
});
|
|
129
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
130
|
+
[
|
|
131
|
+
'gmail', 'send',
|
|
132
|
+
'--to=bob@example.com', '--subject=Long',
|
|
133
|
+
{ kind: 'file', flag: 'body-file', contents: big, ext: undefined },
|
|
134
|
+
'--cc=carol@example.com',
|
|
135
|
+
],
|
|
136
|
+
{ account: undefined },
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('keeps a body at exactly the threshold inline', async () => {
|
|
141
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
142
|
+
const harness = await setupHandlers();
|
|
143
|
+
const atLimit = 'x'.repeat(PAYLOAD_INLINE_MAX);
|
|
144
|
+
await harness.callTool('gog_gmail_send', { to: 'b@x.com', subject: 'S', body: atLimit });
|
|
145
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
146
|
+
['gmail', 'send', '--to=b@x.com', '--subject=S', `--body=${atLimit}`],
|
|
147
|
+
{ account: undefined },
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
118
151
|
it('returns error text on failure', async () => {
|
|
119
152
|
vi.mocked(runner.run).mockRejectedValue(new Error('Send failed'));
|
|
120
|
-
const
|
|
121
|
-
const result = await
|
|
153
|
+
const harness = await setupHandlers();
|
|
154
|
+
const result = await harness.callTool('gog_gmail_send', { to: 'x', subject: 'y', body: 'z' });
|
|
122
155
|
expect(result.content[0].text).toBe('Error: Send failed');
|
|
123
156
|
});
|
|
124
157
|
});
|
|
@@ -126,15 +159,15 @@ describe('gog_gmail_send', () => {
|
|
|
126
159
|
describe('gog_gmail_run', () => {
|
|
127
160
|
it('passes subcommand and args to runner', async () => {
|
|
128
161
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
129
|
-
const
|
|
130
|
-
await
|
|
162
|
+
const harness = await setupHandlers();
|
|
163
|
+
await harness.callTool('gog_gmail_run', { subcommand: 'archive', args: ['msg1'] });
|
|
131
164
|
expect(runner.run).toHaveBeenCalledWith(['gmail', 'archive', 'msg1'], { account: undefined });
|
|
132
165
|
});
|
|
133
166
|
|
|
134
167
|
it('returns error text on failure', async () => {
|
|
135
168
|
vi.mocked(runner.run).mockRejectedValue(new Error('Run failed'));
|
|
136
|
-
const
|
|
137
|
-
const result = await
|
|
169
|
+
const harness = await setupHandlers();
|
|
170
|
+
const result = await harness.callTool('gog_gmail_run', { subcommand: 'archive', args: [] });
|
|
138
171
|
expect(result.content[0].text).toBe('Error: Run failed');
|
|
139
172
|
});
|
|
140
173
|
});
|