gogcli-mcp 2.30.0 → 4.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +15345 -18417
- package/dist/lib.js +13720 -9481
- package/manifest.json +2 -2
- package/mint.yaml +37 -33
- package/package.json +5 -5
- package/server.json +2 -2
- package/src/attachments.ts +28 -34
- package/src/blob-upload.ts +165 -134
- package/src/blob-urls.ts +3 -5
- package/src/bootstrap-auth.ts +97 -0
- package/src/gmail-dispatch-guard.ts +106 -0
- package/src/gmail-results.ts +1 -1
- package/src/index.ts +3 -4
- package/src/lib.ts +24 -9
- package/src/pagination.ts +1 -1
- package/src/runner.ts +23 -175
- package/src/tools/api.ts +7 -7
- package/src/tools/appscript.ts +16 -16
- package/src/tools/auth.ts +16 -16
- package/src/tools/calendar.ts +13 -13
- package/src/tools/chat.ts +25 -25
- package/src/tools/classroom.ts +49 -49
- package/src/tools/contacts.ts +9 -9
- package/src/tools/docs.ts +13 -13
- package/src/tools/drive.ts +23 -25
- package/src/tools/gmail.ts +145 -32
- package/src/tools/sheets.ts +15 -15
- package/src/tools/slides.ts +13 -13
- package/src/tools/tasks.ts +13 -13
- package/src/tools/utils.ts +14 -61
- package/tests/attachments.test.ts +11 -14
- package/tests/blob-upload.test.ts +235 -160
- package/tests/bootstrap-auth.test.ts +245 -0
- package/tests/gmail-dispatch-guard.test.ts +132 -0
- package/tests/runner-file-args.test.ts +1 -13
- package/tests/runner.test.ts +8 -95
- package/tests/sdk-single-copy.test.ts +11 -37
- package/tests/tools/appscript.test.ts +1 -1
- package/tests/tools/auth-401-shapes.test.ts +2 -3
- package/tests/tools/auth.test.ts +5 -4
- package/tests/tools/chat.test.ts +1 -1
- package/tests/tools/drive.test.ts +11 -3
- package/tests/tools/gmail.test.ts +244 -14
- package/tests/tools/sheets.test.ts +1 -1
- package/tests/tools/utils.test.ts +1 -50
- package/tests/zod-single-copy.test.ts +8 -16
- package/tsconfig.json +1 -4
- package/vitest.config.ts +2 -14
- package/src/auth-log.ts +0 -205
- package/src/connector-auth.ts +0 -303
- package/src/connector-runtime.ts +0 -887
- package/src/google-probe.ts +0 -113
- package/src/google-token.ts +0 -391
- package/src/remote-runner.ts +0 -77
- package/src/worker.ts +0 -129
- package/tests/auth-log.test.ts +0 -530
- package/tests/connector-auth.test.ts +0 -559
- package/tests/connector-runtime.test.ts +0 -1644
- package/tests/google-probe.test.ts +0 -116
- package/tests/google-token.test.ts +0 -425
- package/tests/remote-runner.test.ts +0 -202
- package/tests/worker.test.ts +0 -167
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { bootstrapGogAuth, AUTH_BOOTSTRAP_MARKER } from '../src/bootstrap-auth.js';
|
|
8
|
+
import type { Spawner } from '../src/runner.js';
|
|
9
|
+
|
|
10
|
+
const FULL = {
|
|
11
|
+
GOG_CLIENT_ID: 'client-id.apps.googleusercontent.com',
|
|
12
|
+
GOG_CLIENT_SECRET: 'GOCSPX-client-secret-value',
|
|
13
|
+
GOG_REFRESH_TOKEN: '1//refresh-token-value',
|
|
14
|
+
GOG_ACCOUNT: 'someone@example.com',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function fingerprint(env: typeof FULL): string {
|
|
18
|
+
return createHash('sha256')
|
|
19
|
+
.update([env.GOG_CLIENT_ID, env.GOG_CLIENT_SECRET, env.GOG_REFRESH_TOKEN, env.GOG_ACCOUNT].join('\0'))
|
|
20
|
+
.digest('hex');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Call {
|
|
24
|
+
argv: string[];
|
|
25
|
+
env: NodeJS.ProcessEnv;
|
|
26
|
+
/** Contents of every temp-file path in argv, read while the child "runs". */
|
|
27
|
+
files: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Reply = { code: number; stdout?: string; stderr?: string } | { error: Error };
|
|
31
|
+
|
|
32
|
+
function makeSpawner(reply: (argv: string[]) => Reply): { spawner: Spawner; calls: Call[] } {
|
|
33
|
+
const calls: Call[] = [];
|
|
34
|
+
const spawner = vi.fn((_cmd: string, argv: string[], opts: { env: NodeJS.ProcessEnv }) => {
|
|
35
|
+
const files = argv.filter((a) => a.includes('gogcli-mcp-') && existsSync(a)).map((p) => readFileSync(p, 'utf8'));
|
|
36
|
+
calls.push({ argv, env: opts.env, files });
|
|
37
|
+
const proc = new EventEmitter() as ReturnType<Spawner>;
|
|
38
|
+
const io = proc as unknown as { stdout: EventEmitter; stderr: EventEmitter };
|
|
39
|
+
io.stdout = new EventEmitter();
|
|
40
|
+
io.stderr = new EventEmitter();
|
|
41
|
+
proc.kill = vi.fn();
|
|
42
|
+
const r = reply(argv);
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
if ('error' in r) {
|
|
45
|
+
proc.emit('error', r.error);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (r.stdout) io.stdout.emit('data', Buffer.from(r.stdout));
|
|
49
|
+
if (r.stderr) io.stderr.emit('data', Buffer.from(r.stderr));
|
|
50
|
+
proc.emit('close', r.code);
|
|
51
|
+
}, 0);
|
|
52
|
+
return proc;
|
|
53
|
+
}) as unknown as Spawner;
|
|
54
|
+
return { spawner, calls };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const listing = (...emails: string[]): string => JSON.stringify({ accounts: emails.map((email) => ({ email })) });
|
|
58
|
+
|
|
59
|
+
const isList = (argv: string[]): boolean => argv.includes('list');
|
|
60
|
+
|
|
61
|
+
describe('bootstrapGogAuth', () => {
|
|
62
|
+
let home: string;
|
|
63
|
+
let stderr: ReturnType<typeof vi.spyOn>;
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
home = mkdtempSync(join(tmpdir(), 'bootstrap-auth-test-'));
|
|
67
|
+
stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
stderr.mockRestore();
|
|
72
|
+
rmSync(home, { recursive: true, force: true });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const stderrText = (): string => stderr.mock.calls.map((c: unknown[]) => String(c[0])).join('');
|
|
76
|
+
|
|
77
|
+
it('does nothing when none of the variables is set', async () => {
|
|
78
|
+
const { spawner, calls } = makeSpawner(() => ({ code: 0 }));
|
|
79
|
+
const status = await bootstrapGogAuth({}, { spawner, home });
|
|
80
|
+
expect(status).toBe('unconfigured');
|
|
81
|
+
expect(calls).toHaveLength(0);
|
|
82
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('treats blank and unresolved-placeholder values as unset', async () => {
|
|
86
|
+
const { spawner, calls } = makeSpawner(() => ({ code: 0 }));
|
|
87
|
+
const status = await bootstrapGogAuth(
|
|
88
|
+
{ GOG_CLIENT_ID: '', GOG_CLIENT_SECRET: '${user_config.secret}', GOG_REFRESH_TOKEN: 'undefined', GOG_ACCOUNT: ' ' },
|
|
89
|
+
{ spawner, home },
|
|
90
|
+
);
|
|
91
|
+
expect(status).toBe('unconfigured');
|
|
92
|
+
expect(calls).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('names the missing variables, and never a value, when only some are set', async () => {
|
|
96
|
+
const { spawner, calls } = makeSpawner(() => ({ code: 0 }));
|
|
97
|
+
const status = await bootstrapGogAuth(
|
|
98
|
+
{ GOG_CLIENT_ID: FULL.GOG_CLIENT_ID, GOG_REFRESH_TOKEN: FULL.GOG_REFRESH_TOKEN },
|
|
99
|
+
{ spawner, home },
|
|
100
|
+
);
|
|
101
|
+
expect(status).toBe('incomplete');
|
|
102
|
+
expect(calls).toHaveLength(0);
|
|
103
|
+
const out = stderrText();
|
|
104
|
+
expect(out).toContain('GOG_CLIENT_SECRET');
|
|
105
|
+
expect(out).toContain('GOG_ACCOUNT');
|
|
106
|
+
expect(out).not.toContain(FULL.GOG_CLIENT_ID);
|
|
107
|
+
expect(out).not.toContain(FULL.GOG_REFRESH_TOKEN);
|
|
108
|
+
expect(stderr).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('imports credentials then the token, only via temp files, and writes a private marker', async () => {
|
|
112
|
+
const { spawner, calls } = makeSpawner(() => ({ code: 0, stdout: '{}' }));
|
|
113
|
+
const status = await bootstrapGogAuth({ ...FULL }, { spawner, home });
|
|
114
|
+
expect(status).toBe('imported');
|
|
115
|
+
expect(calls).toHaveLength(2);
|
|
116
|
+
|
|
117
|
+
const [creds, tokens] = calls;
|
|
118
|
+
expect(creds.argv).toEqual(expect.arrayContaining(['auth', 'credentials', 'set']));
|
|
119
|
+
expect(JSON.parse(creds.files[0])).toEqual({
|
|
120
|
+
installed: { client_id: FULL.GOG_CLIENT_ID, client_secret: FULL.GOG_CLIENT_SECRET },
|
|
121
|
+
});
|
|
122
|
+
expect(tokens.argv).toEqual(expect.arrayContaining(['auth', 'tokens', 'import']));
|
|
123
|
+
expect(JSON.parse(tokens.files[0])).toEqual({ email: FULL.GOG_ACCOUNT, refresh_token: FULL.GOG_REFRESH_TOKEN });
|
|
124
|
+
|
|
125
|
+
// The temp file is the LAST argv element: the positional <path> gog reads.
|
|
126
|
+
for (const call of calls) {
|
|
127
|
+
expect(call.files).toHaveLength(1);
|
|
128
|
+
expect(existsSync(call.argv[call.argv.length - 1])).toBe(false); // cleaned up afterwards
|
|
129
|
+
const argvText = call.argv.join(' ');
|
|
130
|
+
expect(argvText).not.toContain(FULL.GOG_CLIENT_SECRET);
|
|
131
|
+
expect(argvText).not.toContain(FULL.GOG_REFRESH_TOKEN);
|
|
132
|
+
const envText = JSON.stringify(call.env);
|
|
133
|
+
expect(envText).not.toContain(FULL.GOG_CLIENT_SECRET);
|
|
134
|
+
expect(envText).not.toContain(FULL.GOG_REFRESH_TOKEN);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const markerDir = join(home, '.gogcli-mcp');
|
|
138
|
+
const marker = join(markerDir, AUTH_BOOTSTRAP_MARKER);
|
|
139
|
+
expect(readFileSync(marker, 'utf8')).toBe(fingerprint(FULL));
|
|
140
|
+
expect(statSync(markerDir).mode & 0o777).toBe(0o700);
|
|
141
|
+
expect(statSync(marker).mode & 0o777).toBe(0o600);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('reports present and does nothing when the marker matches and gog lists the account', async () => {
|
|
145
|
+
mkdirSync(join(home, '.gogcli-mcp'));
|
|
146
|
+
writeFileSync(join(home, '.gogcli-mcp', AUTH_BOOTSTRAP_MARKER), fingerprint(FULL));
|
|
147
|
+
const { spawner, calls } = makeSpawner(() => ({ code: 0, stdout: listing('other@example.com', 'SomeOne@Example.com') }));
|
|
148
|
+
const status = await bootstrapGogAuth({ ...FULL }, { spawner, home });
|
|
149
|
+
expect(status).toBe('present');
|
|
150
|
+
expect(calls).toHaveLength(1);
|
|
151
|
+
expect(calls[0].argv).toEqual(expect.arrayContaining(['auth', 'list']));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('re-imports when the marker matches but gog no longer lists the account', async () => {
|
|
155
|
+
mkdirSync(join(home, '.gogcli-mcp'));
|
|
156
|
+
writeFileSync(join(home, '.gogcli-mcp', AUTH_BOOTSTRAP_MARKER), fingerprint(FULL));
|
|
157
|
+
const { spawner, calls } = makeSpawner((argv) => ({ code: 0, stdout: isList(argv) ? listing('other@example.com') : '{}' }));
|
|
158
|
+
expect(await bootstrapGogAuth({ ...FULL }, { spawner, home })).toBe('imported');
|
|
159
|
+
expect(calls.map((c) => c.argv.includes('list'))).toEqual([true, false, false]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it.each([
|
|
163
|
+
['an unparseable listing', { code: 0, stdout: 'not json' }],
|
|
164
|
+
['a listing without accounts', { code: 0, stdout: '{"accounts":null}' }],
|
|
165
|
+
['a listing whose entries carry no email', { code: 0, stdout: '{"accounts":[{}]}' }],
|
|
166
|
+
['a failing listing', { code: 1, stderr: 'keyring locked' }],
|
|
167
|
+
])('re-imports when the marker matches but gog returns %s', async (_label, listReply) => {
|
|
168
|
+
mkdirSync(join(home, '.gogcli-mcp'));
|
|
169
|
+
writeFileSync(join(home, '.gogcli-mcp', AUTH_BOOTSTRAP_MARKER), fingerprint(FULL));
|
|
170
|
+
const { spawner, calls } = makeSpawner((argv) => (isList(argv) ? listReply : { code: 0, stdout: '{}' }));
|
|
171
|
+
expect(await bootstrapGogAuth({ ...FULL }, { spawner, home })).toBe('imported');
|
|
172
|
+
expect(calls).toHaveLength(3);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('re-imports without listing when the secret was rotated (marker differs)', async () => {
|
|
176
|
+
mkdirSync(join(home, '.gogcli-mcp'));
|
|
177
|
+
writeFileSync(join(home, '.gogcli-mcp', AUTH_BOOTSTRAP_MARKER), 'stale-fingerprint');
|
|
178
|
+
const { spawner, calls } = makeSpawner(() => ({ code: 0, stdout: '{}' }));
|
|
179
|
+
expect(await bootstrapGogAuth({ ...FULL }, { spawner, home })).toBe('imported');
|
|
180
|
+
expect(calls).toHaveLength(2);
|
|
181
|
+
expect(calls.some((c) => c.argv.includes('list'))).toBe(false);
|
|
182
|
+
expect(readFileSync(join(home, '.gogcli-mcp', AUTH_BOOTSTRAP_MARKER), 'utf8')).toBe(fingerprint(FULL));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('fails without throwing, redacts, and writes no marker when gog rejects the import', async () => {
|
|
186
|
+
const { spawner } = makeSpawner((argv) =>
|
|
187
|
+
argv.includes('tokens')
|
|
188
|
+
? { code: 1, stderr: `bad token ${FULL.GOG_REFRESH_TOKEN} for secret ${FULL.GOG_CLIENT_SECRET}` }
|
|
189
|
+
: { code: 0, stdout: '{}' },
|
|
190
|
+
);
|
|
191
|
+
expect(await bootstrapGogAuth({ ...FULL }, { spawner, home })).toBe('failed');
|
|
192
|
+
const out = stderrText();
|
|
193
|
+
expect(stderr).toHaveBeenCalledTimes(1);
|
|
194
|
+
expect(out).toContain('bad token');
|
|
195
|
+
expect(out).not.toContain(FULL.GOG_REFRESH_TOKEN);
|
|
196
|
+
expect(out).not.toContain(FULL.GOG_CLIENT_SECRET);
|
|
197
|
+
expect(existsSync(join(home, '.gogcli-mcp', AUTH_BOOTSTRAP_MARKER))).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('fails without throwing when gog is not installed', async () => {
|
|
201
|
+
const enoent = Object.assign(new Error('spawn gog ENOENT'), { code: 'ENOENT' });
|
|
202
|
+
const { spawner } = makeSpawner(() => ({ error: enoent }));
|
|
203
|
+
expect(await bootstrapGogAuth({ ...FULL }, { spawner, home })).toBe('failed');
|
|
204
|
+
expect(stderrText()).toContain('gog executable not found');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('fails without throwing when the marker cannot be written', async () => {
|
|
208
|
+
writeFileSync(join(home, '.gogcli-mcp'), 'a file where the directory should be');
|
|
209
|
+
const { spawner } = makeSpawner(() => ({ code: 0, stdout: '{}' }));
|
|
210
|
+
expect(await bootstrapGogAuth({ ...FULL }, { spawner, home })).toBe('failed');
|
|
211
|
+
expect(stderr).toHaveBeenCalledTimes(1);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('stringifies a non-Error failure', async () => {
|
|
215
|
+
const throwing = vi.fn(() => {
|
|
216
|
+
throw 'plain string failure';
|
|
217
|
+
}) as unknown as Spawner;
|
|
218
|
+
expect(await bootstrapGogAuth({ ...FULL }, { spawner: throwing, home })).toBe('failed');
|
|
219
|
+
expect(stderrText()).toContain('plain string failure');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('puts the marker under the OS home directory by default', async () => {
|
|
223
|
+
const savedHome = process.env.HOME;
|
|
224
|
+
process.env.HOME = home;
|
|
225
|
+
try {
|
|
226
|
+
const { spawner } = makeSpawner(() => ({ code: 0, stdout: '{}' }));
|
|
227
|
+
expect(await bootstrapGogAuth({ ...FULL }, { spawner })).toBe('imported');
|
|
228
|
+
expect(readFileSync(join(home, '.gogcli-mcp', AUTH_BOOTSTRAP_MARKER), 'utf8')).toBe(fingerprint(FULL));
|
|
229
|
+
} finally {
|
|
230
|
+
process.env.HOME = savedHome;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('defaults to process.env', async () => {
|
|
235
|
+
// With none of the four variables set the default path is a no-op, so this
|
|
236
|
+
// proves the defaults are wired without touching the real home directory.
|
|
237
|
+
const saved = Object.fromEntries(Object.keys(FULL).map((k) => [k, process.env[k]]));
|
|
238
|
+
for (const k of Object.keys(FULL)) delete process.env[k];
|
|
239
|
+
try {
|
|
240
|
+
expect(await bootstrapGogAuth()).toBe('unconfigured');
|
|
241
|
+
} finally {
|
|
242
|
+
for (const [k, v] of Object.entries(saved)) if (v !== undefined) process.env[k] = v;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { rawTextResult } from '@chrischall/mcp-utils';
|
|
3
|
+
import {
|
|
4
|
+
extractEmails,
|
|
5
|
+
logGmailDispatch,
|
|
6
|
+
resultText,
|
|
7
|
+
} from '../src/gmail-dispatch-guard.js';
|
|
8
|
+
|
|
9
|
+
describe('extractEmails', () => {
|
|
10
|
+
it('extracts a bare address', () => {
|
|
11
|
+
expect(extractEmails('a@example.com')).toEqual(['a@example.com']);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('extracts multiple addresses from a display-name header value', () => {
|
|
15
|
+
expect(extractEmails('"Alice" <alice@example.com>, "Bob" <bob@example.org>'))
|
|
16
|
+
.toEqual(['alice@example.com', 'bob@example.org']);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('lowercases and dedupes across multiple arguments', () => {
|
|
20
|
+
expect(extractEmails('Alice@Example.com', 'bob@x.com, alice@example.com'))
|
|
21
|
+
.toEqual(['alice@example.com', 'bob@x.com']);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('ignores undefined, null, and empty-string inputs', () => {
|
|
25
|
+
expect(extractEmails(undefined, null, '', 'a@b.com')).toEqual(['a@b.com']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns an empty array when nothing looks like an email', () => {
|
|
29
|
+
expect(extractEmails('no addresses here', undefined)).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('resultText', () => {
|
|
34
|
+
it('reads the text out of a CallToolResult', () => {
|
|
35
|
+
expect(resultText(rawTextResult('{"a":1}'))).toBe('{"a":1}');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('falls back to an empty object for a non-text content block', () => {
|
|
39
|
+
expect(resultText({ content: [{ type: 'image', data: 'x', mimeType: 'image/png' }] })).toBe('{}');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('falls back to an empty object when content is empty', () => {
|
|
43
|
+
expect(resultText({ content: [] })).toBe('{}');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('logGmailDispatch', () => {
|
|
48
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
49
|
+
let writeSpy: ReturnType<typeof vi.spyOn>;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
process.env = { ...ORIGINAL_ENV };
|
|
53
|
+
delete process.env.GOG_GMAIL_TRUSTED_DOMAINS;
|
|
54
|
+
delete process.env.GOG_ACCOUNT;
|
|
55
|
+
writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
process.env = ORIGINAL_ENV;
|
|
60
|
+
writeSpy.mockRestore();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function loggedEvent(): Record<string, unknown> {
|
|
64
|
+
expect(writeSpy).toHaveBeenCalledTimes(1);
|
|
65
|
+
return JSON.parse((writeSpy.mock.calls[0][0] as string).trim());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
it('logs a distinguishable gmail_dispatch event with the recipient count', () => {
|
|
69
|
+
logGmailDispatch('gog_gmail_send', ['a@example.com', 'b@example.com']);
|
|
70
|
+
const event = loggedEvent();
|
|
71
|
+
expect(event.event).toBe('gmail_dispatch');
|
|
72
|
+
expect(event.tool).toBe('gog_gmail_send');
|
|
73
|
+
expect(event.recipientCount).toBe(2);
|
|
74
|
+
expect(typeof event.timestamp).toBe('string');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('treats the account\'s own domain as trusted by default', () => {
|
|
78
|
+
logGmailDispatch('gog_gmail_reply', ['me@example.com', 'outside@other.com'], 'me@example.com');
|
|
79
|
+
const event = loggedEvent();
|
|
80
|
+
expect(event.hasExternalRecipients).toBe(true);
|
|
81
|
+
expect(event.externalRecipients).toEqual(['outside@other.com']);
|
|
82
|
+
expect(event.externalRecipientCount).toBe(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('falls back to GOG_ACCOUNT when no account param is passed', () => {
|
|
86
|
+
process.env.GOG_ACCOUNT = 'me@example.com';
|
|
87
|
+
logGmailDispatch('gog_gmail_send', ['me@example.com', 'outside@other.com']);
|
|
88
|
+
const event = loggedEvent();
|
|
89
|
+
expect(event.externalRecipients).toEqual(['outside@other.com']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('adds every domain in GOG_GMAIL_TRUSTED_DOMAINS on top of the account domain', () => {
|
|
93
|
+
process.env.GOG_GMAIL_TRUSTED_DOMAINS = 'law-firm.example, partner.example';
|
|
94
|
+
logGmailDispatch('gog_gmail_reply_all', ['counsel@law-firm.example', 'stranger@random.example'], 'me@example.com');
|
|
95
|
+
const event = loggedEvent();
|
|
96
|
+
expect(event.externalRecipients).toEqual(['stranger@random.example']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('reports no external recipients when every address is trusted', () => {
|
|
100
|
+
logGmailDispatch('gog_gmail_forward', ['me@example.com'], 'me@example.com');
|
|
101
|
+
const event = loggedEvent();
|
|
102
|
+
expect(event.hasExternalRecipients).toBe(false);
|
|
103
|
+
expect(event.externalRecipients).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('treats an account with no @ as contributing no trusted domain', () => {
|
|
107
|
+
logGmailDispatch('gog_gmail_send', ['a@example.com'], 'not-an-email');
|
|
108
|
+
const event = loggedEvent();
|
|
109
|
+
expect(event.externalRecipients).toEqual(['a@example.com']);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('ignores an empty segment in GOG_GMAIL_TRUSTED_DOMAINS (e.g. a trailing comma)', () => {
|
|
113
|
+
process.env.GOG_GMAIL_TRUSTED_DOMAINS = 'law-firm.example,,';
|
|
114
|
+
logGmailDispatch('gog_gmail_send', ['counsel@law-firm.example'], 'me@example.com');
|
|
115
|
+
const event = loggedEvent();
|
|
116
|
+
expect(event.externalRecipients).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('treats unresolved desktop placeholders as unset env vars', () => {
|
|
120
|
+
process.env.GOG_GMAIL_TRUSTED_DOMAINS = '${user_config.trusted_domains}';
|
|
121
|
+
process.env.GOG_ACCOUNT = '${user_config.account}';
|
|
122
|
+
logGmailDispatch('gog_gmail_send', ['person@user_config.trusted_domains']);
|
|
123
|
+
const event = loggedEvent();
|
|
124
|
+
expect(event.externalRecipients).toEqual(['person@user_config.trusted_domains']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('treats a recipient with no @ as external, not a crash', () => {
|
|
128
|
+
logGmailDispatch('gog_gmail_send', ['not-an-email'], 'me@example.com');
|
|
129
|
+
const event = loggedEvent();
|
|
130
|
+
expect(event.externalRecipients).toEqual(['not-an-email']);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events';
|
|
|
3
3
|
import { existsSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { dirname } from 'node:path';
|
|
5
5
|
import { run, isGogFileArg } from '../src/runner.js';
|
|
6
|
-
import type { Spawner
|
|
6
|
+
import type { Spawner } from '../src/runner.js';
|
|
7
7
|
import { payloadArg, PAYLOAD_INLINE_MAX } from '../src/tools/utils.js';
|
|
8
8
|
|
|
9
9
|
// These tests deliberately use the REAL fs: the whole point is that the bytes
|
|
@@ -210,18 +210,6 @@ describe('run with GogFileArgs', () => {
|
|
|
210
210
|
expect(capture.dirs).toHaveLength(1);
|
|
211
211
|
expect(existsSync(capture.dirs[0])).toBe(false);
|
|
212
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
213
|
});
|
|
226
214
|
|
|
227
215
|
describe('isGogFileArg', () => {
|
package/tests/runner.test.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
3
|
import { spawn as mockedSpawn } from 'node:child_process';
|
|
4
|
-
import { run, runBinary
|
|
5
|
-
import type { Spawner
|
|
4
|
+
import { run, runBinary } from '../src/runner.js';
|
|
5
|
+
import type { Spawner } from '../src/runner.js';
|
|
6
6
|
|
|
7
7
|
// The real spawn is dynamically imported inside runner's default executor.
|
|
8
8
|
// Mock it so the no-spawner/no-executor fallback can be exercised without
|
|
@@ -533,28 +533,23 @@ describe('run', () => {
|
|
|
533
533
|
});
|
|
534
534
|
|
|
535
535
|
// The suffix list was `_TOKEN|_SECRET|_API_KEY|_PRIVATE_KEY` — four spellings
|
|
536
|
-
// of "a key", none of which is a bare `_KEY`.
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
|
|
540
|
-
// which is arbitrary-gog-argv on that box). Neither is anything `gog` reads.
|
|
541
|
-
it('strips a bare *_KEY var — the host signing key and the runner bearer', async () => {
|
|
536
|
+
// of "a key", none of which is a bare `_KEY`. `MCP_BLOB_SIGNING_KEY` (mints the
|
|
537
|
+
// signed blob URLs a `deliver="url"` download is uploaded to) sat in exactly
|
|
538
|
+
// that gap, and it is nothing `gog` reads.
|
|
539
|
+
it('strips a bare *_KEY var — the host blob signing key', async () => {
|
|
542
540
|
const spawner = makeSpawner(0, '{}');
|
|
543
541
|
const snapshot = {
|
|
544
542
|
MCP_BLOB_SIGNING_KEY: process.env.MCP_BLOB_SIGNING_KEY,
|
|
545
|
-
GOG_RUNNER_KEY: process.env.GOG_RUNNER_KEY,
|
|
546
543
|
STRIPE_KEY: process.env.STRIPE_KEY,
|
|
547
544
|
AWS_CREDENTIALS: process.env.AWS_CREDENTIALS,
|
|
548
545
|
};
|
|
549
546
|
process.env.MCP_BLOB_SIGNING_KEY = 'blob-signing-secret';
|
|
550
|
-
process.env.GOG_RUNNER_KEY = 'runner-bearer-secret';
|
|
551
547
|
process.env.STRIPE_KEY = 'sk-live-secret';
|
|
552
548
|
process.env.AWS_CREDENTIALS = '/path/to/creds';
|
|
553
549
|
try {
|
|
554
550
|
await run(['docs', 'cat', 'id'], { spawner });
|
|
555
551
|
const envPassed = (spawner as ReturnType<typeof vi.fn>).mock.calls[0][2].env as NodeJS.ProcessEnv;
|
|
556
552
|
expect(envPassed.MCP_BLOB_SIGNING_KEY).toBeUndefined();
|
|
557
|
-
expect(envPassed.GOG_RUNNER_KEY).toBeUndefined();
|
|
558
553
|
expect(envPassed.STRIPE_KEY).toBeUndefined();
|
|
559
554
|
// `_CREDENTIALS`, generalising the one named GOOGLE_APPLICATION_CREDENTIALS.
|
|
560
555
|
expect(envPassed.AWS_CREDENTIALS).toBeUndefined();
|
|
@@ -803,14 +798,6 @@ describe('run', () => {
|
|
|
803
798
|
expect(callArgs).toContain('files.get');
|
|
804
799
|
});
|
|
805
800
|
|
|
806
|
-
it('runBinary refuses over the hosted-connector forward executor', async () => {
|
|
807
|
-
const executor = vi.fn();
|
|
808
|
-
await expect(
|
|
809
|
-
runExecutor.run({ executor }, () => runBinary(['api', 'call', 'drive', 'v3', 'files.get'])),
|
|
810
|
-
).rejects.toThrow('not available over the hosted connector');
|
|
811
|
-
expect(executor).not.toHaveBeenCalled();
|
|
812
|
-
});
|
|
813
|
-
|
|
814
801
|
it('ignores timeout if close event already settled the promise', async () => {
|
|
815
802
|
vi.useFakeTimers();
|
|
816
803
|
const spawner = vi.fn(() => {
|
|
@@ -906,82 +893,8 @@ describe('run --readonly (gog 0.31)', () => {
|
|
|
906
893
|
});
|
|
907
894
|
});
|
|
908
895
|
|
|
909
|
-
describe('run executor
|
|
910
|
-
it('
|
|
911
|
-
const executor = vi.fn(async () => '{"via":"executor"}') as unknown as GogExecutor;
|
|
912
|
-
const result = await runExecutor.run({ executor }, () => run(['sheets', 'get', 'id1', 'A1']));
|
|
913
|
-
expect(result).toBe('{"via":"executor"}');
|
|
914
|
-
// The executor receives the FULLY-ASSEMBLED gog arg list plus the run opts.
|
|
915
|
-
expect(executor).toHaveBeenCalledWith(
|
|
916
|
-
['--json', '--color=never', '--no-input', 'sheets', 'get', 'id1', 'A1'],
|
|
917
|
-
{ timeout: undefined, interactive: false },
|
|
918
|
-
);
|
|
919
|
-
});
|
|
920
|
-
|
|
921
|
-
it('forwards timeout and interactive through to the injected executor', async () => {
|
|
922
|
-
const executor = vi.fn(async () => '{}') as unknown as GogExecutor;
|
|
923
|
-
await runExecutor.run({ executor }, () =>
|
|
924
|
-
run(['auth', 'add', 'u@g.com'], { interactive: true, timeout: 60_000 }),
|
|
925
|
-
);
|
|
926
|
-
expect(executor).toHaveBeenCalledWith(
|
|
927
|
-
['--json', '--color=never', 'auth', 'add', 'u@g.com'],
|
|
928
|
-
{ timeout: 60_000, interactive: true },
|
|
929
|
-
);
|
|
930
|
-
});
|
|
931
|
-
|
|
932
|
-
it('redacts Google tokens returned by an injected executor', async () => {
|
|
933
|
-
const executor = vi.fn(async () => 'token ya29.a0Ad52N3-ALS-LEAK done') as unknown as GogExecutor;
|
|
934
|
-
const result = await runExecutor.run({ executor }, () => run(['auth', 'list']));
|
|
935
|
-
expect(result).not.toContain('ya29.a0Ad52N3-ALS-LEAK');
|
|
936
|
-
expect(result).toContain('[REDACTED]');
|
|
937
|
-
});
|
|
938
|
-
|
|
939
|
-
it('redacts error text thrown by an injected executor', async () => {
|
|
940
|
-
const executor = vi.fn(async () => {
|
|
941
|
-
throw new Error('boom 1//0eALS-REFRESH-LEAK end');
|
|
942
|
-
}) as unknown as GogExecutor;
|
|
943
|
-
try {
|
|
944
|
-
await runExecutor.run({ executor }, () => run(['gmail', 'get', 'm1']));
|
|
945
|
-
throw new Error('expected rejection');
|
|
946
|
-
} catch (e) {
|
|
947
|
-
const msg = (e as Error).message;
|
|
948
|
-
expect(msg).not.toContain('1//0eALS-REFRESH-LEAK');
|
|
949
|
-
expect(msg).toContain('[REDACTED]');
|
|
950
|
-
}
|
|
951
|
-
});
|
|
952
|
-
|
|
953
|
-
// run() re-wraps every thrown error to redact secrets from its message. That
|
|
954
|
-
// rewrap must not cost a RunnerTransportError its TYPE: the type is the only
|
|
955
|
-
// thing that tells diagnose() the failure was the connector's transport and
|
|
956
|
-
// not the caller's Google credential, and a bare Error puts it straight back
|
|
957
|
-
// to guessing from prose.
|
|
958
|
-
it('preserves a RunnerTransportError through the redacting rewrap', async () => {
|
|
959
|
-
const executor = vi.fn(async () => {
|
|
960
|
-
throw new RunnerTransportError('runner key mismatch; saw 1//0eLEAKED-REFRESH end', 'transport-auth', 401);
|
|
961
|
-
}) as unknown as GogExecutor;
|
|
962
|
-
const err = await runExecutor
|
|
963
|
-
.run({ executor }, () => run(['gmail', 'get', 'm1']))
|
|
964
|
-
.catch((e: unknown) => e);
|
|
965
|
-
expect(isRunnerTransportError(err)).toBe(true);
|
|
966
|
-
expect((err as RunnerTransportError).kind).toBe('transport-auth');
|
|
967
|
-
expect((err as RunnerTransportError).status).toBe(401);
|
|
968
|
-
// and it is still redacted
|
|
969
|
-
expect((err as Error).message).not.toContain('1//0eLEAKED-REFRESH');
|
|
970
|
-
expect((err as Error).message).toContain('[REDACTED]');
|
|
971
|
-
});
|
|
972
|
-
|
|
973
|
-
it('options.spawner takes precedence over an injected ALS executor', async () => {
|
|
974
|
-
const spawner = makeSpawner(0, '{"via":"spawner"}');
|
|
975
|
-
const executor = vi.fn(async () => '{"via":"executor"}') as unknown as GogExecutor;
|
|
976
|
-
const result = await runExecutor.run({ executor }, () =>
|
|
977
|
-
run(['sheets', 'get', 'id1', 'A1'], { spawner }),
|
|
978
|
-
);
|
|
979
|
-
expect(result).toBe('{"via":"spawner"}');
|
|
980
|
-
expect(executor).not.toHaveBeenCalled();
|
|
981
|
-
expect(spawner).toHaveBeenCalledOnce();
|
|
982
|
-
});
|
|
983
|
-
|
|
984
|
-
it('falls back to the lazily-imported real spawn when neither a spawner nor an ALS executor is set', async () => {
|
|
896
|
+
describe('run executor', () => {
|
|
897
|
+
it('uses the lazily-imported real spawn when no spawner is injected', async () => {
|
|
985
898
|
vi.mocked(mockedSpawn).mockImplementation((() => makeProc(0, '{"real":true}')) as never);
|
|
986
899
|
const result = await run(['sheets', 'get', 'id1', 'A1']);
|
|
987
900
|
expect(result).toBe('{"real":true}');
|
|
@@ -3,46 +3,20 @@ import { createRequire } from 'node:module';
|
|
|
3
3
|
import { realpathSync } from 'node:fs';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
// NOMINALLY, not structurally. Two installed copies therefore become two
|
|
11
|
-
// mutually-unassignable classes, and every `ToolRegistrar` in server.ts fails
|
|
12
|
-
// with TS2322 — with no API change and no source change anywhere. #220 split
|
|
13
|
-
// the tree exactly that way: `agents` (a root devDependency, the Worker
|
|
14
|
-
// connector's McpAgent) exact-pins the SDK to 1.29.0 and so takes the hoisted
|
|
15
|
-
// root slot that `@chrischall/mcp-utils` resolves its peer from, while the
|
|
16
|
-
// workspaces asking for ^1.30.0 each nested their own copy.
|
|
17
|
-
//
|
|
18
|
-
// This asserts resolution identity rather than a version string: the failure is
|
|
19
|
-
// "two copies", not "the wrong version", and pinning a version here would just
|
|
20
|
-
// have to be edited on every future bump.
|
|
21
|
-
describe('@modelcontextprotocol/sdk is installed exactly once', () => {
|
|
22
|
-
const here = createRequire(import.meta.url);
|
|
6
|
+
// Keep the modular SDK v2 server on one physical copy: its McpServer class
|
|
7
|
+
// carries private state, so duplicate copies produce nominal TypeScript failures
|
|
8
|
+
// that look like API incompatibilities.
|
|
9
|
+
const here = createRequire(import.meta.url);
|
|
23
10
|
|
|
24
|
-
|
|
25
|
-
|
|
11
|
+
const resolveFrom = (specifier: string, target: string): string =>
|
|
12
|
+
realpathSync(createRequire(fileURLToPath(import.meta.resolve(specifier))).resolve(target));
|
|
26
13
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// `require` condition and CJS resolution of the bare specifier throws.
|
|
30
|
-
const resolveFrom = (specifier: string): string =>
|
|
31
|
-
realpathSync(
|
|
32
|
-
createRequire(fileURLToPath(import.meta.resolve(specifier))).resolve(SDK_SUBPATH),
|
|
33
|
-
);
|
|
14
|
+
describe('the MCP server SDK is installed exactly once', () => {
|
|
15
|
+
const MODERN_SERVER = '@modelcontextprotocol/server';
|
|
34
16
|
|
|
35
|
-
it('
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
expect(resolveFrom('@chrischall/mcp-utils')).toBe(
|
|
39
|
-
realpathSync(here.resolve(SDK_SUBPATH)),
|
|
17
|
+
it('keeps the SDK v2 server shared by this package and mcp-utils', () => {
|
|
18
|
+
expect(resolveFrom('@chrischall/mcp-utils', MODERN_SERVER)).toBe(
|
|
19
|
+
realpathSync(here.resolve(MODERN_SERVER)),
|
|
40
20
|
);
|
|
41
21
|
});
|
|
42
|
-
|
|
43
|
-
it('resolves to the same file for `agents`, which exact-pins the SDK', () => {
|
|
44
|
-
// The Worker connector builds its McpServer via McpAgent from `agents`.
|
|
45
|
-
// An exact pin there is what captured the root hoist slot in #220.
|
|
46
|
-
expect(resolveFrom('agents')).toBe(realpathSync(here.resolve(SDK_SUBPATH)));
|
|
47
|
-
});
|
|
48
22
|
});
|
|
@@ -56,7 +56,7 @@ describe('gog_appscript_pull', () => {
|
|
|
56
56
|
// hosted connector. A caller who does not know that gets a "success" whose
|
|
57
57
|
// files they cannot reach.
|
|
58
58
|
it('says in its description where the directory resolves', async () => {
|
|
59
|
-
const { McpServer } = await import('@modelcontextprotocol/
|
|
59
|
+
const { McpServer } = await import('@modelcontextprotocol/server');
|
|
60
60
|
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
|
61
61
|
const configs = new Map<string, { description?: string }>();
|
|
62
62
|
vi.spyOn(server, 'registerTool').mockImplementation((name, config) => {
|
|
@@ -9,9 +9,8 @@ import { diagnose } from '../../src/tools/utils.js';
|
|
|
9
9
|
*
|
|
10
10
|
* Google API error (401 authError): Invalid Credentials
|
|
11
11
|
*
|
|
12
|
-
* That shape
|
|
13
|
-
*
|
|
14
|
-
* is strictly worse than the `row 401` false positive that motivated the change:
|
|
12
|
+
* That is the shape gog prints for a dead Google credential. Losing it is
|
|
13
|
+
* strictly worse than the `row 401` false positive that motivated the change:
|
|
15
14
|
* a false positive sends someone on a pointless re-auth, but this leaves a REAL
|
|
16
15
|
* dead credential with no hint at all.
|
|
17
16
|
*/
|
package/tests/tools/auth.test.ts
CHANGED
|
@@ -171,16 +171,17 @@ describe('gog_auth_add', () => {
|
|
|
171
171
|
|
|
172
172
|
describe('gog_auth_health', () => {
|
|
173
173
|
it('names itself as the only live measurement of the Google layer', async () => {
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
174
|
+
// A hosted connector's "connected" / "refreshed" is the client's OAuth to
|
|
175
|
+
// the MCP host, which never contacts Google. Nothing in this repo can change
|
|
176
|
+
// that word, so the tool that DOES measure has to say that it is the one
|
|
177
|
+
// that does.
|
|
178
178
|
const harness = await setupHandlers();
|
|
179
179
|
const { tools } = await harness.client.listTools();
|
|
180
180
|
const desc = tools.find((t) => t.name === 'gog_auth_health')!.description!;
|
|
181
181
|
|
|
182
182
|
expect(desc).toMatch(/connected|refreshed/i);
|
|
183
183
|
expect(desc).toMatch(/only|nothing else/i);
|
|
184
|
+
expect(desc).not.toMatch(/connector key|gog machine/i);
|
|
184
185
|
await harness.close();
|
|
185
186
|
});
|
|
186
187
|
|