gogcli-mcp 2.8.0 → 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.
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,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 { 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(registerTasksTools);
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 handlers = setupHandlers();
16
- await handlers.get('gog_tasks_lists')!({});
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 handlers = setupHandlers();
23
- const result = await handlers.get('gog_tasks_lists')!({});
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 handlers = setupHandlers();
32
- await handlers.get('gog_tasks_list')!({ tasklistId: 'list1' });
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 handlers = setupHandlers();
39
- const result = await handlers.get('gog_tasks_list')!({ tasklistId: 'bad' });
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 handlers = setupHandlers();
48
- await handlers.get('gog_tasks_get')!({ tasklistId: 'list1', taskId: 'task1' });
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 handlers = setupHandlers();
55
- const result = await handlers.get('gog_tasks_get')!({ tasklistId: 'l', taskId: 'bad' });
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 handlers = setupHandlers();
64
- await handlers.get('gog_tasks_add')!({ tasklistId: 'list1', title: 'Buy milk' });
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 handlers = setupHandlers();
74
- await handlers.get('gog_tasks_add')!({ tasklistId: 'list1', title: 'Buy milk', notes: 'Whole milk', due: '2026-04-20' });
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 handlers = setupHandlers();
84
- const result = await handlers.get('gog_tasks_add')!({ tasklistId: 'l', title: 't' });
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 handlers = setupHandlers();
93
- await handlers.get('gog_tasks_done')!({ tasklistId: 'list1', taskId: 'task1' });
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 handlers = setupHandlers();
100
- const result = await handlers.get('gog_tasks_done')!({ tasklistId: 'l', taskId: 't' });
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 handlers = setupHandlers();
109
- await handlers.get('gog_tasks_delete')!({ tasklistId: 'list1', taskId: 'task1' });
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 handlers = setupHandlers();
116
- const result = await handlers.get('gog_tasks_delete')!({ tasklistId: 'l', taskId: 't' });
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 handlers = setupHandlers();
125
- await handlers.get('gog_tasks_run')!({ subcommand: 'update', args: ['list1', 'task1', '--title=New'] });
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 handlers = setupHandlers();
132
- const result = await handlers.get('gog_tasks_run')!({ subcommand: 'clear', args: [] });
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
- "exclude": ["node_modules", "dist"]
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: ['src/index.ts'],
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
- }