gogcli-mcp 2.8.0 → 2.18.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/dist/index.js +20218 -19700
- package/dist/lib.js +15845 -23331
- package/manifest.json +33 -1
- package/package.json +7 -6
- package/server.json +2 -2
- package/src/connector-auth.ts +46 -0
- package/src/connector-runtime.ts +177 -0
- package/src/index.ts +7 -5
- package/src/lib.ts +8 -7
- package/src/runner.ts +225 -30
- package/src/server.ts +21 -25
- package/src/tools/api.ts +65 -0
- package/src/tools/auth.ts +112 -15
- package/src/tools/calendar.ts +24 -9
- package/src/tools/docs.ts +28 -3
- package/src/tools/drive.ts +124 -1
- package/src/tools/gmail.ts +7 -3
- package/src/tools/sheets.ts +6 -3
- package/src/tools/slides.ts +9 -4
- package/src/tools/tasks.ts +3 -1
- package/src/tools/utils.ts +163 -27
- package/src/worker.ts +99 -0
- package/tests/connector-auth.test.ts +28 -0
- package/tests/connector-runtime.test.ts +474 -0
- package/tests/runner-file-args-failure.test.ts +94 -0
- package/tests/runner-file-args.test.ts +232 -0
- package/tests/runner.test.ts +221 -13
- package/tests/server.test.ts +28 -28
- package/tests/tools/api.test.ts +107 -0
- package/tests/tools/auth.test.ts +187 -31
- package/tests/tools/calendar.test.ts +115 -52
- package/tests/tools/classroom.test.ts +77 -77
- package/tests/tools/contacts.test.ts +24 -24
- package/tests/tools/docs.test.ts +84 -46
- package/tests/tools/drive.test.ts +226 -55
- package/tests/tools/gmail.test.ts +61 -28
- package/tests/tools/sheets.test.ts +81 -70
- package/tests/tools/slides.test.ts +56 -36
- package/tests/tools/tasks.test.ts +33 -33
- package/tests/tools/utils.test.ts +116 -2
- package/tests/worker.test.ts +142 -0
- package/tsconfig.json +4 -1
- package/vitest.config.ts +33 -2
- package/tests/helpers/test-harness.ts +0 -27
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { run, isGogFileArg } from '../src/runner.js';
|
|
6
|
+
import type { Spawner, GogArg } from '../src/runner.js';
|
|
7
|
+
import { payloadArg, PAYLOAD_INLINE_MAX } from '../src/tools/utils.js';
|
|
8
|
+
|
|
9
|
+
// These tests deliberately use the REAL fs: the whole point is that the bytes
|
|
10
|
+
// gog would read off disk are byte-identical to the payload, and that the temp
|
|
11
|
+
// dir is actually gone afterwards.
|
|
12
|
+
|
|
13
|
+
interface Capture {
|
|
14
|
+
/** argv gog was spawned with. */
|
|
15
|
+
argv: string[];
|
|
16
|
+
/** Contents read back off disk WHILE the child was "running". */
|
|
17
|
+
files: Record<string, string>;
|
|
18
|
+
/** Temp dirs observed in the argv, captured before cleanup ran. */
|
|
19
|
+
dirs: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A Spawner stub that, at spawn time, reads every `--x-file=<path>` arg back
|
|
24
|
+
* off disk. Reading inside the spawner is load-bearing: by the time `run()`
|
|
25
|
+
* resolves, the temp dir has already been removed.
|
|
26
|
+
*/
|
|
27
|
+
function makeCapturingSpawner(
|
|
28
|
+
exitCode: number,
|
|
29
|
+
stdout = '',
|
|
30
|
+
stderr = '',
|
|
31
|
+
): { spawner: Spawner; capture: Capture } {
|
|
32
|
+
const capture: Capture = { argv: [], files: {}, dirs: [] };
|
|
33
|
+
const spawner = vi.fn((_cmd: string, argv: string[]) => {
|
|
34
|
+
capture.argv = argv;
|
|
35
|
+
for (const arg of argv) {
|
|
36
|
+
const match = /^--([^=]+-file)=(.*)$/s.exec(arg);
|
|
37
|
+
if (!match) continue;
|
|
38
|
+
capture.files[match[1]] = readFileSync(match[2], 'utf8');
|
|
39
|
+
capture.dirs.push(dirname(match[2]));
|
|
40
|
+
}
|
|
41
|
+
const proc = new EventEmitter() as ReturnType<Spawner>;
|
|
42
|
+
(proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stdout = new EventEmitter();
|
|
43
|
+
(proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stderr = new EventEmitter();
|
|
44
|
+
proc.kill = vi.fn();
|
|
45
|
+
setTimeout(() => {
|
|
46
|
+
(proc as unknown as { stdout: EventEmitter }).stdout.emit('data', Buffer.from(stdout));
|
|
47
|
+
(proc as unknown as { stderr: EventEmitter }).stderr.emit('data', Buffer.from(stderr));
|
|
48
|
+
proc.emit('close', exitCode);
|
|
49
|
+
}, 0);
|
|
50
|
+
return proc;
|
|
51
|
+
}) as unknown as Spawner;
|
|
52
|
+
return { spawner, capture };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A stub that never emits close, so the timeout path fires. */
|
|
56
|
+
function makeHangingSpawner(capture: Capture): Spawner {
|
|
57
|
+
return vi.fn((_cmd: string, argv: string[]) => {
|
|
58
|
+
capture.argv = argv;
|
|
59
|
+
for (const arg of argv) {
|
|
60
|
+
const match = /^--([^=]+-file)=(.*)$/s.exec(arg);
|
|
61
|
+
if (!match) continue;
|
|
62
|
+
capture.files[match[1]] = readFileSync(match[2], 'utf8');
|
|
63
|
+
capture.dirs.push(dirname(match[2]));
|
|
64
|
+
}
|
|
65
|
+
const proc = new EventEmitter() as ReturnType<Spawner>;
|
|
66
|
+
(proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stdout = new EventEmitter();
|
|
67
|
+
(proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stderr = new EventEmitter();
|
|
68
|
+
proc.kill = vi.fn();
|
|
69
|
+
return proc;
|
|
70
|
+
}) as unknown as Spawner;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const big = (n: number) => 'x'.repeat(n);
|
|
74
|
+
|
|
75
|
+
describe('payloadArg', () => {
|
|
76
|
+
it('keeps a small value on the inline flag verbatim', () => {
|
|
77
|
+
const arg = payloadArg('body-html', 'body-html-file', '<p>hi</p>', 'html');
|
|
78
|
+
expect(arg).toBe('--body-html=<p>hi</p>');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('keeps a value exactly at the threshold inline', () => {
|
|
82
|
+
const value = big(PAYLOAD_INLINE_MAX);
|
|
83
|
+
expect(payloadArg('body', 'body-file', value)).toBe(`--body=${value}`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('switches to a file arg one byte over the threshold', () => {
|
|
87
|
+
const value = big(PAYLOAD_INLINE_MAX + 1);
|
|
88
|
+
expect(payloadArg('body-html', 'body-html-file', value, 'html')).toEqual({
|
|
89
|
+
kind: 'file',
|
|
90
|
+
flag: 'body-html-file',
|
|
91
|
+
contents: value,
|
|
92
|
+
ext: 'html',
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('leaves ext undefined when not supplied, so the runner defaults it', () => {
|
|
97
|
+
const arg = payloadArg('body', 'body-file', big(PAYLOAD_INLINE_MAX + 1));
|
|
98
|
+
expect(arg).toMatchObject({ kind: 'file', flag: 'body-file', ext: undefined });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// A char-based check would let ~3x the byte budget through. Measure bytes.
|
|
102
|
+
it('measures BYTES not characters for multibyte payloads', () => {
|
|
103
|
+
// '—' (em dash) is 3 bytes in UTF-8. Just over the byte cap, well under it
|
|
104
|
+
// by character count.
|
|
105
|
+
const value = '—'.repeat(Math.floor(PAYLOAD_INLINE_MAX / 3) + 1);
|
|
106
|
+
expect(value.length).toBeLessThanOrEqual(PAYLOAD_INLINE_MAX);
|
|
107
|
+
expect(Buffer.byteLength(value, 'utf8')).toBeGreaterThan(PAYLOAD_INLINE_MAX);
|
|
108
|
+
expect(isGogFileArg(payloadArg('body', 'body-file', value))).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('run with GogFileArgs', () => {
|
|
113
|
+
it('substitutes a file arg with a real path whose file holds the exact bytes', async () => {
|
|
114
|
+
const body = big(PAYLOAD_INLINE_MAX + 1);
|
|
115
|
+
const { spawner, capture } = makeCapturingSpawner(0, '{"ok":true}');
|
|
116
|
+
|
|
117
|
+
await run(['gmail', 'drafts', 'create', payloadArg('body-html', 'body-html-file', body, 'html')], { spawner });
|
|
118
|
+
|
|
119
|
+
const fileArg = capture.argv.find((a) => a.startsWith('--body-html-file='))!;
|
|
120
|
+
expect(fileArg).toBeDefined();
|
|
121
|
+
const path = fileArg.slice('--body-html-file='.length);
|
|
122
|
+
expect(path).not.toContain(body);
|
|
123
|
+
expect(path.endsWith('.html')).toBe(true);
|
|
124
|
+
expect(capture.files['body-html-file']).toBe(body);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('defaults the temp-file extension to txt when ext is omitted', async () => {
|
|
128
|
+
const { spawner, capture } = makeCapturingSpawner(0, '{}');
|
|
129
|
+
await run(['gmail', 'send', { kind: 'file', flag: 'body-file', contents: 'hello' }], { spawner });
|
|
130
|
+
const fileArg = capture.argv.find((a) => a.startsWith('--body-file='))!;
|
|
131
|
+
expect(fileArg.endsWith('.txt')).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('round-trips UTF-8 punctuation byte-for-byte through the temp file', async () => {
|
|
135
|
+
// The characters an LLM-authored mail body actually contains, and the ones
|
|
136
|
+
// a lossy encoding step would mangle first.
|
|
137
|
+
const tricky = 'em—dash en–dash minus−sign “curly” ‘quotes’ ellipsis… ✓ 日本語 🎉';
|
|
138
|
+
const body = tricky + '\n' + big(PAYLOAD_INLINE_MAX) + '\n' + tricky;
|
|
139
|
+
const { spawner, capture } = makeCapturingSpawner(0, '{}');
|
|
140
|
+
|
|
141
|
+
await run(['gmail', 'send', payloadArg('body-html', 'body-html-file', body, 'html')], { spawner });
|
|
142
|
+
|
|
143
|
+
expect(capture.files['body-html-file']).toBe(body);
|
|
144
|
+
expect(Buffer.from(capture.files['body-html-file'], 'utf8')).toEqual(Buffer.from(body, 'utf8'));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('writes each file arg to its own path when one command carries two payloads', async () => {
|
|
148
|
+
// --body-file and --signature-file share the .txt extension; a fixed
|
|
149
|
+
// basename would have the second clobber the first.
|
|
150
|
+
const bodyText = 'BODY ' + big(PAYLOAD_INLINE_MAX);
|
|
151
|
+
const sigText = 'SIG ' + big(PAYLOAD_INLINE_MAX);
|
|
152
|
+
const { spawner, capture } = makeCapturingSpawner(0, '{}');
|
|
153
|
+
|
|
154
|
+
await run([
|
|
155
|
+
'gmail', 'send',
|
|
156
|
+
payloadArg('body', 'body-file', bodyText),
|
|
157
|
+
payloadArg('signature', 'signature-file', sigText),
|
|
158
|
+
], { spawner });
|
|
159
|
+
|
|
160
|
+
expect(capture.files['body-file']).toBe(bodyText);
|
|
161
|
+
expect(capture.files['signature-file']).toBe(sigText);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('leaves plain string args untouched alongside a file arg', async () => {
|
|
165
|
+
const { spawner, capture } = makeCapturingSpawner(0, '{}');
|
|
166
|
+
await run([
|
|
167
|
+
'gmail', 'drafts', 'create',
|
|
168
|
+
'--to=a@b.com',
|
|
169
|
+
payloadArg('body', 'body-file', big(PAYLOAD_INLINE_MAX + 1)),
|
|
170
|
+
'--subject=Hi',
|
|
171
|
+
], { spawner });
|
|
172
|
+
|
|
173
|
+
expect(capture.argv.slice(0, 6)).toEqual([
|
|
174
|
+
'--json', '--color=never', '--no-input', 'gmail', 'drafts', 'create',
|
|
175
|
+
]);
|
|
176
|
+
expect(capture.argv).toContain('--to=a@b.com');
|
|
177
|
+
expect(capture.argv).toContain('--subject=Hi');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('removes the temp dir after a successful run', async () => {
|
|
181
|
+
const { spawner, capture } = makeCapturingSpawner(0, '{"ok":true}');
|
|
182
|
+
await run(['gmail', 'send', payloadArg('body', 'body-file', big(PAYLOAD_INLINE_MAX + 1))], { spawner });
|
|
183
|
+
expect(capture.dirs).toHaveLength(1);
|
|
184
|
+
expect(existsSync(capture.dirs[0])).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('removes the temp dir after gog exits non-zero', async () => {
|
|
188
|
+
const { spawner, capture } = makeCapturingSpawner(1, '', 'gog: boom');
|
|
189
|
+
await expect(
|
|
190
|
+
run(['gmail', 'send', payloadArg('body', 'body-file', big(PAYLOAD_INLINE_MAX + 1))], { spawner }),
|
|
191
|
+
).rejects.toThrow('gog: boom');
|
|
192
|
+
expect(capture.dirs).toHaveLength(1);
|
|
193
|
+
expect(existsSync(capture.dirs[0])).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('removes the temp dir after a timeout', async () => {
|
|
197
|
+
// Real timers with a tiny timeout, deliberately: the temp file is written
|
|
198
|
+
// with real fs IO before the spawn, and vitest's fake timers can't advance
|
|
199
|
+
// past a pending IO callback to reach the timeout that is armed after it.
|
|
200
|
+
const capture: Capture = { argv: [], files: {}, dirs: [] };
|
|
201
|
+
const spawner = makeHangingSpawner(capture);
|
|
202
|
+
|
|
203
|
+
await expect(
|
|
204
|
+
run(['gmail', 'send', payloadArg('body', 'body-file', big(PAYLOAD_INLINE_MAX + 1))], {
|
|
205
|
+
spawner,
|
|
206
|
+
timeout: 20,
|
|
207
|
+
}),
|
|
208
|
+
).rejects.toThrow('gog timed out after 20ms');
|
|
209
|
+
|
|
210
|
+
expect(capture.dirs).toHaveLength(1);
|
|
211
|
+
expect(existsSync(capture.dirs[0])).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('forwards GogFileArgs unmaterialized to an injected executor', async () => {
|
|
215
|
+
// The hosted (Worker/Fly) executor does its own materialization on the
|
|
216
|
+
// remote side, so run() must hand it the union, not a local path.
|
|
217
|
+
const { runExecutor } = await import('../src/runner.js');
|
|
218
|
+
let seen: GogArg[] = [];
|
|
219
|
+
const executor = vi.fn(async (args: GogArg[]) => { seen = args; return '{}'; });
|
|
220
|
+
await runExecutor.run({ executor }, () =>
|
|
221
|
+
run(['gmail', 'send', payloadArg('body', 'body-file', big(PAYLOAD_INLINE_MAX + 1))], {}),
|
|
222
|
+
);
|
|
223
|
+
expect(seen.at(-1)).toMatchObject({ kind: 'file', flag: 'body-file' });
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('isGogFileArg', () => {
|
|
228
|
+
it('distinguishes plain strings from file args', () => {
|
|
229
|
+
expect(isGogFileArg('--body=hi')).toBe(false);
|
|
230
|
+
expect(isGogFileArg({ kind: 'file', flag: 'body-file', contents: 'hi' })).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
});
|
package/tests/runner.test.ts
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
3
|
+
import { spawn as mockedSpawn } from 'node:child_process';
|
|
4
|
+
import { run, runBinary, runExecutor } from '../src/runner.js';
|
|
5
|
+
import type { Spawner, GogExecutor } from '../src/runner.js';
|
|
6
|
+
|
|
7
|
+
// The real spawn is dynamically imported inside runner's default executor.
|
|
8
|
+
// Mock it so the no-spawner/no-executor fallback can be exercised without
|
|
9
|
+
// touching a real `gog` binary.
|
|
10
|
+
vi.mock('node:child_process', () => ({ spawn: vi.fn() }));
|
|
11
|
+
|
|
12
|
+
function makeProc(exitCode: number, stdout = '', stderr = ''): ReturnType<Spawner> {
|
|
13
|
+
const proc = new EventEmitter() as ReturnType<Spawner>;
|
|
14
|
+
(proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stdout = new EventEmitter();
|
|
15
|
+
(proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stderr = new EventEmitter();
|
|
16
|
+
setTimeout(() => {
|
|
17
|
+
(proc as unknown as { stdout: EventEmitter }).stdout.emit('data', Buffer.from(stdout));
|
|
18
|
+
(proc as unknown as { stderr: EventEmitter }).stderr.emit('data', Buffer.from(stderr));
|
|
19
|
+
proc.emit('close', exitCode);
|
|
20
|
+
}, 0);
|
|
21
|
+
return proc;
|
|
22
|
+
}
|
|
5
23
|
|
|
6
24
|
function makeSpawner(exitCode: number, stdout = '', stderr = ''): Spawner {
|
|
7
|
-
return vi.fn(() =>
|
|
8
|
-
const proc = new EventEmitter() as ReturnType<Spawner>;
|
|
9
|
-
(proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stdout = new EventEmitter();
|
|
10
|
-
(proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stderr = new EventEmitter();
|
|
11
|
-
setTimeout(() => {
|
|
12
|
-
(proc as unknown as { stdout: EventEmitter }).stdout.emit('data', Buffer.from(stdout));
|
|
13
|
-
(proc as unknown as { stderr: EventEmitter }).stderr.emit('data', Buffer.from(stderr));
|
|
14
|
-
proc.emit('close', exitCode);
|
|
15
|
-
}, 0);
|
|
16
|
-
return proc;
|
|
17
|
-
}) as unknown as Spawner;
|
|
25
|
+
return vi.fn(() => makeProc(exitCode, stdout, stderr)) as unknown as Spawner;
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
describe('run', () => {
|
|
@@ -542,6 +550,72 @@ describe('run', () => {
|
|
|
542
550
|
}
|
|
543
551
|
});
|
|
544
552
|
|
|
553
|
+
it('redacts Google tokens from SUCCESS stdout surfaced to the client', async () => {
|
|
554
|
+
// A successful `gog auth ... ` that echoes credentials (e.g. a token dump)
|
|
555
|
+
// must not leak them into model context on the resolve path.
|
|
556
|
+
const stdoutLeak =
|
|
557
|
+
'{"access_token":"ya29.a0Ad52N3-LEAKED-SUCCESS-TOKEN","refresh_token":"1//0eLEAKED-SUCCESS-REFRESH"}';
|
|
558
|
+
const spawner = makeSpawner(0, stdoutLeak, '');
|
|
559
|
+
const out = await run(['auth', 'list'], { spawner });
|
|
560
|
+
expect(out).not.toContain('ya29.a0Ad52N3-LEAKED-SUCCESS-TOKEN');
|
|
561
|
+
expect(out).not.toContain('1//0eLEAKED-SUCCESS-REFRESH');
|
|
562
|
+
expect(out).toContain('[REDACTED]');
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('redacts Google tokens from interactive SUCCESS stdout+stderr', async () => {
|
|
566
|
+
const spawner = makeSpawner(0, 'token ya29.a0Ad52N3-INTERACTIVE-LEAK done', 'note line');
|
|
567
|
+
const out = await run(['auth', 'add', 'x@y.com'], { spawner, interactive: true });
|
|
568
|
+
expect(out).not.toContain('ya29.a0Ad52N3-INTERACTIVE-LEAK');
|
|
569
|
+
expect(out).toContain('[REDACTED]');
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("redactMode 'tokens' preserves OAuth scope names the shared redactor mangles", async () => {
|
|
573
|
+
// The shared redactor treats a `classroom.coursework.students` scope as a
|
|
574
|
+
// secret and replaces it — corrupting a step-1 auth URL. 'tokens' mode must
|
|
575
|
+
// leave scope names intact.
|
|
576
|
+
const authUrl =
|
|
577
|
+
'https://accounts.google.com/o/oauth2/auth?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fclassroom.coursework.students+openid&state=abc123';
|
|
578
|
+
const spawner = makeSpawner(0, JSON.stringify({ auth_url: authUrl }), '');
|
|
579
|
+
const full = await run(['auth', 'add'], { spawner });
|
|
580
|
+
expect(full).toContain('[REDACTED]'); // full mode mangles the scope
|
|
581
|
+
const spawner2 = makeSpawner(0, JSON.stringify({ auth_url: authUrl }), '');
|
|
582
|
+
const tokensOnly = await run(['auth', 'add'], { spawner: spawner2, redactMode: 'tokens' });
|
|
583
|
+
expect(tokensOnly).toContain('classroom.coursework.students');
|
|
584
|
+
expect(tokensOnly).not.toContain('[REDACTED]');
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("redactMode 'tokens' still strips real Google tokens", async () => {
|
|
588
|
+
const leak = 'url with token ya29.a0Ad52N3-STEP-LEAK and refresh 1//0eSTEP-REFRESH-LEAK';
|
|
589
|
+
const spawner = makeSpawner(0, leak, '');
|
|
590
|
+
const out = await run(['auth', 'add'], { spawner, redactMode: 'tokens' });
|
|
591
|
+
expect(out).not.toContain('ya29.a0Ad52N3-STEP-LEAK');
|
|
592
|
+
expect(out).not.toContain('1//0eSTEP-REFRESH-LEAK');
|
|
593
|
+
expect(out).toContain('[REDACTED]');
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("redactMode 'tokens' strips tokens from thrown error text too", async () => {
|
|
597
|
+
const spawner = makeSpawner(1, '', 'boom ya29.a0Ad52N3-ERR-LEAK end');
|
|
598
|
+
await expect(run(['auth', 'add'], { spawner, redactMode: 'tokens' })).rejects.toThrow('[REDACTED]');
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it('runBinary returns stdout base64-encoded, not the raw string', async () => {
|
|
602
|
+
const spawner = makeSpawner(0, '%PDF-1.4 body');
|
|
603
|
+
const out = await runBinary(['api', 'call', 'drive', 'v3', 'files.get'], { spawner });
|
|
604
|
+
expect(out).toBe(Buffer.from('%PDF-1.4 body').toString('base64'));
|
|
605
|
+
expect(out).not.toBe('%PDF-1.4 body'); // base64, so bytes survive intact
|
|
606
|
+
const callArgs = (spawner as ReturnType<typeof vi.fn>).mock.calls[0][1] as string[];
|
|
607
|
+
expect(callArgs).toContain('--json');
|
|
608
|
+
expect(callArgs).toContain('files.get');
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('runBinary refuses over the hosted-connector forward executor', async () => {
|
|
612
|
+
const executor = vi.fn();
|
|
613
|
+
await expect(
|
|
614
|
+
runExecutor.run({ executor }, () => runBinary(['api', 'call', 'drive', 'v3', 'files.get'])),
|
|
615
|
+
).rejects.toThrow('not available over the hosted connector');
|
|
616
|
+
expect(executor).not.toHaveBeenCalled();
|
|
617
|
+
});
|
|
618
|
+
|
|
545
619
|
it('ignores timeout if close event already settled the promise', async () => {
|
|
546
620
|
vi.useFakeTimers();
|
|
547
621
|
const spawner = vi.fn(() => {
|
|
@@ -569,3 +643,137 @@ describe('run', () => {
|
|
|
569
643
|
vi.useRealTimers();
|
|
570
644
|
});
|
|
571
645
|
});
|
|
646
|
+
|
|
647
|
+
describe('run --readonly (gog 0.31)', () => {
|
|
648
|
+
function withReadonlyEnv<T>(value: string | undefined, fn: () => T): T {
|
|
649
|
+
const original = process.env.GOG_READONLY;
|
|
650
|
+
if (value === undefined) delete process.env.GOG_READONLY;
|
|
651
|
+
else process.env.GOG_READONLY = value;
|
|
652
|
+
try {
|
|
653
|
+
return fn();
|
|
654
|
+
} finally {
|
|
655
|
+
if (original === undefined) delete process.env.GOG_READONLY;
|
|
656
|
+
else process.env.GOG_READONLY = original;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
it('injects --readonly when options.readonly is true', async () => {
|
|
661
|
+
const spawner = makeSpawner(0, '{}');
|
|
662
|
+
await withReadonlyEnv(undefined, () => run(['drive', 'list'], { readonly: true, spawner }));
|
|
663
|
+
expect(spawner).toHaveBeenCalledWith(
|
|
664
|
+
'gog',
|
|
665
|
+
['--json', '--color=never', '--no-input', '--readonly', 'drive', 'list'],
|
|
666
|
+
expect.any(Object),
|
|
667
|
+
);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it('injects --readonly when GOG_READONLY is set to a truthy value', async () => {
|
|
671
|
+
const spawner = makeSpawner(0, '{}');
|
|
672
|
+
await withReadonlyEnv('1', () => run(['drive', 'list'], { spawner }));
|
|
673
|
+
expect(spawner).toHaveBeenCalledWith(
|
|
674
|
+
'gog',
|
|
675
|
+
['--json', '--color=never', '--no-input', '--readonly', 'drive', 'list'],
|
|
676
|
+
expect.any(Object),
|
|
677
|
+
);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('does not inject --readonly when GOG_READONLY is an explicit off value', async () => {
|
|
681
|
+
const spawner = makeSpawner(0, '{}');
|
|
682
|
+
await withReadonlyEnv('false', () => run(['drive', 'list'], { spawner }));
|
|
683
|
+
expect(spawner).toHaveBeenCalledWith(
|
|
684
|
+
'gog',
|
|
685
|
+
['--json', '--color=never', '--no-input', 'drive', 'list'],
|
|
686
|
+
expect.any(Object),
|
|
687
|
+
);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it('does not inject --readonly by default', async () => {
|
|
691
|
+
const spawner = makeSpawner(0, '{}');
|
|
692
|
+
await withReadonlyEnv(undefined, () => run(['drive', 'list'], { spawner }));
|
|
693
|
+
const call = (spawner as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]!;
|
|
694
|
+
expect(call[1]).not.toContain('--readonly');
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// GOG_READONLY is fail-safe: an unrecognised (but set) value blocks writes
|
|
698
|
+
// rather than silently allowing them.
|
|
699
|
+
it('injects --readonly when GOG_READONLY is set to an unrecognised value', async () => {
|
|
700
|
+
const spawner = makeSpawner(0, '{}');
|
|
701
|
+
await withReadonlyEnv('enable-please', () => run(['drive', 'list'], { spawner }));
|
|
702
|
+
const call = (spawner as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]!;
|
|
703
|
+
expect(call[1]).toContain('--readonly');
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('does not inject --readonly when GOG_READONLY is an unresolved .mcpb placeholder', async () => {
|
|
707
|
+
const spawner = makeSpawner(0, '{}');
|
|
708
|
+
await withReadonlyEnv('${user_config.gog_readonly}', () => run(['drive', 'list'], { spawner }));
|
|
709
|
+
const call = (spawner as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]!;
|
|
710
|
+
expect(call[1]).not.toContain('--readonly');
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
describe('run executor seam', () => {
|
|
715
|
+
it('routes to an injected runExecutor executor when no options.spawner is given', async () => {
|
|
716
|
+
const executor = vi.fn(async () => '{"via":"executor"}') as unknown as GogExecutor;
|
|
717
|
+
const result = await runExecutor.run({ executor }, () => run(['sheets', 'get', 'id1', 'A1']));
|
|
718
|
+
expect(result).toBe('{"via":"executor"}');
|
|
719
|
+
// The executor receives the FULLY-ASSEMBLED gog arg list plus the run opts.
|
|
720
|
+
expect(executor).toHaveBeenCalledWith(
|
|
721
|
+
['--json', '--color=never', '--no-input', 'sheets', 'get', 'id1', 'A1'],
|
|
722
|
+
{ timeout: undefined, interactive: false },
|
|
723
|
+
);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('forwards timeout and interactive through to the injected executor', async () => {
|
|
727
|
+
const executor = vi.fn(async () => '{}') as unknown as GogExecutor;
|
|
728
|
+
await runExecutor.run({ executor }, () =>
|
|
729
|
+
run(['auth', 'add', 'u@g.com'], { interactive: true, timeout: 60_000 }),
|
|
730
|
+
);
|
|
731
|
+
expect(executor).toHaveBeenCalledWith(
|
|
732
|
+
['--json', '--color=never', 'auth', 'add', 'u@g.com'],
|
|
733
|
+
{ timeout: 60_000, interactive: true },
|
|
734
|
+
);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it('redacts Google tokens returned by an injected executor', async () => {
|
|
738
|
+
const executor = vi.fn(async () => 'token ya29.a0Ad52N3-ALS-LEAK done') as unknown as GogExecutor;
|
|
739
|
+
const result = await runExecutor.run({ executor }, () => run(['auth', 'list']));
|
|
740
|
+
expect(result).not.toContain('ya29.a0Ad52N3-ALS-LEAK');
|
|
741
|
+
expect(result).toContain('[REDACTED]');
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it('redacts error text thrown by an injected executor', async () => {
|
|
745
|
+
const executor = vi.fn(async () => {
|
|
746
|
+
throw new Error('boom 1//0eALS-REFRESH-LEAK end');
|
|
747
|
+
}) as unknown as GogExecutor;
|
|
748
|
+
try {
|
|
749
|
+
await runExecutor.run({ executor }, () => run(['gmail', 'get', 'm1']));
|
|
750
|
+
throw new Error('expected rejection');
|
|
751
|
+
} catch (e) {
|
|
752
|
+
const msg = (e as Error).message;
|
|
753
|
+
expect(msg).not.toContain('1//0eALS-REFRESH-LEAK');
|
|
754
|
+
expect(msg).toContain('[REDACTED]');
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it('options.spawner takes precedence over an injected ALS executor', async () => {
|
|
759
|
+
const spawner = makeSpawner(0, '{"via":"spawner"}');
|
|
760
|
+
const executor = vi.fn(async () => '{"via":"executor"}') as unknown as GogExecutor;
|
|
761
|
+
const result = await runExecutor.run({ executor }, () =>
|
|
762
|
+
run(['sheets', 'get', 'id1', 'A1'], { spawner }),
|
|
763
|
+
);
|
|
764
|
+
expect(result).toBe('{"via":"spawner"}');
|
|
765
|
+
expect(executor).not.toHaveBeenCalled();
|
|
766
|
+
expect(spawner).toHaveBeenCalledOnce();
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it('falls back to the lazily-imported real spawn when neither a spawner nor an ALS executor is set', async () => {
|
|
770
|
+
vi.mocked(mockedSpawn).mockImplementation((() => makeProc(0, '{"real":true}')) as never);
|
|
771
|
+
const result = await run(['sheets', 'get', 'id1', 'A1']);
|
|
772
|
+
expect(result).toBe('{"real":true}');
|
|
773
|
+
expect(mockedSpawn).toHaveBeenCalledWith(
|
|
774
|
+
'gog',
|
|
775
|
+
['--json', '--color=never', '--no-input', 'sheets', 'get', 'id1', 'A1'],
|
|
776
|
+
expect.objectContaining({ env: expect.any(Object) }),
|
|
777
|
+
);
|
|
778
|
+
});
|
|
779
|
+
});
|
package/tests/server.test.ts
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
3
|
+
import { BASE_TOOL_REGISTRARS, VERSION } from '../src/server.js';
|
|
4
4
|
|
|
5
|
-
describe('
|
|
6
|
-
it('
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
expect(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
5
|
+
describe('BASE_TOOL_REGISTRARS', () => {
|
|
6
|
+
it('registers every base service without duplicate tool names', async () => {
|
|
7
|
+
const harness = await createTestHarness((server) => {
|
|
8
|
+
for (const register of BASE_TOOL_REGISTRARS) {
|
|
9
|
+
register(server, undefined);
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
const names = (await harness.listTools()).map((t) => t.name);
|
|
13
|
+
expect(new Set(names).size).toBe(names.length);
|
|
14
|
+
// One representative tool per service registrar, in registrar order.
|
|
15
|
+
for (const expected of [
|
|
16
|
+
'gog_api_list',
|
|
17
|
+
'gog_auth_list',
|
|
18
|
+
'gog_calendar_events',
|
|
19
|
+
'gog_classroom_courses_list',
|
|
20
|
+
'gog_contacts_list',
|
|
21
|
+
'gog_docs_cat',
|
|
22
|
+
'gog_drive_ls',
|
|
23
|
+
'gog_gmail_search',
|
|
24
|
+
'gog_sheets_get',
|
|
25
|
+
'gog_slides_export',
|
|
26
|
+
'gog_tasks_lists',
|
|
27
|
+
]) {
|
|
28
|
+
expect(names).toContain(expected);
|
|
29
|
+
}
|
|
30
|
+
await harness.close();
|
|
31
31
|
});
|
|
32
32
|
});
|
|
33
33
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { registerApiTools } from '../../src/tools/api.js';
|
|
3
|
+
import * as runner from '../../src/runner.js';
|
|
4
|
+
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
5
|
+
|
|
6
|
+
vi.mock('../../src/runner.js');
|
|
7
|
+
|
|
8
|
+
const setupHandlers = () => createTestHarness(registerApiTools);
|
|
9
|
+
|
|
10
|
+
beforeEach(() => vi.clearAllMocks());
|
|
11
|
+
|
|
12
|
+
describe('gog_api_list', () => {
|
|
13
|
+
it('lists the default API set', async () => {
|
|
14
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
15
|
+
const harness = await setupHandlers();
|
|
16
|
+
await harness.callTool('gog_api_list', {});
|
|
17
|
+
expect(runner.run).toHaveBeenCalledWith(['api', 'list'], { account: undefined });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('adds --all when requested', async () => {
|
|
21
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
22
|
+
const harness = await setupHandlers();
|
|
23
|
+
await harness.callTool('gog_api_list', { all: true });
|
|
24
|
+
expect(runner.run).toHaveBeenCalledWith(['api', 'list', '--all'], { account: undefined });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns error text on failure', async () => {
|
|
28
|
+
vi.mocked(runner.run).mockRejectedValue(new Error('List failed'));
|
|
29
|
+
const harness = await setupHandlers();
|
|
30
|
+
const result = await harness.callTool('gog_api_list', {});
|
|
31
|
+
expect(result.content[0].text).toBe('Error: List failed');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('gog_api_describe', () => {
|
|
36
|
+
it('describes a whole API', async () => {
|
|
37
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
38
|
+
const harness = await setupHandlers();
|
|
39
|
+
await harness.callTool('gog_api_describe', { api: 'drive', version: 'v3' });
|
|
40
|
+
expect(runner.run).toHaveBeenCalledWith(['api', 'describe', 'drive', 'v3'], { account: undefined });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('describes a single method when method is provided', async () => {
|
|
44
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
45
|
+
const harness = await setupHandlers();
|
|
46
|
+
await harness.callTool('gog_api_describe', { api: 'drive', version: 'v3', method: 'files.list' });
|
|
47
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
48
|
+
['api', 'describe', 'drive', 'v3', 'files.list'],
|
|
49
|
+
{ account: undefined },
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns error text on failure', async () => {
|
|
54
|
+
vi.mocked(runner.run).mockRejectedValue(new Error('Describe failed'));
|
|
55
|
+
const harness = await setupHandlers();
|
|
56
|
+
const result = await harness.callTool('gog_api_describe', { api: 'x', version: 'v1' });
|
|
57
|
+
expect(result.content[0].text).toBe('Error: Describe failed');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('gog_api_call', () => {
|
|
62
|
+
it('calls a read method with params', async () => {
|
|
63
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
64
|
+
const harness = await setupHandlers();
|
|
65
|
+
await harness.callTool('gog_api_call', { api: 'drive', version: 'v3', method: 'files.list', params: '{"q":"x"}' });
|
|
66
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
67
|
+
['api', 'call', 'drive', 'v3', 'files.list', '--params={"q":"x"}'],
|
|
68
|
+
{ account: undefined },
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('passes body, scope, allow-write and dry-run for a write method', async () => {
|
|
73
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
74
|
+
const harness = await setupHandlers();
|
|
75
|
+
await harness.callTool('gog_api_call', {
|
|
76
|
+
api: 'drive', version: 'v3', method: 'files.create',
|
|
77
|
+
params: '{"fields":"id"}', body: '{"name":"f"}', scope: 'https://www.googleapis.com/auth/drive',
|
|
78
|
+
allowWrite: true, dryRun: true, account: 'a@b.com',
|
|
79
|
+
});
|
|
80
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
81
|
+
[
|
|
82
|
+
'api', 'call', 'drive', 'v3', 'files.create',
|
|
83
|
+
'--params={"fields":"id"}', '--body={"name":"f"}',
|
|
84
|
+
'--scope=https://www.googleapis.com/auth/drive', '--allow-write', '--dry-run', '--force',
|
|
85
|
+
],
|
|
86
|
+
{ account: 'a@b.com' },
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('appends --force last even when dry-run is absent', async () => {
|
|
91
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
92
|
+
const harness = await setupHandlers();
|
|
93
|
+
await harness.callTool('gog_api_call', {
|
|
94
|
+
api: 'drive', version: 'v3', method: 'files.create', allowWrite: true,
|
|
95
|
+
});
|
|
96
|
+
const passedArgs = vi.mocked(runner.run).mock.calls[0][0];
|
|
97
|
+
expect(passedArgs).toEqual(['api', 'call', 'drive', 'v3', 'files.create', '--allow-write', '--force']);
|
|
98
|
+
expect(passedArgs[passedArgs.length - 1]).toBe('--force');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns error text on failure', async () => {
|
|
102
|
+
vi.mocked(runner.run).mockRejectedValue(new Error('Call failed'));
|
|
103
|
+
const harness = await setupHandlers();
|
|
104
|
+
const result = await harness.callTool('gog_api_call', { api: 'drive', version: 'v3', method: 'files.list' });
|
|
105
|
+
expect(result.content[0].text).toBe('Error: Call failed');
|
|
106
|
+
});
|
|
107
|
+
});
|