gogcli-mcp 2.28.0 → 2.29.1

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.
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { registerDriveTools } from '../../src/tools/drive.js';
2
+ import { registerDriveTools, DRIVE_LS_COMPACT_FIELDS } from '../../src/tools/drive.js';
3
3
  import * as runner from '../../src/runner.js';
4
4
  import { createTestHarness } from '@chrischall/mcp-utils/test';
5
5
 
@@ -7,6 +7,13 @@ vi.mock('../../src/runner.js');
7
7
 
8
8
  const setupHandlers = () => createTestHarness(registerDriveTools);
9
9
 
10
+ // gog_drive_ls answers in the compact rung by default, so every call carries
11
+ // the mask. These keep the flag-mapping tests below about flag mapping while
12
+ // still asserting the shipped default rather than a view they opted out of.
13
+ const lsArgs = (...extra: string[]) =>
14
+ ['drive', 'ls', ...extra, `--fields=${DRIVE_LS_COMPACT_FIELDS}`];
15
+ const lsOpts = { account: undefined, fieldsMask: DRIVE_LS_COMPACT_FIELDS };
16
+
10
17
  beforeEach(() => vi.clearAllMocks());
11
18
 
12
19
  describe('gog_drive_ls', () => {
@@ -14,14 +21,48 @@ describe('gog_drive_ls', () => {
14
21
  vi.mocked(runner.run).mockResolvedValue('{"files":[]}');
15
22
  const harness = await setupHandlers();
16
23
  await harness.callTool('gog_drive_ls', {});
17
- expect(runner.run).toHaveBeenCalledWith(['drive', 'ls'], { account: undefined });
24
+ expect(runner.run).toHaveBeenCalledWith(lsArgs(), lsOpts);
25
+ });
26
+
27
+ // Efficiency is not something a caller should have to ask for: the cheap rung
28
+ // is the default. Measured at 48% smaller on a 25-row listing.
29
+ it('defaults to the compact view, applying the field mask', async () => {
30
+ vi.mocked(runner.run).mockResolvedValue('{"files":[]}');
31
+ const harness = await setupHandlers();
32
+ await harness.callTool('gog_drive_ls', {});
33
+ expect(runner.run).toHaveBeenCalledWith(
34
+ ['drive', 'ls', `--fields=${DRIVE_LS_COMPACT_FIELDS}`],
35
+ lsOpts,
36
+ );
37
+ });
38
+
39
+ it('sends no mask for the full view', async () => {
40
+ vi.mocked(runner.run).mockResolvedValue('{"files":[]}');
41
+ const harness = await setupHandlers();
42
+ await harness.callTool('gog_drive_ls', { view: 'full' });
43
+ expect(runner.run).toHaveBeenCalledWith(['drive', 'ls'], { account: undefined, fieldsMask: undefined });
44
+ });
45
+
46
+ // THE BUG THIS ALMOST SHIPPED: a Google field mask drops nextPageToken from
47
+ // the envelope, so a compact read returns page one with an EMPTY cursor and
48
+ // reads as "no more results" — silent truncation, verified live against gog
49
+ // 0.39.0. Naming the paging field in the mask is what brings it back.
50
+ it('names the paging field in the mask', () => {
51
+ expect(DRIVE_LS_COMPACT_FIELDS).toMatch(/^nextPageToken,/);
52
+ });
53
+
54
+ it('rejects a view rung this tool does not honour', async () => {
55
+ const harness = await setupHandlers();
56
+ const result = await harness.callTool('gog_drive_ls', { view: 'raw' });
57
+ expect(result.isError).toBe(true);
58
+ expect(runner.run).not.toHaveBeenCalled();
18
59
  });
19
60
 
20
61
  it('passes folderId as --parent flag', async () => {
21
62
  vi.mocked(runner.run).mockResolvedValue('{}');
22
63
  const harness = await setupHandlers();
23
64
  await harness.callTool('gog_drive_ls', { folderId: 'folder1' });
24
- expect(runner.run).toHaveBeenCalledWith(['drive', 'ls', '--parent=folder1'], { account: undefined });
65
+ expect(runner.run).toHaveBeenCalledWith(lsArgs('--parent=folder1'), lsOpts);
25
66
  });
26
67
 
27
68
  it('supports max, page, query, and allDrives flags', async () => {
@@ -34,8 +75,8 @@ describe('gog_drive_ls', () => {
34
75
  query: "name contains 'x'",
35
76
  });
36
77
  expect(runner.run).toHaveBeenCalledWith(
37
- ['drive', 'ls', '--parent=folder1', '--max=50', '--page=tok', "--query=name contains 'x'"],
38
- { account: undefined },
78
+ lsArgs('--parent=folder1', '--max=50', '--page=tok', "--query=name contains 'x'"),
79
+ lsOpts,
39
80
  );
40
81
  });
41
82
 
@@ -43,14 +84,14 @@ describe('gog_drive_ls', () => {
43
84
  vi.mocked(runner.run).mockResolvedValue('{}');
44
85
  const harness = await setupHandlers();
45
86
  await harness.callTool('gog_drive_ls', { allDrives: false });
46
- expect(runner.run).toHaveBeenCalledWith(['drive', 'ls', '--no-all-drives'], { account: undefined });
87
+ expect(runner.run).toHaveBeenCalledWith(lsArgs('--no-all-drives'), lsOpts);
47
88
  });
48
89
 
49
90
  it('omits all-drives flag when allDrives is true (default)', async () => {
50
91
  vi.mocked(runner.run).mockResolvedValue('{}');
51
92
  const harness = await setupHandlers();
52
93
  await harness.callTool('gog_drive_ls', { allDrives: true });
53
- expect(runner.run).toHaveBeenCalledWith(['drive', 'ls'], { account: undefined });
94
+ expect(runner.run).toHaveBeenCalledWith(lsArgs(), lsOpts);
54
95
  });
55
96
 
56
97
  it('returns error text on failure', async () => {
@@ -62,11 +103,27 @@ describe('gog_drive_ls', () => {
62
103
  });
63
104
 
64
105
  describe('gog_drive_search', () => {
65
- it('calls run with query', async () => {
106
+ // drive search accepts NO field mask — `gog schema --json` lists only nine
107
+ // commands that do, and this is not one — so the local media strip is the
108
+ // only projection available to it. Measured 27.8% end to end.
109
+ it('calls run with query, defaulting to the compact view', async () => {
66
110
  vi.mocked(runner.run).mockResolvedValue('{"files":[]}');
67
111
  const harness = await setupHandlers();
68
112
  await harness.callTool('gog_drive_search', { query: 'budget' });
69
- expect(runner.run).toHaveBeenCalledWith(['drive', 'search', 'budget'], { account: undefined });
113
+ expect(runner.run).toHaveBeenCalledWith(
114
+ ['drive', 'search', 'budget'],
115
+ { account: undefined, stripMedia: true },
116
+ );
117
+ });
118
+
119
+ it('keeps everything for the full view', async () => {
120
+ vi.mocked(runner.run).mockResolvedValue('{"files":[]}');
121
+ const harness = await setupHandlers();
122
+ await harness.callTool('gog_drive_search', { query: 'budget', view: 'full' });
123
+ expect(runner.run).toHaveBeenCalledWith(
124
+ ['drive', 'search', 'budget'],
125
+ { account: undefined, stripMedia: false },
126
+ );
70
127
  });
71
128
 
72
129
  it('returns error text on failure', async () => {
@@ -78,13 +135,24 @@ describe('gog_drive_search', () => {
78
135
  });
79
136
 
80
137
  describe('gog_drive_get', () => {
81
- it('calls run with fileId', async () => {
82
- vi.mocked(runner.run).mockResolvedValue('{"id":"file1"}');
138
+ // Excluded from the --fields work because a mask saved only 7% there: its
139
+ // default field set is already narrow. The media strip saves 27.5% on the
140
+ // same payload end to end, which clears the bar a parameter has to clear.
141
+ it('calls run with fileId, defaulting to the compact view', async () => {
142
+ vi.mocked(runner.run).mockResolvedValue('{}');
143
+ const harness = await setupHandlers();
144
+ await harness.callTool('gog_drive_get', { fileId: 'f1' });
145
+ expect(runner.run).toHaveBeenCalledWith(['drive', 'get', 'f1'], { account: undefined, stripMedia: true });
146
+ });
147
+
148
+ it('keeps everything for the full view', async () => {
149
+ vi.mocked(runner.run).mockResolvedValue('{}');
83
150
  const harness = await setupHandlers();
84
- await harness.callTool('gog_drive_get', { fileId: 'file1' });
85
- expect(runner.run).toHaveBeenCalledWith(['drive', 'get', 'file1'], { account: undefined });
151
+ await harness.callTool('gog_drive_get', { fileId: 'f1', view: 'full' });
152
+ expect(runner.run).toHaveBeenCalledWith(['drive', 'get', 'f1'], { account: undefined, stripMedia: false });
86
153
  });
87
154
 
155
+
88
156
  it('returns error text on failure', async () => {
89
157
  vi.mocked(runner.run).mockRejectedValue(new Error('Not found'));
90
158
  const harness = await setupHandlers();
@@ -123,6 +123,147 @@ describe('runOrDiagnose', () => {
123
123
  expect(parsed.internalDateDisplay).toBeDefined();
124
124
  });
125
125
 
126
+ // Formatting whitespace is roughly a fifth of a large gog response and
127
+ // carries no information: gog pretty-prints its --json output, and nothing
128
+ // downstream reads the indent. Measured at 18.6% of a 19 KB `drive ls`.
129
+ it('minifies gog\'s pretty-printed JSON on the normal path', async () => {
130
+ vi.mocked(runner.run).mockResolvedValue('{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}');
131
+ const result = await runOrDiagnose(['drive', 'ls'], {});
132
+ expect(result.content[0].text).toBe('{"a":1,"b":[2,3]}');
133
+ });
134
+
135
+ // Only FORMATTING whitespace goes. Whitespace inside a value is content —
136
+ // the blank line between paragraphs of a mail body — and JSON.stringify
137
+ // leaves every byte of it alone. A regex over the serialised text would
138
+ // corrupt exactly the payloads this is meant to shrink.
139
+ it('preserves whitespace INSIDE string values', async () => {
140
+ const body = 'Hi,\n\n indented quote\n\nthanks';
141
+ vi.mocked(runner.run).mockResolvedValue(JSON.stringify({ body }, null, 2));
142
+ const result = await runOrDiagnose(['gmail', 'get'], {});
143
+ expect(JSON.parse(result.content[0].text as string).body).toBe(body);
144
+ });
145
+
146
+ // ofw-mcp emits paging state before its data array so a truncated read still
147
+ // sees it; the same reasoning applies to gog's nextPageToken. JSON.stringify
148
+ // preserves insertion order, so minifying must not reorder anything.
149
+ it('preserves key order', async () => {
150
+ vi.mocked(runner.run).mockResolvedValue('{\n "nextPageToken": "t",\n "files": []\n}');
151
+ const result = await runOrDiagnose(['drive', 'ls'], {});
152
+ expect(result.content[0].text).toBe('{"nextPageToken":"t","files":[]}');
153
+ });
154
+
155
+ // gog does not always answer in JSON — `gog auth list` is plain text, and an
156
+ // empty body is legal. Minification must pass anything unparseable through
157
+ // untouched rather than mangling it or throwing.
158
+ it('passes non-JSON output through untouched', async () => {
159
+ for (const text of ['user@gmail.com\nother@gmail.com', '', ' ', 'not json {']) {
160
+ vi.mocked(runner.run).mockResolvedValue(text);
161
+ const result = await runOrDiagnose(['auth', 'list'], {});
162
+ expect(result.content[0].text).toBe(text);
163
+ }
164
+ });
165
+
166
+ // Brace-prefixed but unparseable — a truncated response from a killed gog, or
167
+ // a JSON prelude followed by garbage. It passes the `^[[{]` guard and then
168
+ // fails JSON.parse, and the only safe answer is the original bytes: a caller
169
+ // debugging a malformed payload needs to see what actually arrived.
170
+ //
171
+ // This path IS reachable through other suites today (gog_docs_structure feeds
172
+ // exactly this shape), but incidental coverage is not the same as a pinned
173
+ // behaviour — change that unrelated fixture and this branch goes dark, and
174
+ // the failure surfaces on whatever PR touched the fixture.
175
+ it('passes brace-prefixed but unparseable output through untouched', async () => {
176
+ for (const text of ['{"truncated": ', '[{"a":1},', '{not: json}']) {
177
+ vi.mocked(runner.run).mockResolvedValue(text);
178
+ const result = await runOrDiagnose(['drive', 'ls'], {});
179
+ expect(result.content[0].text).toBe(text);
180
+ expect(result.isError).toBeUndefined();
181
+ }
182
+ });
183
+
184
+ // The lossless dumps are the rung a person reaches for when a payload is not
185
+ // what they expected, and indentation is most of what makes an unfamiliar
186
+ // shape legible — the same asymmetry mcp-utils' viewResult applies to `raw`.
187
+ it('does NOT minify a lossless response', async () => {
188
+ const raw = '{\n "id": "m1"\n}';
189
+ vi.mocked(runner.run).mockResolvedValue(raw);
190
+ const result = await runOrDiagnose(['gmail', 'raw', 'm1'], { lossless: true });
191
+ expect(result.content[0].text).toBe(raw);
192
+ });
193
+
194
+ // A Google field mask is applied UPSTREAM, inside the API, so a mask this
195
+ // wrapper gets wrong is a hard 400 rather than a thin record. That makes the
196
+ // fallback the thing that keeps compact-by-default survivable: the same role
197
+ // mcp-utils' projectOrRaw plays for a projection done locally.
198
+ it('applies a compact field mask when one is given', async () => {
199
+ vi.mocked(runner.run).mockResolvedValue('{"files":[]}');
200
+ await runOrDiagnose(['drive', 'ls'], { fieldsMask: 'nextPageToken,files(id)' });
201
+ expect(runner.run).toHaveBeenCalledWith(
202
+ ['drive', 'ls', '--fields=nextPageToken,files(id)'],
203
+ expect.objectContaining({ fieldsMask: 'nextPageToken,files(id)' }),
204
+ );
205
+ });
206
+
207
+ it('retries UNPROJECTED when Google rejects the mask', async () => {
208
+ vi.mocked(runner.run)
209
+ .mockRejectedValueOnce(new Error('Google API error (400 invalidParameter): Invalid field selection id'))
210
+ .mockResolvedValueOnce('{"files":[{"id":"f1"}]}');
211
+ const result = await runOrDiagnose(['drive', 'ls'], { fieldsMask: 'files(bogus)' });
212
+ expect(runner.run).toHaveBeenNthCalledWith(1, ['drive', 'ls', '--fields=files(bogus)'], expect.anything());
213
+ expect(runner.run).toHaveBeenNthCalledWith(2, ['drive', 'ls'], expect.anything());
214
+ // The caller gets the whole payload, not an error: a projection that trips
215
+ // returns everything rather than taking the tool call down.
216
+ expect(result.isError).toBeUndefined();
217
+ expect(result.content[0].text).toBe('{"files":[{"id":"f1"}]}');
218
+ });
219
+
220
+ // The fallback must not swallow real failures — a missing file is not a bad
221
+ // mask, and retrying it would just spend a second call to fail identically.
222
+ it('does NOT retry an error that is not a rejected mask', async () => {
223
+ vi.mocked(runner.run)
224
+ .mockRejectedValueOnce(new Error('File not found'))
225
+ .mockResolvedValueOnce('user@gmail.com');
226
+ const result = await runOrDiagnose(['drive', 'ls'], { fieldsMask: 'files(id)' });
227
+ expect(result.isError).toBe(true);
228
+ expect(runner.run).not.toHaveBeenNthCalledWith(2, ['drive', 'ls'], expect.anything());
229
+ });
230
+
231
+ // The second compact mechanism. `--fields` is a projection Google performs;
232
+ // this one is performed here, for the tools whose gog subcommand accepts no
233
+ // mask at all. Both surface through the same `view` vocabulary.
234
+ it('strips media keys when asked, and minifies the result', async () => {
235
+ vi.mocked(runner.run).mockResolvedValue(
236
+ '{\n "id": "f1",\n "thumbnailLink": "https://lh3.googleusercontent.com/x=s220",\n "webViewLink": "https://docs.google.com/d/f1"\n}',
237
+ );
238
+ const result = await runOrDiagnose(['drive', 'get', 'f1'], { stripMedia: true });
239
+ expect(result.content[0].text).toBe('{"id":"f1","webViewLink":"https://docs.google.com/d/f1"}');
240
+ });
241
+
242
+ // webViewLink is the one URL a caller acts on and it sits in the same object
243
+ // as thumbnailLink. Stripping it would empty the response of the useful half.
244
+ it('keeps webViewLink and hasThumbnail while stripping the thumbnail', async () => {
245
+ vi.mocked(runner.run).mockResolvedValue(
246
+ JSON.stringify({ files: [{ id: 'f1', hasThumbnail: false, thumbnailLink: 'https://x/y=s220', webViewLink: 'https://docs/f1' }] }),
247
+ );
248
+ const result = await runOrDiagnose(['drive', 'search', 'q'], { stripMedia: true });
249
+ const parsed = JSON.parse(result.content[0].text as string);
250
+ expect(parsed.files[0]).toEqual({ id: 'f1', hasThumbnail: false, webViewLink: 'https://docs/f1' });
251
+ });
252
+
253
+ it('does not strip media unless asked', async () => {
254
+ vi.mocked(runner.run).mockResolvedValue('{"thumbnailLink":"https://x/y=s220"}');
255
+ const result = await runOrDiagnose(['drive', 'get', 'f1'], {});
256
+ expect(result.content[0].text).toBe('{"thumbnailLink":"https://x/y=s220"}');
257
+ });
258
+
259
+ // Non-JSON must survive the strip path exactly as it survives minification —
260
+ // the guard is shared, so a regression here would be silent.
261
+ it('passes non-JSON through untouched even when stripping is on', async () => {
262
+ vi.mocked(runner.run).mockResolvedValue('user@gmail.com');
263
+ const result = await runOrDiagnose(['auth', 'list'], { stripMedia: true });
264
+ expect(result.content[0].text).toBe('user@gmail.com');
265
+ });
266
+
126
267
  it('appends auth list on non-auth failure', async () => {
127
268
  vi.mocked(runner.run)
128
269
  .mockRejectedValueOnce(new Error('Doc not found'))
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createRequire } from 'node:module';
3
+ import { realpathSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ // The zod counterpart of sdk-single-copy.test.ts, guarding the invariant that
7
+ // broke dependabot #333: the whole monorepo must resolve ONE copy of zod.
8
+ //
9
+ // Same shape of failure, a different package. `@cloudflare/vitest-pool-workers`
10
+ // (a root devDependency) declares zod as an **exact** pin, so it takes the
11
+ // hoisted root slot that `@chrischall/mcp-utils` and the MCP SDK resolve their
12
+ // zod peer from. The moment our workspaces ask for a newer zod than that pin,
13
+ // each nests its own copy — and because a `ZodType` carries brand-bearing
14
+ // internals, TypeScript compares the two NOMINALLY: every schema our tools hand
15
+ // `registerTool` fails with `TS2322: Type 'ZodString' is not assignable to type
16
+ // 'AnySchema'`, with no API change and nothing to fix in the source. #333 split
17
+ // the tree exactly that way and produced 11,024 type errors from a bump of one
18
+ // patch-level dependency.
19
+ //
20
+ // Read the resolved paths in such an error, not the signature.
21
+ //
22
+ // This asserts resolution identity rather than a version string: the failure is
23
+ // "two copies", not "the wrong version", and pinning a version here would just
24
+ // have to be edited on every future bump.
25
+ describe('zod is installed exactly once', () => {
26
+ const here = createRequire(import.meta.url);
27
+
28
+ // `import.meta.resolve`, not `require.resolve`, to reach the dependency's own
29
+ // entry: these packages are ESM-only, so their `exports` maps carry no
30
+ // `require` condition and CJS resolution of the bare specifier throws.
31
+ const resolveFrom = (specifier: string): string =>
32
+ realpathSync(createRequire(fileURLToPath(import.meta.resolve(specifier))).resolve('zod'));
33
+
34
+ it('resolves to the same file for this package and for @chrischall/mcp-utils', () => {
35
+ // mcp-utils declares zod as a peer, and its `accountParam` / `viewParam` /
36
+ // `paginationParams` helpers build the very schemas our registrars pass to
37
+ // `registerTool`, so its copy is the one they must be typed against.
38
+ expect(resolveFrom('@chrischall/mcp-utils')).toBe(realpathSync(here.resolve('zod')));
39
+ });
40
+
41
+ it('resolves to the same file for the MCP SDK, which types every tool schema', () => {
42
+ // `registerTool` accepts the raw shape and infers the handler's argument
43
+ // types from it; a second copy makes every one of those schemas foreign.
44
+ expect(resolveFrom('@modelcontextprotocol/sdk/server/mcp.js')).toBe(
45
+ realpathSync(here.resolve('zod')),
46
+ );
47
+ });
48
+
49
+ it('resolves to the same file for @cloudflare/vitest-pool-workers, which exact-pins zod', () => {
50
+ // The exact pin here is what captured the root hoist slot in #333, and it
51
+ // is why the root `overrides` block carries a zod entry.
52
+ expect(resolveFrom('@cloudflare/vitest-pool-workers')).toBe(
53
+ realpathSync(here.resolve('zod')),
54
+ );
55
+ });
56
+ });