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.
Files changed (46) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +20218 -19700
  4. package/dist/lib.js +15845 -23331
  5. package/manifest.json +33 -1
  6. package/package.json +7 -6
  7. package/server.json +2 -2
  8. package/src/connector-auth.ts +46 -0
  9. package/src/connector-runtime.ts +177 -0
  10. package/src/index.ts +7 -5
  11. package/src/lib.ts +8 -7
  12. package/src/runner.ts +225 -30
  13. package/src/server.ts +21 -25
  14. package/src/tools/api.ts +65 -0
  15. package/src/tools/auth.ts +112 -15
  16. package/src/tools/calendar.ts +24 -9
  17. package/src/tools/docs.ts +28 -3
  18. package/src/tools/drive.ts +124 -1
  19. package/src/tools/gmail.ts +7 -3
  20. package/src/tools/sheets.ts +6 -3
  21. package/src/tools/slides.ts +9 -4
  22. package/src/tools/tasks.ts +3 -1
  23. package/src/tools/utils.ts +163 -27
  24. package/src/worker.ts +99 -0
  25. package/tests/connector-auth.test.ts +28 -0
  26. package/tests/connector-runtime.test.ts +474 -0
  27. package/tests/runner-file-args-failure.test.ts +94 -0
  28. package/tests/runner-file-args.test.ts +232 -0
  29. package/tests/runner.test.ts +221 -13
  30. package/tests/server.test.ts +28 -28
  31. package/tests/tools/api.test.ts +107 -0
  32. package/tests/tools/auth.test.ts +187 -31
  33. package/tests/tools/calendar.test.ts +115 -52
  34. package/tests/tools/classroom.test.ts +77 -77
  35. package/tests/tools/contacts.test.ts +24 -24
  36. package/tests/tools/docs.test.ts +84 -46
  37. package/tests/tools/drive.test.ts +226 -55
  38. package/tests/tools/gmail.test.ts +61 -28
  39. package/tests/tools/sheets.test.ts +81 -70
  40. package/tests/tools/slides.test.ts +56 -36
  41. package/tests/tools/tasks.test.ts +33 -33
  42. package/tests/tools/utils.test.ts +116 -2
  43. package/tests/worker.test.ts +142 -0
  44. package/tsconfig.json +4 -1
  45. package/vitest.config.ts +33 -2
  46. 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 { setupHandlers as setupHandlersBase, type ToolHandler } from '../helpers/test-harness.js';
4
+ import { createTestHarness } from '@chrischall/mcp-utils/test';
5
5
 
6
6
  vi.mock('../../src/runner.js');
7
7
 
8
- const setupHandlers = () => setupHandlersBase(registerDriveTools);
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 handlers = setupHandlers();
16
- await handlers.get('gog_drive_ls')!({});
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 handlers = setupHandlers();
23
- await handlers.get('gog_drive_ls')!({ folderId: 'folder1' });
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 handlers = setupHandlers();
30
- await handlers.get('gog_drive_ls')!({
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 handlers = setupHandlers();
45
- await handlers.get('gog_drive_ls')!({ allDrives: false });
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 handlers = setupHandlers();
52
- await handlers.get('gog_drive_ls')!({ allDrives: true });
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 handlers = setupHandlers();
59
- const result = await handlers.get('gog_drive_ls')!({});
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 handlers = setupHandlers();
68
- await handlers.get('gog_drive_search')!({ query: 'budget' });
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 handlers = setupHandlers();
75
- const result = await handlers.get('gog_drive_search')!({ query: 'x' });
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 handlers = setupHandlers();
84
- await handlers.get('gog_drive_get')!({ fileId: 'file1' });
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 handlers = setupHandlers();
91
- const result = await handlers.get('gog_drive_get')!({ fileId: 'bad' });
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 handlers = setupHandlers();
100
- await handlers.get('gog_drive_mkdir')!({ name: 'Reports' });
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 handlers = setupHandlers();
107
- const result = await handlers.get('gog_drive_mkdir')!({ name: 'Bad' });
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 handlers = setupHandlers();
116
- await handlers.get('gog_drive_rename')!({ fileId: 'file1', newName: 'Budget 2026' });
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 handlers = setupHandlers();
123
- const result = await handlers.get('gog_drive_rename')!({ fileId: 'bad', newName: 'x' });
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 handlers = setupHandlers();
132
- await handlers.get('gog_drive_move')!({ fileId: 'file1', parentId: 'folder1' });
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 handlers = setupHandlers();
139
- const result = await handlers.get('gog_drive_move')!({ fileId: 'f', parentId: 'p' });
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 handlers = setupHandlers();
148
- await handlers.get('gog_drive_delete')!({ fileId: 'file1' });
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 handlers = setupHandlers();
155
- await handlers.get('gog_drive_delete')!({ fileId: 'file1', permanent: true });
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 handlers = setupHandlers();
165
- await handlers.get('gog_drive_delete')!({ fileId: 'file1', permanent: false });
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 handlers = setupHandlers();
172
- const result = await handlers.get('gog_drive_delete')!({ fileId: 'bad' });
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 handlers = setupHandlers();
181
- await handlers.get('gog_drive_share')!({ fileId: 'file1', to: 'user', email: 'bob@example.com' });
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 handlers = setupHandlers();
191
- await handlers.get('gog_drive_share')!({ fileId: 'file1', to: 'domain', domain: 'example.com', role: 'writer' });
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 handlers = setupHandlers();
201
- const result = await handlers.get('gog_drive_share')!({ fileId: 'f', to: 'anyone' });
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 handlers = setupHandlers();
210
- await handlers.get('gog_drive_run')!({ subcommand: 'copy', args: ['file1', 'My Copy'] });
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 handlers = setupHandlers();
217
- const result = await handlers.get('gog_drive_run')!({ subcommand: 'copy', args: [] });
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 { setupHandlers as setupHandlersBase, type ToolHandler } from '../helpers/test-harness.js';
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 = () => setupHandlersBase(registerGmailTools);
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 handlers = setupHandlers();
16
- await handlers.get('gog_gmail_search')!({ query: 'from:alice' });
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 handlers = setupHandlers();
23
- await handlers.get('gog_gmail_search')!({ query: 'is:unread', max: 5 });
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 handlers = setupHandlers();
30
- const result = await handlers.get('gog_gmail_search')!({ query: 'test' });
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 handlers = setupHandlers();
37
- await handlers.get('gog_gmail_search')!({ query: 'subject:invoice', fromContact: 'Alice' });
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 handlers = setupHandlers();
46
- await handlers.get('gog_gmail_get')!({ messageId: 'msg1' });
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 handlers = setupHandlers();
53
- await handlers.get('gog_gmail_get')!({ messageId: 'msg1', format: 'metadata' });
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 handlers = setupHandlers();
60
- const result = await handlers.get('gog_gmail_get')!({ messageId: 'bad' });
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 handlers = setupHandlers();
69
- await handlers.get('gog_gmail_send')!({ to: 'bob@example.com', subject: 'Hi', body: 'Hello' });
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 handlers = setupHandlers();
79
- await handlers.get('gog_gmail_send')!({
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 handlers = setupHandlers();
102
- await handlers.get('gog_gmail_send')!({
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 handlers = setupHandlers();
121
- const result = await handlers.get('gog_gmail_send')!({ to: 'x', subject: 'y', body: 'z' });
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 handlers = setupHandlers();
130
- await handlers.get('gog_gmail_run')!({ subcommand: 'archive', args: ['msg1'] });
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 handlers = setupHandlers();
137
- const result = await handlers.get('gog_gmail_run')!({ subcommand: 'archive', args: [] });
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
  });