gogcli-mcp 1.0.2 → 1.0.3

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/dist/index.js CHANGED
@@ -30116,14 +30116,26 @@ var StdioServerTransport = class {
30116
30116
  // src/runner.ts
30117
30117
  import { spawn } from "node:child_process";
30118
30118
  var TIMEOUT_MS = 3e4;
30119
+ function formatTimeout(ms) {
30120
+ const seconds = Math.round(ms / 1e3);
30121
+ if (seconds >= 60) {
30122
+ const minutes = Math.round(seconds / 60);
30123
+ return `${ms}ms (${minutes} minute${minutes !== 1 ? "s" : ""})`;
30124
+ }
30125
+ return `${ms}ms`;
30126
+ }
30119
30127
  async function run(args, options = {}) {
30120
- const { account, spawner = spawn } = options;
30128
+ const { account, spawner = spawn, interactive = false, timeout } = options;
30121
30129
  const effectiveAccount = account ?? process.env.GOG_ACCOUNT;
30122
- const fullArgs = ["--json", "--no-input", "--color=never"];
30130
+ const fullArgs = ["--json", "--color=never"];
30131
+ if (!interactive) {
30132
+ fullArgs.push("--no-input");
30133
+ }
30123
30134
  if (effectiveAccount) {
30124
30135
  fullArgs.push("--account", effectiveAccount);
30125
30136
  }
30126
30137
  fullArgs.push(...args);
30138
+ const effectiveTimeout = timeout ?? TIMEOUT_MS;
30127
30139
  return new Promise((resolve, reject) => {
30128
30140
  const child = spawner(process.env.GOG_PATH ?? "gog", fullArgs, { env: process.env });
30129
30141
  let stdout = "";
@@ -30132,8 +30144,8 @@ async function run(args, options = {}) {
30132
30144
  const timer = setTimeout(() => {
30133
30145
  settled = true;
30134
30146
  child.kill();
30135
- reject(new Error(`gog timed out after ${TIMEOUT_MS}ms`));
30136
- }, TIMEOUT_MS);
30147
+ reject(new Error(`gog timed out after ${formatTimeout(effectiveTimeout)}`));
30148
+ }, effectiveTimeout);
30137
30149
  child.stdout.on("data", (chunk) => {
30138
30150
  stdout += chunk.toString();
30139
30151
  });
@@ -30145,7 +30157,11 @@ async function run(args, options = {}) {
30145
30157
  if (settled) return;
30146
30158
  settled = true;
30147
30159
  if (code === 0) {
30148
- resolve(stdout);
30160
+ if (interactive && stderr.trim()) {
30161
+ resolve(stdout + "\n" + stderr);
30162
+ } else {
30163
+ resolve(stdout);
30164
+ }
30149
30165
  } else {
30150
30166
  reject(new Error(stderr.trim() || `gog exited with code ${code}`));
30151
30167
  }
@@ -30221,8 +30237,27 @@ function registerAuthTools(server2) {
30221
30237
  return toError(err);
30222
30238
  }
30223
30239
  });
30240
+ server2.registerTool("gog_auth_add", {
30241
+ description: "Authorize a Google account via browser-based OAuth. Opens a browser window where the user must sign in and grant access. Blocks for up to 5 minutes waiting for the user to complete authorization. If the browser does not open automatically, a fallback URL is included in the response. Use gog_auth_list to check which accounts are already configured.",
30242
+ annotations: { destructiveHint: true },
30243
+ inputSchema: {
30244
+ email: external_exports3.string().describe("Google account email to authorize"),
30245
+ services: external_exports3.string().optional().default("all").describe(
30246
+ 'Services to authorize: "all" or comma-separated list (e.g. "sheets,gmail,calendar"). Default: "all"'
30247
+ )
30248
+ }
30249
+ }, async ({ email: email3, services = "all" }) => {
30250
+ try {
30251
+ return toText(await run(["auth", "add", email3, "--services", services], {
30252
+ interactive: true,
30253
+ timeout: 3e5
30254
+ }));
30255
+ } catch (err) {
30256
+ return toError(err);
30257
+ }
30258
+ });
30224
30259
  server2.registerTool("gog_auth_run", {
30225
- description: "Run any gog auth subcommand. Run `gog auth --help` to see all available subcommands and flags. Note: gog auth add requires interactive browser auth and cannot be completed over MCP \u2014 run it in your terminal instead: gog auth add <email> --services <service>",
30260
+ description: "Run any gog auth subcommand. Run `gog auth --help` to see all available subcommands and flags. Note: for browser-based authorization, use gog_auth_add instead.",
30226
30261
  annotations: { destructiveHint: true },
30227
30262
  inputSchema: {
30228
30263
  subcommand: external_exports3.string().describe('The gog auth subcommand, e.g. "remove", "alias", "tokens"'),
@@ -30855,7 +30890,7 @@ function registerTasksTools(server2) {
30855
30890
  }
30856
30891
 
30857
30892
  // src/index.ts
30858
- var server = new McpServer({ name: "gogcli", version: "1.0.2" });
30893
+ var server = new McpServer({ name: "gogcli", version: "1.0.3" });
30859
30894
  registerAuthTools(server);
30860
30895
  registerCalendarTools(server);
30861
30896
  registerContactsTools(server);
@@ -0,0 +1,450 @@
1
+ # Browser-Based Auth Flow Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add a `gog_auth_add` MCP tool that opens the user's browser for Google OAuth and blocks until completion.
6
+
7
+ **Architecture:** Extend `RunOptions` with `interactive` and `timeout` fields. Add a dedicated `gog_auth_add` tool that uses these options to run the blocking browser auth flow. Existing runner behavior is unchanged for all other tools.
8
+
9
+ **Tech Stack:** TypeScript, vitest, zod, @modelcontextprotocol/sdk
10
+
11
+ ---
12
+
13
+ ### Task 1: Runner — add `interactive` option (skip `--no-input`)
14
+
15
+ **Files:**
16
+ - Modify: `src/runner.ts:4-5` (RunOptions interface)
17
+ - Modify: `src/runner.ts:17-22` (run function)
18
+ - Test: `tests/runner.test.ts`
19
+
20
+ - [ ] **Step 1: Write the failing test for `interactive: true` omitting `--no-input`**
21
+
22
+ Add to `tests/runner.test.ts` inside the existing `describe('run', ...)`:
23
+
24
+ ```ts
25
+ it('omits --no-input when interactive is true', async () => {
26
+ const spawner = makeSpawner(0, '{"ok":true}');
27
+ await run(['auth', 'add', 'user@gmail.com'], { spawner, interactive: true });
28
+ const callArgs = (spawner as ReturnType<typeof vi.fn>).mock.calls[0][1] as string[];
29
+ expect(callArgs).toContain('--json');
30
+ expect(callArgs).toContain('--color=never');
31
+ expect(callArgs).not.toContain('--no-input');
32
+ expect(callArgs).toContain('auth');
33
+ });
34
+ ```
35
+
36
+ - [ ] **Step 2: Run test to verify it fails**
37
+
38
+ Run: `npm test -- --reporter=verbose -t "omits --no-input when interactive is true"`
39
+ Expected: FAIL — `interactive` is not a recognized option yet, `--no-input` is still present.
40
+
41
+ - [ ] **Step 3: Write the regression test for default (non-interactive) still including `--no-input`**
42
+
43
+ Add to `tests/runner.test.ts`:
44
+
45
+ ```ts
46
+ it('includes --no-input when interactive is not set', async () => {
47
+ const spawner = makeSpawner(0, '{"ok":true}');
48
+ await run(['sheets', 'get', 'id1', 'A1'], { spawner });
49
+ const callArgs = (spawner as ReturnType<typeof vi.fn>).mock.calls[0][1] as string[];
50
+ expect(callArgs).toContain('--no-input');
51
+ });
52
+ ```
53
+
54
+ - [ ] **Step 4: Implement `interactive` in runner**
55
+
56
+ In `src/runner.ts`, update `RunOptions`:
57
+
58
+ ```ts
59
+ export interface RunOptions {
60
+ account?: string;
61
+ spawner?: Spawner;
62
+ interactive?: boolean;
63
+ timeout?: number;
64
+ }
65
+ ```
66
+
67
+ In the `run()` function, change the `fullArgs` construction:
68
+
69
+ ```ts
70
+ const { account, spawner = spawn as unknown as Spawner, interactive = false, timeout } = options;
71
+
72
+ const effectiveAccount = account ?? process.env.GOG_ACCOUNT;
73
+
74
+ const fullArgs = ['--json', '--color=never'];
75
+ if (!interactive) {
76
+ fullArgs.push('--no-input');
77
+ }
78
+ ```
79
+
80
+ - [ ] **Step 5: Run tests to verify both pass**
81
+
82
+ Run: `npm test -- --reporter=verbose`
83
+ Expected: Both new tests PASS, all existing tests PASS.
84
+
85
+ - [ ] **Step 6: Commit**
86
+
87
+ ```bash
88
+ git add src/runner.ts tests/runner.test.ts
89
+ git commit -m "feat(runner): add interactive option to skip --no-input"
90
+ ```
91
+
92
+ ---
93
+
94
+ ### Task 2: Runner — add custom `timeout` option
95
+
96
+ **Files:**
97
+ - Modify: `src/runner.ts:17-18` (run function, timeout handling)
98
+ - Test: `tests/runner.test.ts`
99
+
100
+ - [ ] **Step 1: Write the failing test for custom timeout**
101
+
102
+ Add to `tests/runner.test.ts`:
103
+
104
+ ```ts
105
+ it('uses custom timeout when provided', async () => {
106
+ vi.useFakeTimers();
107
+ const spawner = vi.fn(() => {
108
+ const proc = new EventEmitter() as ReturnType<Spawner>;
109
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stdout = new EventEmitter();
110
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stderr = new EventEmitter();
111
+ proc.kill = vi.fn();
112
+ return proc;
113
+ }) as unknown as Spawner;
114
+
115
+ const promise = run(['auth', 'add', 'user@gmail.com'], { spawner, timeout: 300_000 });
116
+ // Should NOT have timed out at 30s
117
+ vi.advanceTimersByTime(30_000);
118
+ // Advance to custom timeout
119
+ vi.advanceTimersByTime(270_000);
120
+ await expect(promise).rejects.toThrow('gog timed out after 300000ms (5 minutes)');
121
+ vi.useRealTimers();
122
+ });
123
+ ```
124
+
125
+ - [ ] **Step 2: Write test that default timeout still works**
126
+
127
+ Add to `tests/runner.test.ts`:
128
+
129
+ ```ts
130
+ it('includes human-readable duration in timeout error for default timeout', async () => {
131
+ vi.useFakeTimers();
132
+ const spawner = vi.fn(() => {
133
+ const proc = new EventEmitter() as ReturnType<Spawner>;
134
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stdout = new EventEmitter();
135
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stderr = new EventEmitter();
136
+ proc.kill = vi.fn();
137
+ return proc;
138
+ }) as unknown as Spawner;
139
+
140
+ const promise = run(['sheets', 'get', 'id', 'A1'], { spawner });
141
+ vi.advanceTimersByTime(30_000);
142
+ await expect(promise).rejects.toThrow('gog timed out after 30000ms');
143
+ vi.useRealTimers();
144
+ });
145
+ ```
146
+
147
+ - [ ] **Step 3: Run tests to verify they fail**
148
+
149
+ Run: `npm test -- --reporter=verbose`
150
+ Expected: Custom timeout test FAIL (times out at 30s instead of 300s). Default timeout message test may also fail if message format changed.
151
+
152
+ - [ ] **Step 4: Implement custom timeout in runner**
153
+
154
+ In `src/runner.ts`, update the `run()` function:
155
+
156
+ ```ts
157
+ const effectiveTimeout = timeout ?? TIMEOUT_MS;
158
+
159
+ // Helper for human-readable duration
160
+ function formatTimeout(ms: number): string {
161
+ const seconds = Math.round(ms / 1000);
162
+ if (seconds >= 60) {
163
+ const minutes = Math.round(seconds / 60);
164
+ return `${ms}ms (${minutes} minute${minutes !== 1 ? 's' : ''})`;
165
+ }
166
+ return `${ms}ms`;
167
+ }
168
+
169
+ const timer = setTimeout(() => {
170
+ settled = true;
171
+ child.kill();
172
+ reject(new Error(`gog timed out after ${formatTimeout(effectiveTimeout)}`));
173
+ }, effectiveTimeout);
174
+ ```
175
+
176
+ Note: `formatTimeout` should be defined inside `run()` or as a module-level helper. Since it's small, define it at module level above the `run` function.
177
+
178
+ - [ ] **Step 5: Run tests to verify they pass**
179
+
180
+ Run: `npm test -- --reporter=verbose`
181
+ Expected: All tests PASS.
182
+
183
+ - [ ] **Step 6: Verify existing timeout test still passes**
184
+
185
+ The existing test `'rejects with timeout error when gog does not respond'` uses `.toThrow('gog timed out after 30000ms')` which is a substring match. The new message `"gog timed out after 30000ms"` still contains this substring (30s doesn't reach the 60s threshold for the minutes suffix). Verify it passes with no changes needed.
186
+
187
+ Run: `npm test -- --reporter=verbose -t "does not respond"`
188
+ Expected: PASS with no changes.
189
+
190
+ - [ ] **Step 7: Run full test suite**
191
+
192
+ Run: `npm test -- --reporter=verbose`
193
+ Expected: All tests PASS.
194
+
195
+ - [ ] **Step 8: Commit**
196
+
197
+ ```bash
198
+ git add src/runner.ts tests/runner.test.ts
199
+ git commit -m "feat(runner): add custom timeout option with human-readable error"
200
+ ```
201
+
202
+ ---
203
+
204
+ ### Task 3: Runner — append stderr on interactive success
205
+
206
+ **Files:**
207
+ - Modify: `src/runner.ts` (close handler)
208
+ - Test: `tests/runner.test.ts`
209
+
210
+ - [ ] **Step 1: Write the failing test for stderr appended on interactive success**
211
+
212
+ Add to `tests/runner.test.ts`:
213
+
214
+ ```ts
215
+ it('appends stderr to stdout on success when interactive is true', async () => {
216
+ const spawner = vi.fn(() => {
217
+ const proc = new EventEmitter() as ReturnType<Spawner>;
218
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stdout = new EventEmitter();
219
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stderr = new EventEmitter();
220
+ setTimeout(() => {
221
+ (proc as unknown as { stdout: EventEmitter }).stdout.emit('data', Buffer.from('{"success":true}'));
222
+ (proc as unknown as { stderr: EventEmitter }).stderr.emit('data', Buffer.from('Opening browser...\nIf the browser doesn\'t open, visit this URL:\nhttps://accounts.google.com/auth?...'));
223
+ proc.emit('close', 0);
224
+ }, 0);
225
+ return proc;
226
+ }) as unknown as Spawner;
227
+
228
+ const result = await run(['auth', 'add', 'user@gmail.com'], { spawner, interactive: true });
229
+ expect(result).toContain('{"success":true}');
230
+ expect(result).toContain('Opening browser...');
231
+ expect(result).toContain('https://accounts.google.com/auth?...');
232
+ });
233
+ ```
234
+
235
+ - [ ] **Step 2: Write test that non-interactive success does NOT include stderr**
236
+
237
+ Add to `tests/runner.test.ts`:
238
+
239
+ ```ts
240
+ it('does not append stderr to stdout on success when interactive is false', async () => {
241
+ const spawner = vi.fn(() => {
242
+ const proc = new EventEmitter() as ReturnType<Spawner>;
243
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stdout = new EventEmitter();
244
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stderr = new EventEmitter();
245
+ setTimeout(() => {
246
+ (proc as unknown as { stdout: EventEmitter }).stdout.emit('data', Buffer.from('{"ok":true}'));
247
+ (proc as unknown as { stderr: EventEmitter }).stderr.emit('data', Buffer.from('some warning'));
248
+ proc.emit('close', 0);
249
+ }, 0);
250
+ return proc;
251
+ }) as unknown as Spawner;
252
+
253
+ const result = await run(['sheets', 'get', 'id', 'A1'], { spawner });
254
+ expect(result).toBe('{"ok":true}');
255
+ expect(result).not.toContain('some warning');
256
+ });
257
+ ```
258
+
259
+ - [ ] **Step 3: Run tests to verify they fail**
260
+
261
+ Run: `npm test -- --reporter=verbose`
262
+ Expected: Interactive stderr test FAIL (stderr not included in result).
263
+
264
+ - [ ] **Step 4: Implement stderr append for interactive mode**
265
+
266
+ In `src/runner.ts`, update the close handler:
267
+
268
+ ```ts
269
+ child.on('close', (code: number | null) => {
270
+ clearTimeout(timer);
271
+ if (settled) return;
272
+ settled = true;
273
+ if (code === 0) {
274
+ if (interactive && stderr.trim()) {
275
+ resolve(stdout + '\n' + stderr);
276
+ } else {
277
+ resolve(stdout);
278
+ }
279
+ } else {
280
+ reject(new Error(stderr.trim() || `gog exited with code ${code}`));
281
+ }
282
+ });
283
+ ```
284
+
285
+ - [ ] **Step 5: Run tests to verify they pass**
286
+
287
+ Run: `npm test -- --reporter=verbose`
288
+ Expected: All tests PASS.
289
+
290
+ - [ ] **Step 6: Commit**
291
+
292
+ ```bash
293
+ git add src/runner.ts tests/runner.test.ts
294
+ git commit -m "feat(runner): append stderr on interactive success for fallback URL"
295
+ ```
296
+
297
+ ---
298
+
299
+ ### Task 4: Auth tool — add `gog_auth_add`
300
+
301
+ **Files:**
302
+ - Modify: `src/tools/auth.ts:6-56` (add new tool, update `gog_auth_run` description)
303
+ - Test: `tests/tools/auth.test.ts`
304
+
305
+ - [ ] **Step 1: Write the failing test for `gog_auth_add` with default services**
306
+
307
+ Add to `tests/tools/auth.test.ts`:
308
+
309
+ ```ts
310
+ describe('gog_auth_add', () => {
311
+ it('calls run with correct args, interactive true, and 5-minute timeout', async () => {
312
+ vi.mocked(runner.run).mockResolvedValue('Authorization successful for user@gmail.com');
313
+ const handlers = setupHandlers();
314
+ const result = await handlers.get('gog_auth_add')!({ email: 'user@gmail.com' });
315
+ expect(runner.run).toHaveBeenCalledWith(
316
+ ['auth', 'add', 'user@gmail.com', '--services', 'all'],
317
+ { interactive: true, timeout: 300_000 },
318
+ );
319
+ expect(result.content[0].text).toBe('Authorization successful for user@gmail.com');
320
+ });
321
+ });
322
+ ```
323
+
324
+ - [ ] **Step 2: Run test to verify it fails**
325
+
326
+ Run: `npm test -- --reporter=verbose -t "gog_auth_add"`
327
+ Expected: FAIL — handler `gog_auth_add` does not exist.
328
+
329
+ - [ ] **Step 3: Write additional tests for `gog_auth_add`**
330
+
331
+ Add inside the `describe('gog_auth_add', ...)`:
332
+
333
+ ```ts
334
+ it('passes custom services when provided', async () => {
335
+ vi.mocked(runner.run).mockResolvedValue('Authorization successful');
336
+ const handlers = setupHandlers();
337
+ await handlers.get('gog_auth_add')!({ email: 'user@gmail.com', services: 'sheets,gmail' });
338
+ expect(runner.run).toHaveBeenCalledWith(
339
+ ['auth', 'add', 'user@gmail.com', '--services', 'sheets,gmail'],
340
+ { interactive: true, timeout: 300_000 },
341
+ );
342
+ });
343
+
344
+ it('returns error text on failure', async () => {
345
+ vi.mocked(runner.run).mockRejectedValue(new Error('Auth cancelled by user'));
346
+ const handlers = setupHandlers();
347
+ const result = await handlers.get('gog_auth_add')!({ email: 'user@gmail.com' });
348
+ expect(result.content[0].text).toBe('Error: Auth cancelled by user');
349
+ });
350
+
351
+ it('returns error text on timeout', async () => {
352
+ vi.mocked(runner.run).mockRejectedValue(new Error('gog timed out after 300000ms (5 minutes)'));
353
+ const handlers = setupHandlers();
354
+ const result = await handlers.get('gog_auth_add')!({ email: 'user@gmail.com' });
355
+ expect(result.content[0].text).toContain('timed out');
356
+ expect(result.content[0].text).toContain('5 minutes');
357
+ });
358
+ ```
359
+
360
+ - [ ] **Step 4: Implement `gog_auth_add` tool**
361
+
362
+ Add to `src/tools/auth.ts`, before the `gog_auth_run` registration:
363
+
364
+ ```ts
365
+ server.registerTool('gog_auth_add', {
366
+ description:
367
+ 'Authorize a Google account via browser-based OAuth. ' +
368
+ 'Opens a browser window where the user must sign in and grant access. ' +
369
+ 'Blocks for up to 5 minutes waiting for the user to complete authorization. ' +
370
+ 'If the browser does not open automatically, a fallback URL is included in the response. ' +
371
+ 'Use gog_auth_list to check which accounts are already configured.',
372
+ annotations: { destructiveHint: true },
373
+ inputSchema: {
374
+ email: z.string().describe('Google account email to authorize'),
375
+ services: z.string().optional().default('all').describe(
376
+ 'Services to authorize: "all" or comma-separated list (e.g. "sheets,gmail,calendar"). Default: "all"',
377
+ ),
378
+ },
379
+ }, async ({ email, services }) => {
380
+ try {
381
+ return toText(await run(['auth', 'add', email, '--services', services], {
382
+ interactive: true,
383
+ timeout: 300_000,
384
+ }));
385
+ } catch (err) {
386
+ return toError(err);
387
+ }
388
+ });
389
+ ```
390
+
391
+ - [ ] **Step 5: Update `gog_auth_run` description**
392
+
393
+ Change the `gog_auth_run` description from:
394
+
395
+ ```ts
396
+ description: 'Run any gog auth subcommand. Run `gog auth --help` to see all available subcommands and flags. Note: gog auth add requires interactive browser auth and cannot be completed over MCP — run it in your terminal instead: gog auth add <email> --services <service>',
397
+ ```
398
+
399
+ to:
400
+
401
+ ```ts
402
+ description: 'Run any gog auth subcommand. Run `gog auth --help` to see all available subcommands and flags. Note: for browser-based authorization, use gog_auth_add instead.',
403
+ ```
404
+
405
+ - [ ] **Step 6: Run all tests**
406
+
407
+ Run: `npm test -- --reporter=verbose`
408
+ Expected: All tests PASS.
409
+
410
+ - [ ] **Step 7: Commit**
411
+
412
+ ```bash
413
+ git add src/tools/auth.ts tests/tools/auth.test.ts
414
+ git commit -m "feat(auth): add gog_auth_add tool for browser-based OAuth"
415
+ ```
416
+
417
+ ---
418
+
419
+ ### Task 5: Manifest update
420
+
421
+ **Files:**
422
+ - Modify: `manifest.json`
423
+
424
+ - [ ] **Step 1: Add `gog_auth_add` to manifest tools list**
425
+
426
+ Add a new entry in the `tools` array in `manifest.json`, after the existing `gog_auth_list` entry:
427
+
428
+ ```json
429
+ {
430
+ "name": "gog_auth_add",
431
+ "description": "Authorize a Google account via browser-based OAuth"
432
+ },
433
+ ```
434
+
435
+ - [ ] **Step 2: Run build to verify no issues**
436
+
437
+ Run: `npm run build`
438
+ Expected: Build succeeds.
439
+
440
+ - [ ] **Step 3: Run full test suite**
441
+
442
+ Run: `npm test -- --reporter=verbose`
443
+ Expected: All tests PASS.
444
+
445
+ - [ ] **Step 4: Commit**
446
+
447
+ ```bash
448
+ git add manifest.json
449
+ git commit -m "chore(manifest): add gog_auth_add tool"
450
+ ```
@@ -0,0 +1,88 @@
1
+ # Browser-Based Auth Flow for gogcli-mcp
2
+
3
+ ## Summary
4
+
5
+ Add a `gog_auth_add` MCP tool that runs the default `gog auth add` browser flow, opening the user's browser for Google OAuth and blocking until completion. This gives users a one-click auth experience without leaving Claude.
6
+
7
+ ## Runner Changes
8
+
9
+ ### `RunOptions` additions (`src/runner.ts`)
10
+
11
+ Add two optional fields to the existing `RunOptions` interface:
12
+
13
+ ```ts
14
+ export interface RunOptions {
15
+ account?: string;
16
+ spawner?: Spawner;
17
+ interactive?: boolean; // NEW: when true, omit --no-input
18
+ timeout?: number; // NEW: override default 30s timeout (ms)
19
+ }
20
+ ```
21
+
22
+ ### Behavior changes in `run()`
23
+
24
+ - When `interactive` is `true`, do NOT inject `--no-input` into `fullArgs`.
25
+ - When `timeout` is provided, use it instead of the `TIMEOUT_MS` constant.
26
+ - When `interactive` is `true` and the process exits successfully (code 0), append any captured stderr to the stdout result. This ensures the fallback URL ("If the browser doesn't open, visit this URL") reaches Claude even on success.
27
+ - When `timeout` is provided, the timeout error message includes the human-readable duration: e.g., `"gog timed out after 300000ms (5 minutes)"` so both the raw value and friendly form are visible.
28
+
29
+ ### Default behavior
30
+
31
+ Unchanged. Existing calls without `interactive` or `timeout` behave exactly as before.
32
+
33
+ ## New Tool: `gog_auth_add`
34
+
35
+ ### Registration (`src/tools/auth.ts`)
36
+
37
+ - **Name:** `gog_auth_add`
38
+ - **Annotations:** `{ destructiveHint: true }`
39
+ - **Input schema:**
40
+ - `email` — required `z.string()`, Google account email to authorize
41
+ - `services` — optional `z.string()`, default `"all"`, comma-separated services or `"all"`
42
+ - **Handler:** Calls `run(['auth', 'add', email, '--services', services], { interactive: true, timeout: 300_000 })`
43
+ - **Error handling:** Standard `toError()` wrapping, same as other auth tools.
44
+
45
+ ### Tool description
46
+
47
+ The description tells Claude:
48
+
49
+ 1. This opens a browser window for Google OAuth
50
+ 2. The user must complete authorization in the browser
51
+ 3. The tool blocks for up to 5 minutes waiting for completion
52
+ 4. If the browser doesn't open automatically, a fallback URL is included in the response
53
+
54
+ ### `gog_auth_run` description update
55
+
56
+ Remove the "gog auth add requires interactive browser auth and cannot be completed over MCP" caveat. Replace with a note pointing to `gog_auth_add` for browser-based authorization.
57
+
58
+ ## Manifest update
59
+
60
+ Add `gog_auth_add` to the `manifest.json` tools list.
61
+
62
+ ## Error Handling
63
+
64
+ | Scenario | Behavior |
65
+ |---|---|
66
+ | User completes auth in browser | Tool returns success with gogcli output |
67
+ | User doesn't complete within 5 min | Process killed, error: "gog auth timed out after 5 minutes" |
68
+ | User cancels/denies in browser | gogcli exits non-zero, stderr returned as error |
69
+ | Browser doesn't open | Fallback URL included in response via stderr capture |
70
+ | Already authorized account | gogcli re-authorizes (updates scopes), no special handling |
71
+
72
+ ## Testing
73
+
74
+ ### Runner tests (`tests/runner.test.ts`)
75
+
76
+ - `interactive: true` omits `--no-input` from args
77
+ - Custom `timeout` is respected (process killed at custom time, not 30s)
78
+ - `interactive: true` on success appends stderr to stdout result
79
+ - `interactive: false` (or omitted) still includes `--no-input` (regression)
80
+ - Timeout error message includes human-readable duration
81
+
82
+ ### Auth tool tests (`tests/tools/auth.test.ts`)
83
+
84
+ - `gog_auth_add` passes correct args to runner with `interactive: true` and `timeout: 300_000`
85
+ - `gog_auth_add` defaults services to `"all"` when omitted
86
+ - `gog_auth_add` passes custom services when provided
87
+ - `gog_auth_add` returns error text on failure
88
+ - `gog_auth_add` returns error text on timeout
package/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifest_version": "0.3",
4
4
  "name": "gogcli-mcp",
5
5
  "display_name": "gogcli",
6
- "version": "1.0.2",
6
+ "version": "1.0.3",
7
7
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -64,6 +64,10 @@
64
64
  }
65
65
  },
66
66
  "tools": [
67
+ {
68
+ "name": "gog_auth_add",
69
+ "description": "Authorize a Google account via browser-based OAuth"
70
+ },
67
71
  {
68
72
  "name": "gog_auth_list",
69
73
  "description": "List all Google accounts stored in gogcli"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "MCP server wrapping gogcli for Google service access",
5
5
  "repository": {
6
6
  "type": "git",
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ import { registerGmailTools } from './tools/gmail.js';
10
10
  import { registerSheetsTools } from './tools/sheets.js';
11
11
  import { registerTasksTools } from './tools/tasks.js';
12
12
 
13
- const server = new McpServer({ name: 'gogcli', version: '1.0.2' });
13
+ const server = new McpServer({ name: 'gogcli', version: '1.0.3' });
14
14
 
15
15
  registerAuthTools(server);
16
16
  registerCalendarTools(server);
package/src/runner.ts CHANGED
@@ -10,21 +10,37 @@ export type Spawner = (
10
10
  export interface RunOptions {
11
11
  account?: string;
12
12
  spawner?: Spawner;
13
+ interactive?: boolean;
14
+ timeout?: number;
13
15
  }
14
16
 
15
17
  const TIMEOUT_MS = 30_000;
16
18
 
19
+ function formatTimeout(ms: number): string {
20
+ const seconds = Math.round(ms / 1000);
21
+ if (seconds >= 60) {
22
+ const minutes = Math.round(seconds / 60);
23
+ return `${ms}ms (${minutes} minute${minutes !== 1 ? 's' : ''})`;
24
+ }
25
+ return `${ms}ms`;
26
+ }
27
+
17
28
  export async function run(args: string[], options: RunOptions = {}): Promise<string> {
18
- const { account, spawner = spawn as unknown as Spawner } = options;
29
+ const { account, spawner = spawn as unknown as Spawner, interactive = false, timeout } = options;
19
30
 
20
31
  const effectiveAccount = account ?? process.env.GOG_ACCOUNT;
21
32
 
22
- const fullArgs = ['--json', '--no-input', '--color=never'];
33
+ const fullArgs = ['--json', '--color=never'];
34
+ if (!interactive) {
35
+ fullArgs.push('--no-input');
36
+ }
23
37
  if (effectiveAccount) {
24
38
  fullArgs.push('--account', effectiveAccount);
25
39
  }
26
40
  fullArgs.push(...args);
27
41
 
42
+ const effectiveTimeout = timeout ?? TIMEOUT_MS;
43
+
28
44
  return new Promise((resolve, reject) => {
29
45
  const child = spawner(process.env.GOG_PATH ?? 'gog', fullArgs, { env: process.env });
30
46
  let stdout = '';
@@ -34,8 +50,8 @@ export async function run(args: string[], options: RunOptions = {}): Promise<str
34
50
  const timer = setTimeout(() => {
35
51
  settled = true;
36
52
  child.kill();
37
- reject(new Error(`gog timed out after ${TIMEOUT_MS}ms`));
38
- }, TIMEOUT_MS);
53
+ reject(new Error(`gog timed out after ${formatTimeout(effectiveTimeout)}`));
54
+ }, effectiveTimeout);
39
55
 
40
56
  child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
41
57
  child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
@@ -45,7 +61,11 @@ export async function run(args: string[], options: RunOptions = {}): Promise<str
45
61
  if (settled) return;
46
62
  settled = true;
47
63
  if (code === 0) {
48
- resolve(stdout);
64
+ if (interactive && stderr.trim()) {
65
+ resolve(stdout + '\n' + stderr);
66
+ } else {
67
+ resolve(stdout);
68
+ }
49
69
  } else {
50
70
  reject(new Error(stderr.trim() || `gog exited with code ${code}`));
51
71
  }
package/src/tools/auth.ts CHANGED
@@ -40,8 +40,33 @@ export function registerAuthTools(server: McpServer): void {
40
40
  }
41
41
  });
42
42
 
43
+ server.registerTool('gog_auth_add', {
44
+ description:
45
+ 'Authorize a Google account via browser-based OAuth. ' +
46
+ 'Opens a browser window where the user must sign in and grant access. ' +
47
+ 'Blocks for up to 5 minutes waiting for the user to complete authorization. ' +
48
+ 'If the browser does not open automatically, a fallback URL is included in the response. ' +
49
+ 'Use gog_auth_list to check which accounts are already configured.',
50
+ annotations: { destructiveHint: true },
51
+ inputSchema: {
52
+ email: z.string().describe('Google account email to authorize'),
53
+ services: z.string().optional().default('all').describe(
54
+ 'Services to authorize: "all" or comma-separated list (e.g. "sheets,gmail,calendar"). Default: "all"',
55
+ ),
56
+ },
57
+ }, async ({ email, services = 'all' }) => {
58
+ try {
59
+ return toText(await run(['auth', 'add', email, '--services', services], {
60
+ interactive: true,
61
+ timeout: 300_000,
62
+ }));
63
+ } catch (err) {
64
+ return toError(err);
65
+ }
66
+ });
67
+
43
68
  server.registerTool('gog_auth_run', {
44
- description: 'Run any gog auth subcommand. Run `gog auth --help` to see all available subcommands and flags. Note: gog auth add requires interactive browser auth and cannot be completed over MCP — run it in your terminal instead: gog auth add <email> --services <service>',
69
+ description: 'Run any gog auth subcommand. Run `gog auth --help` to see all available subcommands and flags. Note: for browser-based authorization, use gog_auth_add instead.',
45
70
  annotations: { destructiveHint: true },
46
71
  inputSchema: {
47
72
  subcommand: z.string().describe('The gog auth subcommand, e.g. "remove", "alias", "tokens"'),
@@ -18,12 +18,12 @@ function makeSpawner(exitCode: number, stdout = '', stderr = ''): Spawner {
18
18
  }
19
19
 
20
20
  describe('run', () => {
21
- it('passes --json --no-input --color=never before service args', async () => {
21
+ it('passes --json --color=never --no-input before service args', async () => {
22
22
  const spawner = makeSpawner(0, '{"ok":true}');
23
23
  await run(['sheets', 'get', 'id1', 'A1'], { spawner });
24
24
  expect(spawner).toHaveBeenCalledWith(
25
25
  'gog',
26
- ['--json', '--no-input', '--color=never', 'sheets', 'get', 'id1', 'A1'],
26
+ ['--json', '--color=never', '--no-input', 'sheets', 'get', 'id1', 'A1'],
27
27
  expect.objectContaining({ env: process.env }),
28
28
  );
29
29
  });
@@ -33,7 +33,7 @@ describe('run', () => {
33
33
  await run(['sheets', 'metadata', 'id1'], { account: 'me@gmail.com', spawner });
34
34
  expect(spawner).toHaveBeenCalledWith(
35
35
  'gog',
36
- ['--json', '--no-input', '--color=never', '--account', 'me@gmail.com', 'sheets', 'metadata', 'id1'],
36
+ ['--json', '--color=never', '--no-input', '--account', 'me@gmail.com', 'sheets', 'metadata', 'id1'],
37
37
  expect.any(Object),
38
38
  );
39
39
  });
@@ -46,7 +46,7 @@ describe('run', () => {
46
46
  await run(['sheets', 'metadata', 'id1'], { spawner });
47
47
  expect(spawner).toHaveBeenCalledWith(
48
48
  'gog',
49
- ['--json', '--no-input', '--color=never', '--account', 'env@gmail.com', 'sheets', 'metadata', 'id1'],
49
+ ['--json', '--color=never', '--no-input', '--account', 'env@gmail.com', 'sheets', 'metadata', 'id1'],
50
50
  expect.any(Object),
51
51
  );
52
52
  } finally {
@@ -66,7 +66,7 @@ describe('run', () => {
66
66
  await run(['sheets', 'metadata', 'id1'], { account: 'override@gmail.com', spawner });
67
67
  expect(spawner).toHaveBeenCalledWith(
68
68
  'gog',
69
- ['--json', '--no-input', '--color=never', '--account', 'override@gmail.com', 'sheets', 'metadata', 'id1'],
69
+ ['--json', '--color=never', '--no-input', '--account', 'override@gmail.com', 'sheets', 'metadata', 'id1'],
70
70
  expect.any(Object),
71
71
  );
72
72
  } finally {
@@ -211,6 +211,95 @@ describe('run', () => {
211
211
  vi.useRealTimers();
212
212
  });
213
213
 
214
+ it('omits --no-input when interactive is true', async () => {
215
+ const spawner = makeSpawner(0, '{"ok":true}');
216
+ await run(['auth', 'add', 'user@gmail.com'], { spawner, interactive: true });
217
+ const callArgs = (spawner as ReturnType<typeof vi.fn>).mock.calls[0][1] as string[];
218
+ expect(callArgs).toContain('--json');
219
+ expect(callArgs).toContain('--color=never');
220
+ expect(callArgs).not.toContain('--no-input');
221
+ expect(callArgs).toContain('auth');
222
+ });
223
+
224
+ it('includes --no-input when interactive is not set', async () => {
225
+ const spawner = makeSpawner(0, '{"ok":true}');
226
+ await run(['sheets', 'get', 'id1', 'A1'], { spawner });
227
+ const callArgs = (spawner as ReturnType<typeof vi.fn>).mock.calls[0][1] as string[];
228
+ expect(callArgs).toContain('--no-input');
229
+ });
230
+
231
+ it('appends stderr to stdout on success when interactive is true', async () => {
232
+ const spawner = vi.fn(() => {
233
+ const proc = new EventEmitter() as ReturnType<Spawner>;
234
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stdout = new EventEmitter();
235
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stderr = new EventEmitter();
236
+ setTimeout(() => {
237
+ (proc as unknown as { stdout: EventEmitter }).stdout.emit('data', Buffer.from('{"success":true}'));
238
+ (proc as unknown as { stderr: EventEmitter }).stderr.emit('data', Buffer.from('Opening browser...\nIf the browser doesn\'t open, visit this URL:\nhttps://accounts.google.com/auth?...'));
239
+ proc.emit('close', 0);
240
+ }, 0);
241
+ return proc;
242
+ }) as unknown as Spawner;
243
+
244
+ const result = await run(['auth', 'add', 'user@gmail.com'], { spawner, interactive: true });
245
+ expect(result).toContain('{"success":true}');
246
+ expect(result).toContain('Opening browser...');
247
+ expect(result).toContain('https://accounts.google.com/auth?...');
248
+ });
249
+
250
+ it('does not append stderr to stdout on success when interactive is false', async () => {
251
+ const spawner = vi.fn(() => {
252
+ const proc = new EventEmitter() as ReturnType<Spawner>;
253
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stdout = new EventEmitter();
254
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stderr = new EventEmitter();
255
+ setTimeout(() => {
256
+ (proc as unknown as { stdout: EventEmitter }).stdout.emit('data', Buffer.from('{"ok":true}'));
257
+ (proc as unknown as { stderr: EventEmitter }).stderr.emit('data', Buffer.from('some warning'));
258
+ proc.emit('close', 0);
259
+ }, 0);
260
+ return proc;
261
+ }) as unknown as Spawner;
262
+
263
+ const result = await run(['sheets', 'get', 'id', 'A1'], { spawner });
264
+ expect(result).toBe('{"ok":true}');
265
+ expect(result).not.toContain('some warning');
266
+ });
267
+
268
+ it('uses custom timeout when provided', async () => {
269
+ vi.useFakeTimers();
270
+ const spawner = vi.fn(() => {
271
+ const proc = new EventEmitter() as ReturnType<Spawner>;
272
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stdout = new EventEmitter();
273
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stderr = new EventEmitter();
274
+ proc.kill = vi.fn();
275
+ return proc;
276
+ }) as unknown as Spawner;
277
+
278
+ const promise = run(['auth', 'add', 'user@gmail.com'], { spawner, timeout: 300_000 });
279
+ // Should NOT have timed out at 30s
280
+ vi.advanceTimersByTime(30_000);
281
+ // Advance to custom timeout
282
+ vi.advanceTimersByTime(270_000);
283
+ await expect(promise).rejects.toThrow('gog timed out after 300000ms (5 minutes)');
284
+ vi.useRealTimers();
285
+ });
286
+
287
+ it('includes human-readable duration in timeout error for default timeout', async () => {
288
+ vi.useFakeTimers();
289
+ const spawner = vi.fn(() => {
290
+ const proc = new EventEmitter() as ReturnType<Spawner>;
291
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stdout = new EventEmitter();
292
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stderr = new EventEmitter();
293
+ proc.kill = vi.fn();
294
+ return proc;
295
+ }) as unknown as Spawner;
296
+
297
+ const promise = run(['sheets', 'get', 'id', 'A1'], { spawner });
298
+ vi.advanceTimersByTime(30_000);
299
+ await expect(promise).rejects.toThrow('gog timed out after 30000ms');
300
+ vi.useRealTimers();
301
+ });
302
+
214
303
  it('ignores timeout if close event already settled the promise', async () => {
215
304
  vi.useFakeTimers();
216
305
  const spawner = vi.fn(() => {
@@ -78,6 +78,44 @@ describe('gog_auth_services', () => {
78
78
  });
79
79
  });
80
80
 
81
+ describe('gog_auth_add', () => {
82
+ it('calls run with correct args, interactive true, and 5-minute timeout', async () => {
83
+ vi.mocked(runner.run).mockResolvedValue('Authorization successful for user@gmail.com');
84
+ const handlers = setupHandlers();
85
+ const result = await handlers.get('gog_auth_add')!({ email: 'user@gmail.com' });
86
+ expect(runner.run).toHaveBeenCalledWith(
87
+ ['auth', 'add', 'user@gmail.com', '--services', 'all'],
88
+ { interactive: true, timeout: 300_000 },
89
+ );
90
+ expect(result.content[0].text).toBe('Authorization successful for user@gmail.com');
91
+ });
92
+
93
+ it('passes custom services when provided', async () => {
94
+ vi.mocked(runner.run).mockResolvedValue('Authorization successful');
95
+ const handlers = setupHandlers();
96
+ await handlers.get('gog_auth_add')!({ email: 'user@gmail.com', services: 'sheets,gmail' });
97
+ expect(runner.run).toHaveBeenCalledWith(
98
+ ['auth', 'add', 'user@gmail.com', '--services', 'sheets,gmail'],
99
+ { interactive: true, timeout: 300_000 },
100
+ );
101
+ });
102
+
103
+ it('returns error text on failure', async () => {
104
+ vi.mocked(runner.run).mockRejectedValue(new Error('Auth cancelled by user'));
105
+ const handlers = setupHandlers();
106
+ const result = await handlers.get('gog_auth_add')!({ email: 'user@gmail.com' });
107
+ expect(result.content[0].text).toBe('Error: Auth cancelled by user');
108
+ });
109
+
110
+ it('returns error text on timeout', async () => {
111
+ vi.mocked(runner.run).mockRejectedValue(new Error('gog timed out after 300000ms (5 minutes)'));
112
+ const handlers = setupHandlers();
113
+ const result = await handlers.get('gog_auth_add')!({ email: 'user@gmail.com' });
114
+ expect(result.content[0].text).toContain('timed out');
115
+ expect(result.content[0].text).toContain('5 minutes');
116
+ });
117
+ });
118
+
81
119
  describe('gog_auth_run', () => {
82
120
  it('passes subcommand and args to runner', async () => {
83
121
  vi.mocked(runner.run).mockResolvedValue('removed user@gmail.com');
Binary file