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 +42 -7
- package/docs/superpowers/plans/2026-04-13-browser-auth.md +450 -0
- package/docs/superpowers/specs/2026-04-13-browser-auth-design.md +88 -0
- package/{gogcli-mcp-1.0.2.skill → gogcli-mcp-1.0.3.skill} +0 -0
- package/manifest.json +5 -1
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/runner.ts +25 -5
- package/src/tools/auth.ts +26 -1
- package/tests/runner.test.ts +94 -5
- package/tests/tools/auth.test.ts +38 -0
- package/gogcli-mcp-1.0.2.mcpb +0 -0
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", "--
|
|
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 ${
|
|
30136
|
-
},
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
|
Binary file
|
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.
|
|
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
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.
|
|
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', '--
|
|
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 ${
|
|
38
|
-
},
|
|
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
|
-
|
|
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:
|
|
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"'),
|
package/tests/runner.test.ts
CHANGED
|
@@ -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
|
|
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', '--
|
|
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', '--
|
|
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', '--
|
|
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', '--
|
|
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(() => {
|
package/tests/tools/auth.test.ts
CHANGED
|
@@ -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');
|
package/gogcli-mcp-1.0.2.mcpb
DELETED
|
Binary file
|