gogcli-mcp 2.0.11 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +42 -0
- package/dist/index.js +132 -34
- package/dist/lib.js +132 -34
- package/manifest.json +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/tools/calendar.ts +12 -4
- package/src/tools/gmail.ts +1 -1
- package/src/tools/sheets-a1.ts +64 -0
- package/src/tools/sheets.ts +54 -14
- package/src/tools/utils.ts +59 -12
- package/tests/tools/calendar.test.ts +81 -0
- package/tests/tools/sheets-a1.test.ts +70 -0
- package/tests/tools/sheets.test.ts +162 -0
- package/tests/tools/utils.test.ts +103 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
3
|
import { registerSheetsTools } from '../../src/tools/sheets.js';
|
|
3
4
|
import * as runner from '../../src/runner.js';
|
|
4
5
|
import { setupHandlers as setupHandlersBase, type ToolHandler } from '../helpers/test-harness.js';
|
|
@@ -84,6 +85,135 @@ describe('gog_sheets_update', () => {
|
|
|
84
85
|
const result = await handlers.get('gog_sheets_update')!({ spreadsheetId: 'bad', range: 'A1', values: [['x']] });
|
|
85
86
|
expect(result.content[0].text).toBe('Error: Update failed');
|
|
86
87
|
});
|
|
88
|
+
|
|
89
|
+
it('appends --dry-run when dry_run is true', async () => {
|
|
90
|
+
vi.mocked(runner.run).mockResolvedValue('{"dryRun":true}');
|
|
91
|
+
const handlers = setupHandlers();
|
|
92
|
+
const values = [['x']];
|
|
93
|
+
await handlers.get('gog_sheets_update')!({ spreadsheetId: 'sid', range: 'A1', values, dry_run: true });
|
|
94
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
95
|
+
['sheets', 'update', 'sid', 'A1', `--values-json=${JSON.stringify(values)}`, '--dry-run'],
|
|
96
|
+
{ account: undefined },
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('omits --dry-run when dry_run is false or unset', async () => {
|
|
101
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
102
|
+
const handlers = setupHandlers();
|
|
103
|
+
await handlers.get('gog_sheets_update')!({ spreadsheetId: 'sid', range: 'A1', values: [['x']], dry_run: false });
|
|
104
|
+
expect(vi.mocked(runner.run).mock.calls[0]![0]).not.toContain('--dry-run');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('gog_sheets_update fail_if_not_empty guard', () => {
|
|
109
|
+
it('aborts the write when the target range already holds data', async () => {
|
|
110
|
+
vi.mocked(runner.run).mockResolvedValueOnce('{"values":[["existing"]]}');
|
|
111
|
+
const handlers = setupHandlers();
|
|
112
|
+
const result = await handlers.get('gog_sheets_update')!({
|
|
113
|
+
spreadsheetId: 'sid', range: 'A1', values: [['new']], fail_if_not_empty: true,
|
|
114
|
+
});
|
|
115
|
+
// Only the read happened — no update call.
|
|
116
|
+
expect(runner.run).toHaveBeenCalledTimes(1);
|
|
117
|
+
expect(runner.run).toHaveBeenCalledWith(['sheets', 'get', 'sid', 'A1:A1'], { account: undefined });
|
|
118
|
+
expect(result.content[0].text).toContain('Write aborted');
|
|
119
|
+
expect(result.content[0].text).toContain('A1:A1');
|
|
120
|
+
expect(result.content[0].text).toContain('1 cell');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('proceeds with the write when the target range is empty', async () => {
|
|
124
|
+
vi.mocked(runner.run)
|
|
125
|
+
.mockResolvedValueOnce('{}')
|
|
126
|
+
.mockResolvedValueOnce('{"updatedCells":1}');
|
|
127
|
+
const handlers = setupHandlers();
|
|
128
|
+
const values = [['new']];
|
|
129
|
+
const result = await handlers.get('gog_sheets_update')!({
|
|
130
|
+
spreadsheetId: 'sid', range: 'A1', values, fail_if_not_empty: true,
|
|
131
|
+
});
|
|
132
|
+
expect(runner.run).toHaveBeenCalledTimes(2);
|
|
133
|
+
expect(vi.mocked(runner.run).mock.calls[1]![0]).toEqual(
|
|
134
|
+
['sheets', 'update', 'sid', 'A1', `--values-json=${JSON.stringify(values)}`],
|
|
135
|
+
);
|
|
136
|
+
expect(result.content[0].text).toBe('{"updatedCells":1}');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('expands an anchor cell to the full written area before reading', async () => {
|
|
140
|
+
vi.mocked(runner.run)
|
|
141
|
+
.mockResolvedValueOnce('{}')
|
|
142
|
+
.mockResolvedValueOnce('{}');
|
|
143
|
+
const handlers = setupHandlers();
|
|
144
|
+
await handlers.get('gog_sheets_update')!({
|
|
145
|
+
spreadsheetId: 'sid', range: 'Sheet1!A1',
|
|
146
|
+
values: [['a', 'b'], ['c', 'd'], ['e', 'f']], fail_if_not_empty: true,
|
|
147
|
+
});
|
|
148
|
+
expect(vi.mocked(runner.run).mock.calls[0]![0]).toEqual(['sheets', 'get', 'sid', 'Sheet1!A1:B3']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('aborts without writing and diagnoses the error when the verification read fails', async () => {
|
|
152
|
+
vi.mocked(runner.run)
|
|
153
|
+
.mockRejectedValueOnce(new Error('read boom')) // the verification get
|
|
154
|
+
.mockResolvedValueOnce('{"accounts":[{"email":"u@x.com"}]}'); // diagnose -> auth list
|
|
155
|
+
const handlers = setupHandlers();
|
|
156
|
+
const result = await handlers.get('gog_sheets_update')!({
|
|
157
|
+
spreadsheetId: 'sid', range: 'A1', values: [['new']], fail_if_not_empty: true,
|
|
158
|
+
});
|
|
159
|
+
// get + auth list (for diagnosis), but the update is never attempted
|
|
160
|
+
expect(runner.run).toHaveBeenCalledTimes(2);
|
|
161
|
+
expect(vi.mocked(runner.run).mock.calls.some((c) => c[0][1] === 'update')).toBe(false);
|
|
162
|
+
expect(result.content[0].text).toContain('Error: read boom');
|
|
163
|
+
expect(result.content[0].text).toContain('Configured accounts:');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('surfaces the re-auth hint when the verification read fails with an auth error', async () => {
|
|
167
|
+
vi.mocked(runner.run)
|
|
168
|
+
.mockRejectedValueOnce(new Error('Request failed with status 401'))
|
|
169
|
+
.mockResolvedValueOnce('{"accounts":[{"email":"u@x.com"}]}');
|
|
170
|
+
const handlers = setupHandlers();
|
|
171
|
+
const result = await handlers.get('gog_sheets_update')!({
|
|
172
|
+
spreadsheetId: 'sid', range: 'A1', values: [['x']], fail_if_not_empty: true,
|
|
173
|
+
});
|
|
174
|
+
expect(result.content[0].text).toContain('gog_auth_add');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('aborts when emptiness cannot be verified from unparseable output', async () => {
|
|
178
|
+
vi.mocked(runner.run).mockResolvedValueOnce('not json');
|
|
179
|
+
const handlers = setupHandlers();
|
|
180
|
+
const result = await handlers.get('gog_sheets_update')!({
|
|
181
|
+
spreadsheetId: 'sid', range: 'A1', values: [['new']], fail_if_not_empty: true,
|
|
182
|
+
});
|
|
183
|
+
expect(runner.run).toHaveBeenCalledTimes(1);
|
|
184
|
+
expect(result.content[0].text).toContain('could not be verified');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('still honors dry_run after the guard passes', async () => {
|
|
188
|
+
vi.mocked(runner.run)
|
|
189
|
+
.mockResolvedValueOnce('{}')
|
|
190
|
+
.mockResolvedValueOnce('{"dryRun":true}');
|
|
191
|
+
const handlers = setupHandlers();
|
|
192
|
+
await handlers.get('gog_sheets_update')!({
|
|
193
|
+
spreadsheetId: 'sid', range: 'A1', values: [['new']], fail_if_not_empty: true, dry_run: true,
|
|
194
|
+
});
|
|
195
|
+
expect(vi.mocked(runner.run).mock.calls[1]![0]).toContain('--dry-run');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('skips the guard read when values has no rows', async () => {
|
|
199
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
200
|
+
const handlers = setupHandlers();
|
|
201
|
+
await handlers.get('gog_sheets_update')!({
|
|
202
|
+
spreadsheetId: 'sid', range: 'A1', values: [], fail_if_not_empty: true,
|
|
203
|
+
});
|
|
204
|
+
expect(runner.run).toHaveBeenCalledTimes(1);
|
|
205
|
+
expect(vi.mocked(runner.run).mock.calls[0]![0]![1]).toBe('update');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('skips the guard read when rows have no columns', async () => {
|
|
209
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
210
|
+
const handlers = setupHandlers();
|
|
211
|
+
await handlers.get('gog_sheets_update')!({
|
|
212
|
+
spreadsheetId: 'sid', range: 'A1', values: [[]], fail_if_not_empty: true,
|
|
213
|
+
});
|
|
214
|
+
expect(runner.run).toHaveBeenCalledTimes(1);
|
|
215
|
+
expect(vi.mocked(runner.run).mock.calls[0]![0]![1]).toBe('update');
|
|
216
|
+
});
|
|
87
217
|
});
|
|
88
218
|
|
|
89
219
|
describe('gog_sheets_append', () => {
|
|
@@ -104,6 +234,17 @@ describe('gog_sheets_append', () => {
|
|
|
104
234
|
const result = await handlers.get('gog_sheets_append')!({ spreadsheetId: 'bad', range: 'A1', values: [['x']] });
|
|
105
235
|
expect(result.content[0].text).toBe('Error: Append failed');
|
|
106
236
|
});
|
|
237
|
+
|
|
238
|
+
it('appends --dry-run when dry_run is true', async () => {
|
|
239
|
+
vi.mocked(runner.run).mockResolvedValue('{"dryRun":true}');
|
|
240
|
+
const handlers = setupHandlers();
|
|
241
|
+
const values = [['x']];
|
|
242
|
+
await handlers.get('gog_sheets_append')!({ spreadsheetId: 'sid', range: 'Sheet1!A:A', values, dry_run: true });
|
|
243
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
244
|
+
['sheets', 'append', 'sid', 'Sheet1!A:A', `--values-json=${JSON.stringify(values)}`, '--dry-run'],
|
|
245
|
+
{ account: undefined },
|
|
246
|
+
);
|
|
247
|
+
});
|
|
107
248
|
});
|
|
108
249
|
|
|
109
250
|
describe('gog_sheets_clear', () => {
|
|
@@ -120,9 +261,30 @@ describe('gog_sheets_clear', () => {
|
|
|
120
261
|
const result = await handlers.get('gog_sheets_clear')!({ spreadsheetId: 'bad', range: 'A1' });
|
|
121
262
|
expect(result.content[0].text).toBe('Error: Clear failed');
|
|
122
263
|
});
|
|
264
|
+
|
|
265
|
+
it('appends --dry-run when dry_run is true', async () => {
|
|
266
|
+
vi.mocked(runner.run).mockResolvedValue('{"dryRun":true}');
|
|
267
|
+
const handlers = setupHandlers();
|
|
268
|
+
await handlers.get('gog_sheets_clear')!({ spreadsheetId: 'sid', range: 'Sheet1!A1:Z100', dry_run: true });
|
|
269
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
270
|
+
['sheets', 'clear', 'sid', 'Sheet1!A1:Z100', '--dry-run'],
|
|
271
|
+
{ account: undefined },
|
|
272
|
+
);
|
|
273
|
+
});
|
|
123
274
|
});
|
|
124
275
|
|
|
125
276
|
describe('gog_sheets_metadata', () => {
|
|
277
|
+
it('advertises grid dimensions in its description', () => {
|
|
278
|
+
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
|
279
|
+
const configs = new Map<string, { description?: string }>();
|
|
280
|
+
vi.spyOn(server, 'registerTool').mockImplementation((name, config) => {
|
|
281
|
+
configs.set(name, config as { description?: string });
|
|
282
|
+
return undefined as never;
|
|
283
|
+
});
|
|
284
|
+
registerSheetsTools(server);
|
|
285
|
+
expect(configs.get('gog_sheets_metadata')!.description).toMatch(/grid dimensions|rowCount|columnCount/i);
|
|
286
|
+
});
|
|
287
|
+
|
|
126
288
|
it('calls run with spreadsheetId only', async () => {
|
|
127
289
|
vi.mocked(runner.run).mockResolvedValue('{"title":"My Sheet","sheets":[{"title":"Sheet1"}]}');
|
|
128
290
|
const handlers = setupHandlers();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import * as runner from '../../src/runner.js';
|
|
3
|
-
import { runOrDiagnose, pushPaginationFlags } from '../../src/tools/utils.js';
|
|
3
|
+
import { runOrDiagnose, pushPaginationFlags, formatAccountList } from '../../src/tools/utils.js';
|
|
4
4
|
|
|
5
5
|
vi.mock('../../src/runner.js');
|
|
6
6
|
|
|
@@ -40,6 +40,53 @@ describe('pushPaginationFlags', () => {
|
|
|
40
40
|
});
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
describe('formatAccountList', () => {
|
|
44
|
+
const AUTH_LIST_JSON = JSON.stringify({
|
|
45
|
+
accounts: [
|
|
46
|
+
{
|
|
47
|
+
email: 'chris.c.hall@gmail.com',
|
|
48
|
+
subject: '109876543210987654321',
|
|
49
|
+
client: 'default',
|
|
50
|
+
services: ['gmail', 'drive'],
|
|
51
|
+
scopes: ['https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/drive'],
|
|
52
|
+
created_at: '2026-01-02T03:04:05Z',
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('reduces gog auth list JSON to email addresses only', () => {
|
|
58
|
+
const out = formatAccountList(AUTH_LIST_JSON);
|
|
59
|
+
expect(out).toBe('chris.c.hall@gmail.com');
|
|
60
|
+
// none of the sensitive fields leak through
|
|
61
|
+
expect(out).not.toContain('scopes');
|
|
62
|
+
expect(out).not.toContain('gmail.modify');
|
|
63
|
+
expect(out).not.toContain('109876543210987654321');
|
|
64
|
+
expect(out).not.toContain('created_at');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('joins multiple account emails with newlines', () => {
|
|
68
|
+
const out = formatAccountList(JSON.stringify({ accounts: [{ email: 'a@x.com' }, { email: 'b@y.com' }] }));
|
|
69
|
+
expect(out).toBe('a@x.com\nb@y.com');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('skips accounts with no email', () => {
|
|
73
|
+
const out = formatAccountList(JSON.stringify({ accounts: [{ email: 'a@x.com' }, { client: 'other' }] }));
|
|
74
|
+
expect(out).toBe('a@x.com');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('falls back to the trimmed raw text when not parseable JSON', () => {
|
|
78
|
+
expect(formatAccountList(' user@gmail.com\n')).toBe('user@gmail.com');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('falls back to raw text when JSON has no accounts array', () => {
|
|
82
|
+
expect(formatAccountList('{"foo":1}')).toBe('{"foo":1}');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('falls back to raw text when parsed JSON is null', () => {
|
|
86
|
+
expect(formatAccountList('null')).toBe('null');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
43
90
|
describe('runOrDiagnose', () => {
|
|
44
91
|
it('returns output text on success', async () => {
|
|
45
92
|
vi.mocked(runner.run).mockResolvedValue('{"ok":true}');
|
|
@@ -58,6 +105,35 @@ describe('runOrDiagnose', () => {
|
|
|
58
105
|
expect(result.content[0].text).not.toContain('gog_auth_add');
|
|
59
106
|
});
|
|
60
107
|
|
|
108
|
+
it('redacts scopes/subject/timestamps from the configured-accounts block', async () => {
|
|
109
|
+
const authListJson = JSON.stringify({
|
|
110
|
+
accounts: [{
|
|
111
|
+
email: 'chris.c.hall@gmail.com',
|
|
112
|
+
subject: '109876543210987654321',
|
|
113
|
+
scopes: ['https://www.googleapis.com/auth/gmail.modify'],
|
|
114
|
+
created_at: '2026-01-02T03:04:05Z',
|
|
115
|
+
}],
|
|
116
|
+
});
|
|
117
|
+
vi.mocked(runner.run)
|
|
118
|
+
.mockRejectedValueOnce(new Error('refusing to delete gmail draft r123 without --force (non-interactive)'))
|
|
119
|
+
.mockResolvedValueOnce(authListJson);
|
|
120
|
+
const result = await runOrDiagnose(['gmail', 'drafts', 'delete', 'r123'], {});
|
|
121
|
+
const text = result.content[0].text;
|
|
122
|
+
expect(text).toContain('Configured accounts:\nchris.c.hall@gmail.com');
|
|
123
|
+
expect(text).not.toContain('scopes');
|
|
124
|
+
expect(text).not.toContain('gmail.modify');
|
|
125
|
+
expect(text).not.toContain('109876543210987654321');
|
|
126
|
+
expect(text).not.toContain('created_at');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('shows (none) when no accounts are configured', async () => {
|
|
130
|
+
vi.mocked(runner.run)
|
|
131
|
+
.mockRejectedValueOnce(new Error('Doc not found'))
|
|
132
|
+
.mockResolvedValueOnce('{"accounts":[]}');
|
|
133
|
+
const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
|
|
134
|
+
expect(result.content[0].text).toBe('Error: Doc not found\n\nConfigured accounts:\n(none)');
|
|
135
|
+
});
|
|
136
|
+
|
|
61
137
|
it('appends re-auth hint on 401 error', async () => {
|
|
62
138
|
vi.mocked(runner.run)
|
|
63
139
|
.mockRejectedValueOnce(new Error('Request failed with status 401'))
|
|
@@ -175,4 +251,30 @@ describe('runOrDiagnose', () => {
|
|
|
175
251
|
expect(result.content[0].text).toContain('transient');
|
|
176
252
|
expect(result.content[0].text).not.toContain('Configured accounts');
|
|
177
253
|
});
|
|
254
|
+
|
|
255
|
+
it('appends grid-limit hint pointing at gog_sheets_insert', async () => {
|
|
256
|
+
vi.mocked(runner.run)
|
|
257
|
+
.mockRejectedValueOnce(new Error('Range (Sheet1!AP1:AW1) exceeds grid limits. Max rows: 1000, max columns: 41'))
|
|
258
|
+
.mockResolvedValueOnce('user@gmail.com');
|
|
259
|
+
const result = await runOrDiagnose(['sheets', 'update', 'abc', 'AP1'], {});
|
|
260
|
+
expect(result.content[0].text).toContain('exceeds grid limits');
|
|
261
|
+
expect(result.content[0].text).toContain('gog_sheets_insert');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('does not append grid-limit hint on unrelated errors', async () => {
|
|
265
|
+
vi.mocked(runner.run)
|
|
266
|
+
.mockRejectedValueOnce(new Error('Spreadsheet not found'))
|
|
267
|
+
.mockResolvedValueOnce('user@gmail.com');
|
|
268
|
+
const result = await runOrDiagnose(['sheets', 'update', 'abc', 'A1'], {});
|
|
269
|
+
expect(result.content[0].text).not.toContain('gog_sheets_insert');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('keeps grid-limit hint when auth list also fails', async () => {
|
|
273
|
+
vi.mocked(runner.run)
|
|
274
|
+
.mockRejectedValueOnce(new Error('exceeds grid limits. Max rows: 1000, max columns: 41'))
|
|
275
|
+
.mockRejectedValueOnce(new Error('auth list failed'));
|
|
276
|
+
const result = await runOrDiagnose(['sheets', 'update', 'abc', 'A1'], {});
|
|
277
|
+
expect(result.content[0].text).toContain('gog_sheets_insert');
|
|
278
|
+
expect(result.content[0].text).not.toContain('Configured accounts');
|
|
279
|
+
});
|
|
178
280
|
});
|