gogcli-mcp 1.0.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.
@@ -0,0 +1,758 @@
1
+ # gogcli-mcp 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:** Build a TypeScript MCP server that wraps the `gog` CLI, exposing Google Sheets operations as typed MCP tools, with scaffold for adding other services later.
6
+
7
+ **Architecture:** A `runner.ts` module handles all `gog` process execution (auth injection, stdout/stderr capture, error surfacing). Service tool files (starting with `sheets.ts`) import `run()` from runner and register tools on the MCP server. `index.ts` wires everything together via stdio transport.
8
+
9
+ **Tech Stack:** TypeScript (ESM, NodeNext), `@modelcontextprotocol/sdk`, Zod v4, vitest, esbuild
10
+
11
+ ---
12
+
13
+ ## File Map
14
+
15
+ | File | Purpose |
16
+ |------|---------|
17
+ | `package.json` | Dependencies, build and test scripts |
18
+ | `tsconfig.json` | TypeScript config (ES2022, NodeNext) |
19
+ | `vitest.config.ts` | Test config with 100% coverage thresholds |
20
+ | `src/runner.ts` | Spawns `gog`, injects auth flags, returns stdout or throws stderr |
21
+ | `src/tools/sheets.ts` | Registers 8 Sheets MCP tools (7 curated + 1 escape hatch) |
22
+ | `src/index.ts` | MCP server entry point (stdio transport, registers all tools) |
23
+ | `.mcp.json` | Claude Code MCP server configuration |
24
+ | `tests/runner.test.ts` | Unit tests for runner (mock spawner via DI) |
25
+ | `tests/tools/sheets.test.ts` | Unit tests for Sheets tools (mock runner module) |
26
+
27
+ ---
28
+
29
+ ## Task 1: Project Scaffold
30
+
31
+ **Files:**
32
+ - Create: `package.json`
33
+ - Create: `tsconfig.json`
34
+ - Create: `vitest.config.ts`
35
+
36
+ - [ ] **Step 1: Write `package.json`**
37
+
38
+ ```json
39
+ {
40
+ "name": "gogcli-mcp",
41
+ "version": "1.0.0",
42
+ "description": "MCP server wrapping gogcli for Google service access",
43
+ "type": "module",
44
+ "bin": {
45
+ "gogcli-mcp": "dist/index.js"
46
+ },
47
+ "scripts": {
48
+ "build": "tsc && npm run bundle",
49
+ "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js",
50
+ "test": "vitest run",
51
+ "test:watch": "vitest",
52
+ "test:coverage": "vitest run --coverage"
53
+ },
54
+ "dependencies": {
55
+ "@modelcontextprotocol/sdk": "^1.29.0",
56
+ "zod": "^4.3.6"
57
+ },
58
+ "devDependencies": {
59
+ "@types/node": "^25.5.2",
60
+ "@vitest/coverage-v8": "^4.1.2",
61
+ "esbuild": "^0.28.0",
62
+ "typescript": "^6.0.2",
63
+ "vitest": "^4.1.2"
64
+ }
65
+ }
66
+ ```
67
+
68
+ - [ ] **Step 2: Write `tsconfig.json`**
69
+
70
+ ```json
71
+ {
72
+ "compilerOptions": {
73
+ "target": "ES2022",
74
+ "module": "NodeNext",
75
+ "moduleResolution": "NodeNext",
76
+ "outDir": "./dist",
77
+ "rootDir": "./src",
78
+ "strict": true,
79
+ "esModuleInterop": true,
80
+ "skipLibCheck": true
81
+ },
82
+ "include": ["src/**/*"],
83
+ "exclude": ["node_modules", "dist"]
84
+ }
85
+ ```
86
+
87
+ - [ ] **Step 3: Write `vitest.config.ts`**
88
+
89
+ ```typescript
90
+ import { defineConfig } from 'vitest/config';
91
+
92
+ export default defineConfig({
93
+ test: {
94
+ coverage: {
95
+ provider: 'v8',
96
+ include: ['src/**/*.ts'],
97
+ exclude: ['src/index.ts'],
98
+ thresholds: {
99
+ lines: 100,
100
+ functions: 100,
101
+ branches: 100,
102
+ statements: 100,
103
+ },
104
+ },
105
+ },
106
+ });
107
+ ```
108
+
109
+ - [ ] **Step 4: Install dependencies**
110
+
111
+ ```bash
112
+ npm install
113
+ ```
114
+
115
+ Expected: `node_modules/` created, no errors.
116
+
117
+ - [ ] **Step 5: Commit**
118
+
119
+ ```bash
120
+ git add package.json tsconfig.json vitest.config.ts package-lock.json
121
+ git commit -m "chore: project scaffold"
122
+ ```
123
+
124
+ ---
125
+
126
+ ## Task 2: runner.ts with Tests
127
+
128
+ **Files:**
129
+ - Create: `tests/runner.test.ts`
130
+ - Create: `src/runner.ts`
131
+
132
+ The runner is the only module that touches `child_process`. It accepts a `spawner` parameter (default: the real `spawn`) so tests can inject a mock without module-level mocking. It builds the full `gog` invocation: prepends `--json --no-input --color=never`, injects `--account` from `options.account` then `GOG_ACCOUNT` env var, appends the caller-supplied args.
133
+
134
+ - [ ] **Step 1: Create `tests/runner.test.ts` with failing tests**
135
+
136
+ ```typescript
137
+ import { describe, it, expect, vi } from 'vitest';
138
+ import { EventEmitter } from 'node:events';
139
+ import { run } from '../src/runner.js';
140
+ import type { Spawner } from '../src/runner.js';
141
+
142
+ function makeSpawner(exitCode: number, stdout = '', stderr = ''): Spawner {
143
+ return vi.fn(() => {
144
+ const proc = new EventEmitter() as ReturnType<Spawner>;
145
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stdout = new EventEmitter();
146
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stderr = new EventEmitter();
147
+ setTimeout(() => {
148
+ (proc as unknown as { stdout: EventEmitter }).stdout.emit('data', Buffer.from(stdout));
149
+ (proc as unknown as { stderr: EventEmitter }).stderr.emit('data', Buffer.from(stderr));
150
+ proc.emit('close', exitCode);
151
+ }, 0);
152
+ return proc;
153
+ }) as unknown as Spawner;
154
+ }
155
+
156
+ describe('run', () => {
157
+ it('passes --json --no-input --color=never before service args', async () => {
158
+ const spawner = makeSpawner(0, '{"ok":true}');
159
+ await run(['sheets', 'get', 'id1', 'A1'], { spawner });
160
+ expect(spawner).toHaveBeenCalledWith(
161
+ 'gog',
162
+ ['--json', '--no-input', '--color=never', 'sheets', 'get', 'id1', 'A1'],
163
+ expect.objectContaining({ env: process.env }),
164
+ );
165
+ });
166
+
167
+ it('injects --account from options.account', async () => {
168
+ const spawner = makeSpawner(0, '{}');
169
+ await run(['sheets', 'metadata', 'id1'], { account: 'me@gmail.com', spawner });
170
+ expect(spawner).toHaveBeenCalledWith(
171
+ 'gog',
172
+ ['--json', '--no-input', '--color=never', '--account', 'me@gmail.com', 'sheets', 'metadata', 'id1'],
173
+ expect.any(Object),
174
+ );
175
+ });
176
+
177
+ it('injects --account from GOG_ACCOUNT env var when no options.account', async () => {
178
+ const spawner = makeSpawner(0, '{}');
179
+ const originalEnv = process.env.GOG_ACCOUNT;
180
+ process.env.GOG_ACCOUNT = 'env@gmail.com';
181
+ try {
182
+ await run(['sheets', 'metadata', 'id1'], { spawner });
183
+ expect(spawner).toHaveBeenCalledWith(
184
+ 'gog',
185
+ ['--json', '--no-input', '--color=never', '--account', 'env@gmail.com', 'sheets', 'metadata', 'id1'],
186
+ expect.any(Object),
187
+ );
188
+ } finally {
189
+ if (originalEnv === undefined) {
190
+ delete process.env.GOG_ACCOUNT;
191
+ } else {
192
+ process.env.GOG_ACCOUNT = originalEnv;
193
+ }
194
+ }
195
+ });
196
+
197
+ it('options.account takes precedence over GOG_ACCOUNT env var', async () => {
198
+ const spawner = makeSpawner(0, '{}');
199
+ const originalEnv = process.env.GOG_ACCOUNT;
200
+ process.env.GOG_ACCOUNT = 'env@gmail.com';
201
+ try {
202
+ await run(['sheets', 'metadata', 'id1'], { account: 'override@gmail.com', spawner });
203
+ const callArgs = (spawner as ReturnType<typeof vi.fn>).mock.calls[0][1] as string[];
204
+ const accountIdx = callArgs.indexOf('--account');
205
+ expect(callArgs[accountIdx + 1]).toBe('override@gmail.com');
206
+ } finally {
207
+ if (originalEnv === undefined) {
208
+ delete process.env.GOG_ACCOUNT;
209
+ } else {
210
+ process.env.GOG_ACCOUNT = originalEnv;
211
+ }
212
+ }
213
+ });
214
+
215
+ it('omits --account when neither options.account nor GOG_ACCOUNT is set', async () => {
216
+ const spawner = makeSpawner(0, '{}');
217
+ const originalEnv = process.env.GOG_ACCOUNT;
218
+ delete process.env.GOG_ACCOUNT;
219
+ try {
220
+ await run(['sheets', 'metadata', 'id1'], { spawner });
221
+ const callArgs = (spawner as ReturnType<typeof vi.fn>).mock.calls[0][1] as string[];
222
+ expect(callArgs).not.toContain('--account');
223
+ } finally {
224
+ if (originalEnv !== undefined) {
225
+ process.env.GOG_ACCOUNT = originalEnv;
226
+ }
227
+ }
228
+ });
229
+
230
+ it('returns stdout on exit code 0', async () => {
231
+ const spawner = makeSpawner(0, '{"values":[["hello"]]}');
232
+ const result = await run(['sheets', 'get', 'id1', 'A1'], { spawner });
233
+ expect(result).toBe('{"values":[["hello"]]}');
234
+ });
235
+
236
+ it('throws with stderr message on non-zero exit', async () => {
237
+ const spawner = makeSpawner(1, '', 'Spreadsheet not found');
238
+ await expect(run(['sheets', 'get', 'bad', 'A1'], { spawner }))
239
+ .rejects.toThrow('Spreadsheet not found');
240
+ });
241
+
242
+ it('throws with fallback message when stderr is empty on non-zero exit', async () => {
243
+ const spawner = makeSpawner(2, '', '');
244
+ await expect(run(['sheets', 'get', 'bad', 'A1'], { spawner }))
245
+ .rejects.toThrow('gog exited with code 2');
246
+ });
247
+
248
+ it('rejects on spawn error', async () => {
249
+ const spawner = vi.fn(() => {
250
+ const proc = new EventEmitter() as ReturnType<Spawner>;
251
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stdout = new EventEmitter();
252
+ (proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stderr = new EventEmitter();
253
+ setTimeout(() => proc.emit('error', new Error('gog not found')), 0);
254
+ return proc;
255
+ }) as unknown as Spawner;
256
+ await expect(run(['sheets', 'get', 'id', 'A1'], { spawner }))
257
+ .rejects.toThrow('gog not found');
258
+ });
259
+ });
260
+ ```
261
+
262
+ - [ ] **Step 2: Run tests to confirm they fail**
263
+
264
+ ```bash
265
+ npm test
266
+ ```
267
+
268
+ Expected: FAIL — `src/runner.ts` does not exist.
269
+
270
+ - [ ] **Step 3: Create `src/runner.ts`**
271
+
272
+ ```typescript
273
+ import { spawn } from 'node:child_process';
274
+ import type { ChildProcess } from 'node:child_process';
275
+
276
+ export type Spawner = (
277
+ command: string,
278
+ args: string[],
279
+ options: { env: NodeJS.ProcessEnv },
280
+ ) => ChildProcess;
281
+
282
+ export interface RunOptions {
283
+ account?: string;
284
+ spawner?: Spawner;
285
+ }
286
+
287
+ export async function run(args: string[], options: RunOptions = {}): Promise<string> {
288
+ const { account, spawner = spawn as unknown as Spawner } = options;
289
+
290
+ const effectiveAccount = account ?? process.env.GOG_ACCOUNT;
291
+
292
+ const fullArgs = ['--json', '--no-input', '--color=never'];
293
+ if (effectiveAccount) {
294
+ fullArgs.push('--account', effectiveAccount);
295
+ }
296
+ fullArgs.push(...args);
297
+
298
+ return new Promise((resolve, reject) => {
299
+ const child = spawner('gog', fullArgs, { env: process.env });
300
+ let stdout = '';
301
+ let stderr = '';
302
+
303
+ child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
304
+ child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
305
+
306
+ child.on('close', (code: number | null) => {
307
+ if (code === 0) {
308
+ resolve(stdout);
309
+ } else {
310
+ reject(new Error(stderr.trim() || `gog exited with code ${code}`));
311
+ }
312
+ });
313
+
314
+ child.on('error', reject);
315
+ });
316
+ }
317
+ ```
318
+
319
+ - [ ] **Step 4: Run tests and confirm they pass**
320
+
321
+ ```bash
322
+ npm test
323
+ ```
324
+
325
+ Expected: All 8 runner tests PASS.
326
+
327
+ - [ ] **Step 5: Commit**
328
+
329
+ ```bash
330
+ git add src/runner.ts tests/runner.test.ts
331
+ git commit -m "feat: add runner module with unit tests"
332
+ ```
333
+
334
+ ---
335
+
336
+ ## Task 3: Sheets Tools with Tests
337
+
338
+ **Files:**
339
+ - Create: `tests/tools/sheets.test.ts`
340
+ - Create: `src/tools/sheets.ts`
341
+
342
+ Each curated tool calls `run(['sheets', <subcommand>, ...positional-args, ...flag-args], { account })`. The `gog_sheets_update` and `gog_sheets_append` tools pass values via `--values-json=<JSON>` (gogcli's structured input flag for 2D arrays). All tools catch errors and return them as text content so the model can see gogcli's error message.
343
+
344
+ - [ ] **Step 1: Create `tests/tools/sheets.test.ts` with failing tests**
345
+
346
+ ```typescript
347
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
348
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
349
+ import { registerSheetsTools } from '../../src/tools/sheets.js';
350
+ import * as runner from '../../src/runner.js';
351
+
352
+ vi.mock('../../src/runner.js');
353
+
354
+ type ToolHandler = (args: Record<string, unknown>) => Promise<{ content: Array<{ type: string; text: string }> }>;
355
+
356
+ function setupHandlers(): Map<string, ToolHandler> {
357
+ const server = new McpServer({ name: 'test', version: '0.0.0' });
358
+ const handlers = new Map<string, ToolHandler>();
359
+ vi.spyOn(server, 'registerTool').mockImplementation((name, _config, cb) => {
360
+ handlers.set(name, cb as ToolHandler);
361
+ return undefined as never;
362
+ });
363
+ registerSheetsTools(server);
364
+ return handlers;
365
+ }
366
+
367
+ beforeEach(() => vi.clearAllMocks());
368
+
369
+ describe('gog_sheets_get', () => {
370
+ it('calls run with correct args', async () => {
371
+ vi.mocked(runner.run).mockResolvedValue('{"values":[["a","b"]]}');
372
+ const handlers = setupHandlers();
373
+ const result = await handlers.get('gog_sheets_get')!({ spreadsheetId: 'sid', range: 'Sheet1!A1:B2' });
374
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'get', 'sid', 'Sheet1!A1:B2'], { account: undefined });
375
+ expect(result.content[0].text).toBe('{"values":[["a","b"]]}');
376
+ });
377
+
378
+ it('forwards account override', async () => {
379
+ vi.mocked(runner.run).mockResolvedValue('{}');
380
+ const handlers = setupHandlers();
381
+ await handlers.get('gog_sheets_get')!({ spreadsheetId: 'sid', range: 'A1', account: 'other@gmail.com' });
382
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'get', 'sid', 'A1'], { account: 'other@gmail.com' });
383
+ });
384
+
385
+ it('returns error text on failure', async () => {
386
+ vi.mocked(runner.run).mockRejectedValue(new Error('Spreadsheet not found'));
387
+ const handlers = setupHandlers();
388
+ const result = await handlers.get('gog_sheets_get')!({ spreadsheetId: 'bad', range: 'A1' });
389
+ expect(result.content[0].text).toBe('Error: Spreadsheet not found');
390
+ });
391
+ });
392
+
393
+ describe('gog_sheets_update', () => {
394
+ it('passes values via --values-json flag', async () => {
395
+ vi.mocked(runner.run).mockResolvedValue('{"updatedCells":2}');
396
+ const handlers = setupHandlers();
397
+ const values = [['hello', 'world']];
398
+ await handlers.get('gog_sheets_update')!({ spreadsheetId: 'sid', range: 'A1:B1', values });
399
+ expect(runner.run).toHaveBeenCalledWith(
400
+ ['sheets', 'update', 'sid', 'A1:B1', `--values-json=${JSON.stringify(values)}`],
401
+ { account: undefined },
402
+ );
403
+ });
404
+ });
405
+
406
+ describe('gog_sheets_append', () => {
407
+ it('passes values via --values-json flag', async () => {
408
+ vi.mocked(runner.run).mockResolvedValue('{"updates":{}}');
409
+ const handlers = setupHandlers();
410
+ const values = [['r1c1', 'r1c2'], ['r2c1', 'r2c2']];
411
+ await handlers.get('gog_sheets_append')!({ spreadsheetId: 'sid', range: 'Sheet1!A:B', values });
412
+ expect(runner.run).toHaveBeenCalledWith(
413
+ ['sheets', 'append', 'sid', 'Sheet1!A:B', `--values-json=${JSON.stringify(values)}`],
414
+ { account: undefined },
415
+ );
416
+ });
417
+ });
418
+
419
+ describe('gog_sheets_clear', () => {
420
+ it('calls run with correct args', async () => {
421
+ vi.mocked(runner.run).mockResolvedValue('{}');
422
+ const handlers = setupHandlers();
423
+ await handlers.get('gog_sheets_clear')!({ spreadsheetId: 'sid', range: 'Sheet1!A1:Z100' });
424
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'clear', 'sid', 'Sheet1!A1:Z100'], { account: undefined });
425
+ });
426
+ });
427
+
428
+ describe('gog_sheets_metadata', () => {
429
+ it('calls run with spreadsheetId only', async () => {
430
+ vi.mocked(runner.run).mockResolvedValue('{"title":"My Sheet","sheets":[{"title":"Sheet1"}]}');
431
+ const handlers = setupHandlers();
432
+ const result = await handlers.get('gog_sheets_metadata')!({ spreadsheetId: 'sid' });
433
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'metadata', 'sid'], { account: undefined });
434
+ expect(result.content[0].text).toContain('My Sheet');
435
+ });
436
+ });
437
+
438
+ describe('gog_sheets_create', () => {
439
+ it('calls run with title', async () => {
440
+ vi.mocked(runner.run).mockResolvedValue('{"spreadsheetId":"newid","title":"Budget 2026"}');
441
+ const handlers = setupHandlers();
442
+ await handlers.get('gog_sheets_create')!({ title: 'Budget 2026' });
443
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'create', 'Budget 2026'], { account: undefined });
444
+ });
445
+ });
446
+
447
+ describe('gog_sheets_find_replace', () => {
448
+ it('calls run with find and replace args', async () => {
449
+ vi.mocked(runner.run).mockResolvedValue('{"occurrencesChanged":3}');
450
+ const handlers = setupHandlers();
451
+ await handlers.get('gog_sheets_find_replace')!({ spreadsheetId: 'sid', find: 'foo', replace: 'bar' });
452
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'find-replace', 'sid', 'foo', 'bar'], { account: undefined });
453
+ });
454
+ });
455
+
456
+ describe('gog_sheets_run', () => {
457
+ it('passes raw subcommand and args to runner', async () => {
458
+ vi.mocked(runner.run).mockResolvedValue('{}');
459
+ const handlers = setupHandlers();
460
+ await handlers.get('gog_sheets_run')!({
461
+ subcommand: 'freeze',
462
+ args: ['sid', '--rows=1'],
463
+ });
464
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'freeze', 'sid', '--rows=1'], { account: undefined });
465
+ });
466
+
467
+ it('works with empty args array', async () => {
468
+ vi.mocked(runner.run).mockResolvedValue('{}');
469
+ const handlers = setupHandlers();
470
+ await handlers.get('gog_sheets_run')!({ subcommand: 'metadata', args: [] });
471
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'metadata'], { account: undefined });
472
+ });
473
+ });
474
+ ```
475
+
476
+ - [ ] **Step 2: Run tests to confirm they fail**
477
+
478
+ ```bash
479
+ npm test
480
+ ```
481
+
482
+ Expected: FAIL — `src/tools/sheets.ts` does not exist.
483
+
484
+ - [ ] **Step 3: Create `src/tools/sheets.ts`**
485
+
486
+ ```typescript
487
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
488
+ import { z } from 'zod';
489
+ import { run } from '../runner.js';
490
+
491
+ const accountParam = z.string().optional().describe(
492
+ 'Google account email to use (overrides GOG_ACCOUNT env var)',
493
+ );
494
+
495
+ function toText(output: string): { content: [{ type: 'text'; text: string }] } {
496
+ return { content: [{ type: 'text' as const, text: output }] };
497
+ }
498
+
499
+ function toError(err: unknown): { content: [{ type: 'text'; text: string }] } {
500
+ return toText(err instanceof Error ? `Error: ${err.message}` : String(err));
501
+ }
502
+
503
+ export function registerSheetsTools(server: McpServer): void {
504
+ server.registerTool('gog_sheets_get', {
505
+ description: 'Read values from a Google Sheets range. Returns a JSON object with a "values" array of rows.',
506
+ annotations: { readOnlyHint: true },
507
+ inputSchema: {
508
+ spreadsheetId: z.string().describe('Spreadsheet ID (from the URL)'),
509
+ range: z.string().describe('Range in A1 notation, e.g. Sheet1!A1:B10 or a named range'),
510
+ account: accountParam,
511
+ },
512
+ }, async ({ spreadsheetId, range, account }) => {
513
+ try {
514
+ return toText(await run(['sheets', 'get', spreadsheetId, range], { account }));
515
+ } catch (err) {
516
+ return toError(err);
517
+ }
518
+ });
519
+
520
+ server.registerTool('gog_sheets_update', {
521
+ description: 'Write values to a Google Sheets range, overwriting existing content.',
522
+ annotations: { destructiveHint: true },
523
+ inputSchema: {
524
+ spreadsheetId: z.string().describe('Spreadsheet ID (from the URL)'),
525
+ range: z.string().describe('Top-left cell or range in A1 notation, e.g. Sheet1!A1'),
526
+ values: z.array(z.array(z.string())).describe('2D array of values: outer array is rows, inner is columns'),
527
+ account: accountParam,
528
+ },
529
+ }, async ({ spreadsheetId, range, values, account }) => {
530
+ try {
531
+ return toText(await run(
532
+ ['sheets', 'update', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`],
533
+ { account },
534
+ ));
535
+ } catch (err) {
536
+ return toError(err);
537
+ }
538
+ });
539
+
540
+ server.registerTool('gog_sheets_append', {
541
+ description: 'Append rows to a Google Sheet after the last row with data in the given range.',
542
+ annotations: { destructiveHint: true },
543
+ inputSchema: {
544
+ spreadsheetId: z.string().describe('Spreadsheet ID (from the URL)'),
545
+ range: z.string().describe('Range indicating which sheet/columns to append to, e.g. Sheet1!A:C'),
546
+ values: z.array(z.array(z.string())).describe('2D array of rows to append'),
547
+ account: accountParam,
548
+ },
549
+ }, async ({ spreadsheetId, range, values, account }) => {
550
+ try {
551
+ return toText(await run(
552
+ ['sheets', 'append', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`],
553
+ { account },
554
+ ));
555
+ } catch (err) {
556
+ return toError(err);
557
+ }
558
+ });
559
+
560
+ server.registerTool('gog_sheets_clear', {
561
+ description: 'Clear all values in a Google Sheets range (formatting is preserved).',
562
+ annotations: { destructiveHint: true },
563
+ inputSchema: {
564
+ spreadsheetId: z.string().describe('Spreadsheet ID'),
565
+ range: z.string().describe('Range in A1 notation to clear'),
566
+ account: accountParam,
567
+ },
568
+ }, async ({ spreadsheetId, range, account }) => {
569
+ try {
570
+ return toText(await run(['sheets', 'clear', spreadsheetId, range], { account }));
571
+ } catch (err) {
572
+ return toError(err);
573
+ }
574
+ });
575
+
576
+ server.registerTool('gog_sheets_metadata', {
577
+ description: 'Get spreadsheet metadata: title, sheet tabs, named ranges, and other properties.',
578
+ annotations: { readOnlyHint: true },
579
+ inputSchema: {
580
+ spreadsheetId: z.string().describe('Spreadsheet ID'),
581
+ account: accountParam,
582
+ },
583
+ }, async ({ spreadsheetId, account }) => {
584
+ try {
585
+ return toText(await run(['sheets', 'metadata', spreadsheetId], { account }));
586
+ } catch (err) {
587
+ return toError(err);
588
+ }
589
+ });
590
+
591
+ server.registerTool('gog_sheets_create', {
592
+ description: 'Create a new Google Spreadsheet. Returns JSON with the new spreadsheetId and URL.',
593
+ inputSchema: {
594
+ title: z.string().describe('Title for the new spreadsheet'),
595
+ account: accountParam,
596
+ },
597
+ }, async ({ title, account }) => {
598
+ try {
599
+ return toText(await run(['sheets', 'create', title], { account }));
600
+ } catch (err) {
601
+ return toError(err);
602
+ }
603
+ });
604
+
605
+ server.registerTool('gog_sheets_find_replace', {
606
+ description: 'Find and replace text across an entire Google Spreadsheet.',
607
+ inputSchema: {
608
+ spreadsheetId: z.string().describe('Spreadsheet ID'),
609
+ find: z.string().describe('Text to find'),
610
+ replace: z.string().describe('Replacement text'),
611
+ account: accountParam,
612
+ },
613
+ }, async ({ spreadsheetId, find, replace, account }) => {
614
+ try {
615
+ return toText(await run(['sheets', 'find-replace', spreadsheetId, find, replace], { account }));
616
+ } catch (err) {
617
+ return toError(err);
618
+ }
619
+ });
620
+
621
+ server.registerTool('gog_sheets_run', {
622
+ description: 'Run any gog sheets subcommand not covered by the other tools. See `gog sheets --help` for the full list of subcommands and their flags.',
623
+ inputSchema: {
624
+ subcommand: z.string().describe('The gog sheets subcommand to run, e.g. "freeze", "add-tab", "rename-tab"'),
625
+ args: z.array(z.string()).describe('Additional positional args and flags, e.g. ["<spreadsheetId>", "--rows=1"]'),
626
+ account: accountParam,
627
+ },
628
+ }, async ({ subcommand, args, account }) => {
629
+ try {
630
+ return toText(await run(['sheets', subcommand, ...args], { account }));
631
+ } catch (err) {
632
+ return toError(err);
633
+ }
634
+ });
635
+ }
636
+ ```
637
+
638
+ - [ ] **Step 4: Run tests and confirm they pass**
639
+
640
+ ```bash
641
+ npm test
642
+ ```
643
+
644
+ Expected: All tests PASS (runner tests + sheets tests).
645
+
646
+ - [ ] **Step 5: Commit**
647
+
648
+ ```bash
649
+ git add src/tools/sheets.ts tests/tools/sheets.test.ts
650
+ git commit -m "feat: add Sheets MCP tools with unit tests"
651
+ ```
652
+
653
+ ---
654
+
655
+ ## Task 4: Server Entry Point and MCP Config
656
+
657
+ **Files:**
658
+ - Create: `src/index.ts`
659
+ - Create: `.mcp.json`
660
+
661
+ - [ ] **Step 1: Create `src/index.ts`**
662
+
663
+ ```typescript
664
+ #!/usr/bin/env node
665
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
666
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
667
+ import { registerSheetsTools } from './tools/sheets.js';
668
+
669
+ const server = new McpServer({ name: 'gogcli', version: '1.0.0' });
670
+
671
+ registerSheetsTools(server);
672
+
673
+ // To add more services: import registerXxxTools and call them here.
674
+ // Example: registerGmailTools(server);
675
+
676
+ const transport = new StdioServerTransport();
677
+ await server.connect(transport);
678
+ ```
679
+
680
+ - [ ] **Step 2: Create `.mcp.json`**
681
+
682
+ ```json
683
+ {
684
+ "mcpServers": {
685
+ "gogcli": {
686
+ "command": "node",
687
+ "args": ["dist/index.js"],
688
+ "env": {
689
+ "GOG_ACCOUNT": "${GOG_ACCOUNT}"
690
+ }
691
+ }
692
+ }
693
+ }
694
+ ```
695
+
696
+ - [ ] **Step 3: Commit**
697
+
698
+ ```bash
699
+ git add src/index.ts .mcp.json
700
+ git commit -m "feat: add MCP server entry point and config"
701
+ ```
702
+
703
+ ---
704
+
705
+ ## Task 5: Build and Smoke Test
706
+
707
+ **Files:** none new — verifies the build pipeline works end-to-end.
708
+
709
+ - [ ] **Step 1: Run the TypeScript compiler**
710
+
711
+ ```bash
712
+ npx tsc --noEmit
713
+ ```
714
+
715
+ Expected: No errors.
716
+
717
+ - [ ] **Step 2: Build the bundle**
718
+
719
+ ```bash
720
+ npm run build
721
+ ```
722
+
723
+ Expected: `dist/index.js` created, no errors.
724
+
725
+ - [ ] **Step 3: Run full test suite with coverage**
726
+
727
+ ```bash
728
+ npm run test:coverage
729
+ ```
730
+
731
+ Expected: All tests pass, 100% coverage on `src/runner.ts` and `src/tools/sheets.ts`.
732
+
733
+ - [ ] **Step 4: Smoke test the server starts**
734
+
735
+ ```bash
736
+ echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | node dist/index.js
737
+ ```
738
+
739
+ Expected: JSON response listing all 8 `gog_sheets_*` tools.
740
+
741
+ - [ ] **Step 5: Commit**
742
+
743
+ ```bash
744
+ git add dist/
745
+ git commit -m "build: add compiled bundle"
746
+ ```
747
+
748
+ ---
749
+
750
+ ## Adding Future Services (Reference)
751
+
752
+ When you want to add Gmail, Calendar, or another service:
753
+
754
+ 1. Create `src/tools/<service>.ts` — export `registerXxxTools(server: McpServer)`
755
+ 2. Implement curated tools for common operations + a `gog_<service>_run` escape hatch (same pattern as sheets.ts)
756
+ 3. Create `tests/tools/<service>.test.ts` — mock `runner.run` via `vi.mock('../../src/runner.js')`
757
+ 4. In `src/index.ts`, import `registerXxxTools` and call it after `registerSheetsTools(server)`
758
+ 5. No changes to `runner.ts` or `.mcp.json` required