gogcli-mcp 2.7.1 → 2.18.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 +20220 -19700
- package/dist/lib.js +15847 -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 +30 -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 +93 -44
- 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,26 +1,26 @@
|
|
|
1
1
|
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
|
-
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(registerTasksTools);
|
|
9
9
|
|
|
10
10
|
beforeEach(() => vi.clearAllMocks());
|
|
11
11
|
|
|
12
12
|
describe('gog_tasks_lists', () => {
|
|
13
13
|
it('calls run with tasks lists list', async () => {
|
|
14
14
|
vi.mocked(runner.run).mockResolvedValue('{"items":[]}');
|
|
15
|
-
const
|
|
16
|
-
await
|
|
15
|
+
const harness = await setupHandlers();
|
|
16
|
+
await harness.callTool('gog_tasks_lists', {});
|
|
17
17
|
expect(runner.run).toHaveBeenCalledWith(['tasks', 'lists', 'list'], { account: undefined });
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
it('returns error text on failure', async () => {
|
|
21
21
|
vi.mocked(runner.run).mockRejectedValue(new Error('Lists failed'));
|
|
22
|
-
const
|
|
23
|
-
const result = await
|
|
22
|
+
const harness = await setupHandlers();
|
|
23
|
+
const result = await harness.callTool('gog_tasks_lists', {});
|
|
24
24
|
expect(result.content[0].text).toBe('Error: Lists failed');
|
|
25
25
|
});
|
|
26
26
|
});
|
|
@@ -28,15 +28,15 @@ describe('gog_tasks_lists', () => {
|
|
|
28
28
|
describe('gog_tasks_list', () => {
|
|
29
29
|
it('calls run with tasklistId', async () => {
|
|
30
30
|
vi.mocked(runner.run).mockResolvedValue('{"items":[]}');
|
|
31
|
-
const
|
|
32
|
-
await
|
|
31
|
+
const harness = await setupHandlers();
|
|
32
|
+
await harness.callTool('gog_tasks_list', { tasklistId: 'list1' });
|
|
33
33
|
expect(runner.run).toHaveBeenCalledWith(['tasks', 'list', 'list1'], { account: undefined });
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
it('returns error text on failure', async () => {
|
|
37
37
|
vi.mocked(runner.run).mockRejectedValue(new Error('List failed'));
|
|
38
|
-
const
|
|
39
|
-
const result = await
|
|
38
|
+
const harness = await setupHandlers();
|
|
39
|
+
const result = await harness.callTool('gog_tasks_list', { tasklistId: 'bad' });
|
|
40
40
|
expect(result.content[0].text).toBe('Error: List failed');
|
|
41
41
|
});
|
|
42
42
|
});
|
|
@@ -44,15 +44,15 @@ describe('gog_tasks_list', () => {
|
|
|
44
44
|
describe('gog_tasks_get', () => {
|
|
45
45
|
it('calls run with tasklistId and taskId', async () => {
|
|
46
46
|
vi.mocked(runner.run).mockResolvedValue('{"id":"task1"}');
|
|
47
|
-
const
|
|
48
|
-
await
|
|
47
|
+
const harness = await setupHandlers();
|
|
48
|
+
await harness.callTool('gog_tasks_get', { tasklistId: 'list1', taskId: 'task1' });
|
|
49
49
|
expect(runner.run).toHaveBeenCalledWith(['tasks', 'get', 'list1', 'task1'], { account: undefined });
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
it('returns error text on failure', async () => {
|
|
53
53
|
vi.mocked(runner.run).mockRejectedValue(new Error('Get failed'));
|
|
54
|
-
const
|
|
55
|
-
const result = await
|
|
54
|
+
const harness = await setupHandlers();
|
|
55
|
+
const result = await harness.callTool('gog_tasks_get', { tasklistId: 'l', taskId: 'bad' });
|
|
56
56
|
expect(result.content[0].text).toBe('Error: Get failed');
|
|
57
57
|
});
|
|
58
58
|
});
|
|
@@ -60,8 +60,8 @@ describe('gog_tasks_get', () => {
|
|
|
60
60
|
describe('gog_tasks_add', () => {
|
|
61
61
|
it('calls run with required args', async () => {
|
|
62
62
|
vi.mocked(runner.run).mockResolvedValue('{"id":"task2"}');
|
|
63
|
-
const
|
|
64
|
-
await
|
|
63
|
+
const harness = await setupHandlers();
|
|
64
|
+
await harness.callTool('gog_tasks_add', { tasklistId: 'list1', title: 'Buy milk' });
|
|
65
65
|
expect(runner.run).toHaveBeenCalledWith(
|
|
66
66
|
['tasks', 'add', 'list1', '--title=Buy milk'],
|
|
67
67
|
{ account: undefined },
|
|
@@ -70,8 +70,8 @@ describe('gog_tasks_add', () => {
|
|
|
70
70
|
|
|
71
71
|
it('appends optional flags when provided', async () => {
|
|
72
72
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
73
|
-
const
|
|
74
|
-
await
|
|
73
|
+
const harness = await setupHandlers();
|
|
74
|
+
await harness.callTool('gog_tasks_add', { tasklistId: 'list1', title: 'Buy milk', notes: 'Whole milk', due: '2026-04-20' });
|
|
75
75
|
expect(runner.run).toHaveBeenCalledWith(
|
|
76
76
|
['tasks', 'add', 'list1', '--title=Buy milk', '--notes=Whole milk', '--due=2026-04-20'],
|
|
77
77
|
{ account: undefined },
|
|
@@ -80,8 +80,8 @@ describe('gog_tasks_add', () => {
|
|
|
80
80
|
|
|
81
81
|
it('returns error text on failure', async () => {
|
|
82
82
|
vi.mocked(runner.run).mockRejectedValue(new Error('Add failed'));
|
|
83
|
-
const
|
|
84
|
-
const result = await
|
|
83
|
+
const harness = await setupHandlers();
|
|
84
|
+
const result = await harness.callTool('gog_tasks_add', { tasklistId: 'l', title: 't' });
|
|
85
85
|
expect(result.content[0].text).toBe('Error: Add failed');
|
|
86
86
|
});
|
|
87
87
|
});
|
|
@@ -89,15 +89,15 @@ describe('gog_tasks_add', () => {
|
|
|
89
89
|
describe('gog_tasks_done', () => {
|
|
90
90
|
it('calls run with tasklistId and taskId', async () => {
|
|
91
91
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
92
|
-
const
|
|
93
|
-
await
|
|
92
|
+
const harness = await setupHandlers();
|
|
93
|
+
await harness.callTool('gog_tasks_done', { tasklistId: 'list1', taskId: 'task1' });
|
|
94
94
|
expect(runner.run).toHaveBeenCalledWith(['tasks', 'done', 'list1', 'task1'], { account: undefined });
|
|
95
95
|
});
|
|
96
96
|
|
|
97
97
|
it('returns error text on failure', async () => {
|
|
98
98
|
vi.mocked(runner.run).mockRejectedValue(new Error('Done failed'));
|
|
99
|
-
const
|
|
100
|
-
const result = await
|
|
99
|
+
const harness = await setupHandlers();
|
|
100
|
+
const result = await harness.callTool('gog_tasks_done', { tasklistId: 'l', taskId: 't' });
|
|
101
101
|
expect(result.content[0].text).toBe('Error: Done failed');
|
|
102
102
|
});
|
|
103
103
|
});
|
|
@@ -105,15 +105,15 @@ describe('gog_tasks_done', () => {
|
|
|
105
105
|
describe('gog_tasks_delete', () => {
|
|
106
106
|
it('calls run with tasklistId and taskId', async () => {
|
|
107
107
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
108
|
-
const
|
|
109
|
-
await
|
|
110
|
-
expect(runner.run).toHaveBeenCalledWith(['tasks', 'delete', 'list1', 'task1'], { account: undefined });
|
|
108
|
+
const harness = await setupHandlers();
|
|
109
|
+
await harness.callTool('gog_tasks_delete', { tasklistId: 'list1', taskId: 'task1' });
|
|
110
|
+
expect(runner.run).toHaveBeenCalledWith(['tasks', 'delete', 'list1', 'task1', '--force'], { account: undefined });
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
it('returns error text on failure', async () => {
|
|
114
114
|
vi.mocked(runner.run).mockRejectedValue(new Error('Delete failed'));
|
|
115
|
-
const
|
|
116
|
-
const result = await
|
|
115
|
+
const harness = await setupHandlers();
|
|
116
|
+
const result = await harness.callTool('gog_tasks_delete', { tasklistId: 'l', taskId: 't' });
|
|
117
117
|
expect(result.content[0].text).toBe('Error: Delete failed');
|
|
118
118
|
});
|
|
119
119
|
});
|
|
@@ -121,15 +121,15 @@ describe('gog_tasks_delete', () => {
|
|
|
121
121
|
describe('gog_tasks_run', () => {
|
|
122
122
|
it('passes subcommand and args to runner', async () => {
|
|
123
123
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
124
|
-
const
|
|
125
|
-
await
|
|
124
|
+
const harness = await setupHandlers();
|
|
125
|
+
await harness.callTool('gog_tasks_run', { subcommand: 'update', args: ['list1', 'task1', '--title=New'] });
|
|
126
126
|
expect(runner.run).toHaveBeenCalledWith(['tasks', 'update', 'list1', 'task1', '--title=New'], { account: undefined });
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
it('returns error text on failure', async () => {
|
|
130
130
|
vi.mocked(runner.run).mockRejectedValue(new Error('Run failed'));
|
|
131
|
-
const
|
|
132
|
-
const result = await
|
|
131
|
+
const harness = await setupHandlers();
|
|
132
|
+
const result = await harness.callTool('gog_tasks_run', { subcommand: 'clear', args: [] });
|
|
133
133
|
expect(result.content[0].text).toBe('Error: Run failed');
|
|
134
134
|
});
|
|
135
135
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import * as runner from '../../src/runner.js';
|
|
3
|
-
import { runOrDiagnose, pushPaginationFlags, formatAccountList } from '../../src/tools/utils.js';
|
|
3
|
+
import { runOrDiagnose, pushPaginationFlags, formatAccountList, formatAuthHealth } from '../../src/tools/utils.js';
|
|
4
4
|
|
|
5
5
|
vi.mock('../../src/runner.js');
|
|
6
6
|
|
|
@@ -88,10 +88,11 @@ describe('formatAccountList', () => {
|
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
describe('runOrDiagnose', () => {
|
|
91
|
-
it('returns output text on success', async () => {
|
|
91
|
+
it('returns output text on success (not flagged as an error)', async () => {
|
|
92
92
|
vi.mocked(runner.run).mockResolvedValue('{"ok":true}');
|
|
93
93
|
const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
|
|
94
94
|
expect(result.content[0].text).toBe('{"ok":true}');
|
|
95
|
+
expect(result.isError).toBeUndefined();
|
|
95
96
|
});
|
|
96
97
|
|
|
97
98
|
it('appends auth list on non-auth failure', async () => {
|
|
@@ -102,6 +103,8 @@ describe('runOrDiagnose', () => {
|
|
|
102
103
|
expect(result.content[0].text).toBe(
|
|
103
104
|
'Error: Doc not found\n\nConfigured accounts:\nuser@gmail.com',
|
|
104
105
|
);
|
|
106
|
+
// mcp-utils errorResult flags diagnosed failures for the client.
|
|
107
|
+
expect(result.isError).toBe(true);
|
|
105
108
|
expect(result.content[0].text).not.toContain('gog_auth_add');
|
|
106
109
|
});
|
|
107
110
|
|
|
@@ -168,6 +171,31 @@ describe('runOrDiagnose', () => {
|
|
|
168
171
|
expect(result.content[0].text).toContain('gog_auth_add');
|
|
169
172
|
});
|
|
170
173
|
|
|
174
|
+
it('gives invalid_grant a richer hint than a plain 401: cause + durable fix + both re-auth paths', async () => {
|
|
175
|
+
vi.mocked(runner.run)
|
|
176
|
+
.mockRejectedValueOnce(new Error('oauth2: "invalid_grant" "Token has been expired or revoked."'))
|
|
177
|
+
.mockResolvedValueOnce('user@gmail.com');
|
|
178
|
+
const result = await runOrDiagnose(['gmail', 'search', 'is:unread'], {});
|
|
179
|
+
const text = result.content[0].text as string;
|
|
180
|
+
// plain-English cause
|
|
181
|
+
expect(text).toContain('7-day');
|
|
182
|
+
expect(text).toContain('Testing');
|
|
183
|
+
// both re-auth paths
|
|
184
|
+
expect(text).toContain('gog_auth_add_url');
|
|
185
|
+
// durable fix
|
|
186
|
+
expect(text).toContain('In production');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('does NOT give a plain 401 the invalid_grant durable-fix text', async () => {
|
|
190
|
+
vi.mocked(runner.run)
|
|
191
|
+
.mockRejectedValueOnce(new Error('Request failed with status 401'))
|
|
192
|
+
.mockResolvedValueOnce('user@gmail.com');
|
|
193
|
+
const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
|
|
194
|
+
const text = result.content[0].text as string;
|
|
195
|
+
expect(text).toContain('gog_auth_add');
|
|
196
|
+
expect(text).not.toContain('In production');
|
|
197
|
+
});
|
|
198
|
+
|
|
171
199
|
it('returns plain error with auth hint when auth list also fails on auth error', async () => {
|
|
172
200
|
vi.mocked(runner.run)
|
|
173
201
|
.mockRejectedValueOnce(new Error('Request failed with status 401'))
|
|
@@ -184,6 +212,7 @@ describe('runOrDiagnose', () => {
|
|
|
184
212
|
.mockRejectedValueOnce(new Error('auth list failed'));
|
|
185
213
|
const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
|
|
186
214
|
expect(result.content[0].text).toBe('Error: Doc not found');
|
|
215
|
+
expect(result.isError).toBe(true);
|
|
187
216
|
expect(result.content[0].text).not.toContain('gog_auth_add');
|
|
188
217
|
});
|
|
189
218
|
|
|
@@ -278,3 +307,88 @@ describe('runOrDiagnose', () => {
|
|
|
278
307
|
expect(result.content[0].text).not.toContain('Configured accounts');
|
|
279
308
|
});
|
|
280
309
|
});
|
|
310
|
+
|
|
311
|
+
describe('formatAuthHealth', () => {
|
|
312
|
+
const NOW = Date.parse('2026-07-24T12:00:00.000Z');
|
|
313
|
+
|
|
314
|
+
it('flags a dead (invalid_grant) token with a mapped cause, age, and re-auth paths', () => {
|
|
315
|
+
const raw = JSON.stringify({
|
|
316
|
+
accounts: [{
|
|
317
|
+
email: 'chris.c.hall@gmail.com',
|
|
318
|
+
created_at: '2026-07-17T12:00:00.000Z',
|
|
319
|
+
valid: false,
|
|
320
|
+
error: 'refresh access token: oauth2: "invalid_grant" "Token has been expired or revoked."',
|
|
321
|
+
}],
|
|
322
|
+
});
|
|
323
|
+
const out = formatAuthHealth(raw, NOW);
|
|
324
|
+
expect(out).toContain('✗ chris.c.hall@gmail.com: NEEDS RE-AUTH');
|
|
325
|
+
expect(out).toContain('7-day limit');
|
|
326
|
+
expect(out).toContain('Authorized 7.0 day(s) ago');
|
|
327
|
+
expect(out).toContain('gog_auth_add_url');
|
|
328
|
+
// never echoes token material
|
|
329
|
+
expect(out).not.toContain('access token');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('warns a still-valid token as it nears the 7-day testing cliff (with estimated expiry)', () => {
|
|
333
|
+
const raw = JSON.stringify({
|
|
334
|
+
accounts: [{ email: 'a@x.com', created_at: '2026-07-18T00:00:00.000Z', valid: true }],
|
|
335
|
+
});
|
|
336
|
+
const out = formatAuthHealth(raw, NOW);
|
|
337
|
+
expect(out).toContain('✓ a@x.com: token valid');
|
|
338
|
+
expect(out).toContain('⚠');
|
|
339
|
+
expect(out).toContain('Approaching the 7-day');
|
|
340
|
+
expect(out).toContain('2026-07-25'); // created_at + 7d
|
|
341
|
+
expect(out).toContain('In production');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('does not warn a freshly authorized valid token', () => {
|
|
345
|
+
const raw = JSON.stringify({
|
|
346
|
+
accounts: [{ email: 'a@x.com', created_at: '2026-07-23T12:00:00.000Z', valid: true }],
|
|
347
|
+
});
|
|
348
|
+
const out = formatAuthHealth(raw, NOW);
|
|
349
|
+
expect(out).toContain('✓ a@x.com: token valid');
|
|
350
|
+
expect(out).toContain('Authorized 1.0 day(s) ago');
|
|
351
|
+
expect(out).not.toContain('⚠');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('uses the raw error for a non-invalid_grant invalid token', () => {
|
|
355
|
+
const raw = JSON.stringify({
|
|
356
|
+
accounts: [{ email: 'a@x.com', created_at: '2026-07-23T12:00:00.000Z', valid: false, error: 'network unreachable' }],
|
|
357
|
+
});
|
|
358
|
+
expect(formatAuthHealth(raw, NOW)).toContain('NEEDS RE-AUTH — network unreachable.');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('says "unknown error" for an invalid token with no error field', () => {
|
|
362
|
+
const raw = JSON.stringify({ accounts: [{ email: 'a@x.com', valid: false }] });
|
|
363
|
+
expect(formatAuthHealth(raw, NOW)).toContain('NEEDS RE-AUTH — unknown error.');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('reports unknown validity when the --check field is absent', () => {
|
|
367
|
+
const raw = JSON.stringify({ accounts: [{ email: 'a@x.com', created_at: '2026-07-23T12:00:00.000Z' }] });
|
|
368
|
+
const out = formatAuthHealth(raw, NOW);
|
|
369
|
+
expect(out).toContain('? a@x.com: token validity unknown');
|
|
370
|
+
expect(out).toContain('Authorized 1.0 day(s) ago');
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('omits the age when created_at is missing or unparseable', () => {
|
|
374
|
+
expect(formatAuthHealth(JSON.stringify({ accounts: [{ email: 'a@x.com', valid: true }] }), NOW))
|
|
375
|
+
.not.toContain('Authorized');
|
|
376
|
+
expect(formatAuthHealth(JSON.stringify({ accounts: [{ email: 'a@x.com', created_at: 'not-a-date', valid: true }] }), NOW))
|
|
377
|
+
.not.toContain('Authorized');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('falls back to a friendly line when no accounts are configured', () => {
|
|
381
|
+
expect(formatAuthHealth(JSON.stringify({ accounts: [] }), NOW))
|
|
382
|
+
.toBe('No Google accounts are configured. Use gog_auth_add to authorize one.');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('labels an account with no email', () => {
|
|
386
|
+
expect(formatAuthHealth(JSON.stringify({ accounts: [{ valid: true }] }), NOW))
|
|
387
|
+
.toContain('✓ (unknown account): token valid');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('falls back to trimmed raw text when the output is not the expected JSON', () => {
|
|
391
|
+
expect(formatAuthHealth(' not json\n', NOW)).toBe('not json');
|
|
392
|
+
expect(formatAuthHealth('{"foo":1}', NOW)).toBe('{"foo":1}');
|
|
393
|
+
});
|
|
394
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { SELF } from 'cloudflare:test';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
4
|
+
import type { ToolRegistrar } from '@chrischall/mcp-utils';
|
|
5
|
+
import {
|
|
6
|
+
BASE_TOOL_REGISTRARS,
|
|
7
|
+
registerAuthTools,
|
|
8
|
+
registerSheetsTools,
|
|
9
|
+
registerGmailTools,
|
|
10
|
+
registerDriveTools,
|
|
11
|
+
registerDocsTools,
|
|
12
|
+
} from '../src/lib.js';
|
|
13
|
+
import { registerExtraSheetsTools } from '../../gogcli-mcp-sheets/src/tools/sheets-extra.js';
|
|
14
|
+
import { registerExtraGmailTools } from '../../gogcli-mcp-gmail/src/tools/gmail-extra.js';
|
|
15
|
+
import { registerExtraDriveTools } from '../../gogcli-mcp-drive/src/tools/drive-extra.js';
|
|
16
|
+
import { registerExtraDocsTools } from '../../gogcli-mcp-docs/src/tools/docs-extra.js';
|
|
17
|
+
|
|
18
|
+
// Each per-service MCP path exposes auth + <service> base + <service> extras —
|
|
19
|
+
// the same tool set that sub-package's stdio server exposes. Mirror worker.ts's
|
|
20
|
+
// wiring. Min counts are conservative floors (base+auth alone is ~13-15).
|
|
21
|
+
const SERVICE_PATHS: Array<{
|
|
22
|
+
path: string;
|
|
23
|
+
regs: ToolRegistrar[];
|
|
24
|
+
baseTool: string;
|
|
25
|
+
minTools: number;
|
|
26
|
+
}> = [
|
|
27
|
+
{ path: '/mcp/sheets', regs: [registerAuthTools, registerSheetsTools, registerExtraSheetsTools], baseTool: 'gog_sheets_get', minTools: 40 },
|
|
28
|
+
{ path: '/mcp/gmail', regs: [registerAuthTools, registerGmailTools, registerExtraGmailTools], baseTool: 'gog_gmail_search', minTools: 40 },
|
|
29
|
+
{ path: '/mcp/drive', regs: [registerAuthTools, registerDriveTools, registerExtraDriveTools], baseTool: 'gog_drive_ls', minTools: 35 },
|
|
30
|
+
{ path: '/mcp/docs', regs: [registerAuthTools, registerDocsTools, registerExtraDocsTools], baseTool: 'gog_docs_cat', minTools: 60 },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// Handshake + tool-surface test for the gogcli Cloudflare remote connector, run
|
|
34
|
+
// inside the real Workers runtime (Miniflare) via `@cloudflare/vitest-pool-workers`
|
|
35
|
+
// against `wrangler.jsonc`. It proves things that don't require a live Fly
|
|
36
|
+
// backend or an authenticated session:
|
|
37
|
+
// 1. the OAuth default handler serves discovery + the login page;
|
|
38
|
+
// 2. an unauthenticated `/mcp` request is rejected before any tool code runs;
|
|
39
|
+
// 3. the base registrars register the full gog tool surface.
|
|
40
|
+
//
|
|
41
|
+
// The full authenticated `initialize` + `tools/list` over `/mcp` requires a real
|
|
42
|
+
// OAuth access token minted via `workers-oauth-provider`'s KV-backed grant flow
|
|
43
|
+
// (POST /authorize with a real connector key → auth code → POST /token), which
|
|
44
|
+
// would mean a live Fly login or extensive KV mocking — out of scope for a
|
|
45
|
+
// hermetic in-process test. So #3 asserts tool registration through the same
|
|
46
|
+
// in-memory MCP harness the stdio suite uses, wired exactly as `worker.ts` wires
|
|
47
|
+
// it (BASE_TOOL_REGISTRARS), rather than through the token-gated `/mcp` route.
|
|
48
|
+
|
|
49
|
+
describe('gogcli Cloudflare connector — OAuth surface', () => {
|
|
50
|
+
it('serves the OAuth authorization-server discovery document', async () => {
|
|
51
|
+
const res = await SELF.fetch('https://example.com/.well-known/oauth-authorization-server');
|
|
52
|
+
expect(res.status).toBe(200);
|
|
53
|
+
const meta = (await res.json()) as { authorization_endpoint?: string; token_endpoint?: string };
|
|
54
|
+
expect(meta.authorization_endpoint).toContain('/authorize');
|
|
55
|
+
expect(meta.token_endpoint).toContain('/token');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('rejects an unauthenticated /mcp request', async () => {
|
|
59
|
+
const res = await SELF.fetch('https://example.com/mcp', {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'content-type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
|
|
63
|
+
});
|
|
64
|
+
expect(res.status).toBe(401);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Each per-service path is wired as an auth-gated API route (→ 401), not a
|
|
68
|
+
// 404 from the default handler. (A 401 confirms the path is registered and
|
|
69
|
+
// token-gated; correct routing to the SERVICE agent vs the base agent is
|
|
70
|
+
// verified live post-deploy via an authenticated tools/list.)
|
|
71
|
+
for (const { path } of SERVICE_PATHS) {
|
|
72
|
+
it(`rejects an unauthenticated ${path} request`, async () => {
|
|
73
|
+
const res = await SELF.fetch(`https://example.com${path}`, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { 'content-type': 'application/json' },
|
|
76
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
|
|
77
|
+
});
|
|
78
|
+
expect(res.status).toBe(401);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
it('GET /authorize renders the gogcli login page with the connector-key field', async () => {
|
|
83
|
+
// No `client_id` query param: the login page renders without needing a
|
|
84
|
+
// registered OAuth client, which is all we verify here.
|
|
85
|
+
// `redirect_uri` IS required, though — do not remove it. workers-oauth-provider
|
|
86
|
+
// 0.8.x calls validateRedirectUriScheme() unconditionally from parseAuthRequest,
|
|
87
|
+
// and it rejects any value with no scheme — including the empty string an ABSENT
|
|
88
|
+
// `redirect_uri` becomes ("Invalid redirect URI"). client_id stays omitted, so no
|
|
89
|
+
// client lookup happens and the assertion below is unchanged.
|
|
90
|
+
const res = await SELF.fetch(
|
|
91
|
+
'https://example.com/authorize?response_type=code&state=abc' +
|
|
92
|
+
`&redirect_uri=${encodeURIComponent('https://example.com/callback')}`,
|
|
93
|
+
);
|
|
94
|
+
expect(res.status).toBe(200);
|
|
95
|
+
expect(res.headers.get('content-type')).toContain('text/html');
|
|
96
|
+
const html = await res.text();
|
|
97
|
+
expect(html).toContain('gogcli');
|
|
98
|
+
expect(html).toContain('gogcli connector key');
|
|
99
|
+
expect(html).toContain('type="password"');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('gogcli Cloudflare connector — tool surface', () => {
|
|
104
|
+
it('registers the base gog tool set via the same registrars as worker.ts', async () => {
|
|
105
|
+
// Mirror src/worker.ts's `tools` wiring: register every BASE_TOOL_REGISTRARS
|
|
106
|
+
// onto the in-memory harness server (no executor needed just to list tools).
|
|
107
|
+
const harness = await createTestHarness((server) => {
|
|
108
|
+
for (const register of BASE_TOOL_REGISTRARS) register(server);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const names = (await harness.listTools()).map((t) => t.name);
|
|
113
|
+
// Spot-check representative tools from multiple services + a run escape hatch.
|
|
114
|
+
expect(names).toContain('gog_sheets_get');
|
|
115
|
+
expect(names).toContain('gog_gmail_search');
|
|
116
|
+
expect(names).toContain('gog_drive_ls');
|
|
117
|
+
expect(names).toContain('gog_sheets_run');
|
|
118
|
+
// The base package exposes a broad multi-service surface.
|
|
119
|
+
expect(names.length).toBeGreaterThan(50);
|
|
120
|
+
} finally {
|
|
121
|
+
await harness.close();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Each per-service path registers auth + that service's base + its EXTRAS —
|
|
126
|
+
// proving the extended tool set is wired (well past the ~13 base+auth tools).
|
|
127
|
+
for (const { path, regs, baseTool, minTools } of SERVICE_PATHS) {
|
|
128
|
+
it(`${path} registers the extended ${baseTool.split('_')[1]} tool set (base + extras)`, async () => {
|
|
129
|
+
const harness = await createTestHarness((server) => {
|
|
130
|
+
for (const register of regs) register(server);
|
|
131
|
+
});
|
|
132
|
+
try {
|
|
133
|
+
const names = (await harness.listTools()).map((t) => t.name);
|
|
134
|
+
expect(names).toContain(baseTool); // the service's base op
|
|
135
|
+
expect(names).toContain('gog_auth_list'); // auth tools included
|
|
136
|
+
expect(names.length).toBeGreaterThan(minTools); // extras present, not just base
|
|
137
|
+
} finally {
|
|
138
|
+
await harness.close();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
});
|
package/tsconfig.json
CHANGED
|
@@ -5,5 +5,8 @@
|
|
|
5
5
|
"rootDir": "./src"
|
|
6
6
|
},
|
|
7
7
|
"include": ["src/**/*"],
|
|
8
|
-
|
|
8
|
+
// src/worker.ts is the Cloudflare-Worker entry: it imports
|
|
9
|
+
// @chrischall/mcp-connector (which pulls the `agents` runtime + Cloudflare
|
|
10
|
+
// types) and is compiled by wrangler/esbuild, not by this stdio tsc build.
|
|
11
|
+
"exclude": ["node_modules", "dist", "src/worker.ts"]
|
|
9
12
|
}
|
package/vitest.config.ts
CHANGED
|
@@ -1,11 +1,42 @@
|
|
|
1
|
-
import { defineConfig } from 'vitest/config';
|
|
1
|
+
import { configDefaults, defineConfig } from 'vitest/config';
|
|
2
2
|
|
|
3
3
|
export default defineConfig({
|
|
4
4
|
test: {
|
|
5
|
+
// `tests/worker.test.ts` only runs under the Workers runtime pool
|
|
6
|
+
// (root `vitest.workers.config.mts` / `npm run worker:test`), which provides
|
|
7
|
+
// the virtual `cloudflare:test` module it imports. The node pool must skip it.
|
|
8
|
+
exclude: [...configDefaults.exclude, 'tests/worker.test.ts'],
|
|
9
|
+
// Neutralize the gog env vars for the whole suite.
|
|
10
|
+
//
|
|
11
|
+
// THE BUG THIS FIXES: the runner tests assert the exact argv `run()` builds,
|
|
12
|
+
// but `run()` reads GOG_ACCOUNT/GOG_PATH/GOG_READONLY from the ambient
|
|
13
|
+
// environment. Anyone whose shell exports them — which is normal for a
|
|
14
|
+
// machine that also *uses* these MCP servers — got 10+ failures on an
|
|
15
|
+
// untouched working tree, and the vitest diff dumped the entire process.env
|
|
16
|
+
// (live API keys included) into the terminal. CI passed only because its
|
|
17
|
+
// environment happens to be bare.
|
|
18
|
+
//
|
|
19
|
+
// Empty string rather than deletion: vitest's `env` merges into process.env
|
|
20
|
+
// and cannot unset a key, but `readEnvVar` already treats '' as unset (the
|
|
21
|
+
// same rule that makes blank .mcpb user-config fields behave as absent), so
|
|
22
|
+
// this is exactly equivalent to running with the vars removed.
|
|
23
|
+
env: {
|
|
24
|
+
GOG_ACCOUNT: '',
|
|
25
|
+
GOG_PATH: '',
|
|
26
|
+
GOG_READONLY: '',
|
|
27
|
+
},
|
|
5
28
|
coverage: {
|
|
6
29
|
provider: 'v8',
|
|
7
30
|
include: ['src/**/*.ts'],
|
|
8
|
-
exclude: [
|
|
31
|
+
exclude: [
|
|
32
|
+
'src/index.ts',
|
|
33
|
+
// src/worker.ts is the only Worker-path file that can't load under the
|
|
34
|
+
// node pool (it imports the `@chrischall/mcp-connector`/`agents` runtime);
|
|
35
|
+
// it's exercised by the Workers pool suite (`npm run worker:test`). Its
|
|
36
|
+
// testable helpers live in src/connector-runtime.ts, and src/connector-auth.ts
|
|
37
|
+
// is node-loadable — both are unit-tested here and stay in the 100% gate.
|
|
38
|
+
'src/worker.ts',
|
|
39
|
+
],
|
|
9
40
|
thresholds: {
|
|
10
41
|
lines: 100,
|
|
11
42
|
functions: 100,
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
// Shared test harness for tool registrars across base + sub-packages.
|
|
2
|
-
//
|
|
3
|
-
// `vi.mock(...)` must stay in the caller's test file because vitest hoists
|
|
4
|
-
// it at module scope, but the boilerplate around it lives here.
|
|
5
|
-
import { vi } from 'vitest';
|
|
6
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
|
-
|
|
8
|
-
export type ToolHandler = (
|
|
9
|
-
args: Record<string, unknown>,
|
|
10
|
-
) => Promise<{ content: Array<{ type: string; text: string }> }>;
|
|
11
|
-
|
|
12
|
-
export function toText(text: string): { content: Array<{ type: string; text: string }> } {
|
|
13
|
-
return { content: [{ type: 'text', text }] };
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function setupHandlers(
|
|
17
|
-
register: (server: McpServer) => void,
|
|
18
|
-
): Map<string, ToolHandler> {
|
|
19
|
-
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
|
20
|
-
const handlers = new Map<string, ToolHandler>();
|
|
21
|
-
vi.spyOn(server, 'registerTool').mockImplementation((name, _config, cb) => {
|
|
22
|
-
handlers.set(name, cb as ToolHandler);
|
|
23
|
-
return undefined as never;
|
|
24
|
-
});
|
|
25
|
-
register(server);
|
|
26
|
-
return handlers;
|
|
27
|
-
}
|