gogcli-mcp 2.29.1 → 3.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.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +17105 -18892
  4. package/dist/lib.js +15157 -9562
  5. package/manifest.json +1 -1
  6. package/mint.yaml +2 -2
  7. package/package.json +5 -5
  8. package/server.json +2 -2
  9. package/src/blob-upload.ts +179 -0
  10. package/src/blob-urls.ts +280 -0
  11. package/src/connector-auth.ts +22 -6
  12. package/src/connector-login.ts +87 -0
  13. package/src/connector-runtime.ts +33 -10
  14. package/src/gmail-dispatch-guard.ts +106 -0
  15. package/src/gmail-results.ts +1 -1
  16. package/src/lib.ts +29 -0
  17. package/src/pagination.ts +1 -1
  18. package/src/runner.ts +19 -2
  19. package/src/tools/api.ts +7 -7
  20. package/src/tools/appscript.ts +15 -15
  21. package/src/tools/auth.ts +11 -11
  22. package/src/tools/calendar.ts +13 -13
  23. package/src/tools/chat.ts +25 -25
  24. package/src/tools/classroom.ts +49 -49
  25. package/src/tools/contacts.ts +9 -9
  26. package/src/tools/docs.ts +13 -13
  27. package/src/tools/drive.ts +22 -22
  28. package/src/tools/gmail.ts +136 -23
  29. package/src/tools/sheets.ts +15 -15
  30. package/src/tools/slides.ts +13 -13
  31. package/src/tools/tasks.ts +13 -13
  32. package/src/tools/utils.ts +2 -3
  33. package/src/worker.ts +52 -64
  34. package/tests/blob-upload.test.ts +204 -0
  35. package/tests/blob-urls.test.ts +319 -0
  36. package/tests/connector-login.test.ts +151 -0
  37. package/tests/connector-runtime.test.ts +21 -1
  38. package/tests/gmail-dispatch-guard.test.ts +132 -0
  39. package/tests/runner.test.ts +62 -1
  40. package/tests/sdk-single-copy.test.ts +18 -36
  41. package/tests/tools/appscript.test.ts +1 -1
  42. package/tests/tools/chat.test.ts +1 -1
  43. package/tests/tools/gmail.test.ts +242 -12
  44. package/tests/tools/sheets.test.ts +1 -1
  45. package/tests/zod-single-copy.test.ts +2 -2
  46. package/tsconfig.json +1 -3
  47. package/vitest.config.ts +3 -5
@@ -0,0 +1,319 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
+ import { createHmac } from 'node:crypto';
3
+ import {
4
+ blobStoreFromEnv,
5
+ createBlobUrlMinter,
6
+ readPayload,
7
+ writePayload,
8
+ BLOB_URL_MAX_TTL_MS,
9
+ BLOB_URL_CEILING_MARGIN_MS,
10
+ BLOB_URL_DEFAULT_TTL_MS,
11
+ } from '../src/blob-urls.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Fixtures. These are VECTORS: the signatures below were computed against the
15
+ // payload shapes as mcp-host's `blob-key.ts` spells them, independently of this
16
+ // module's code. If a future edit changes how a payload is assembled — a
17
+ // separator, the order of the fields, the encoding of the MAC — these stop
18
+ // matching. That is the point.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const BASE_URL = 'https://mcp.example.com/b/reg_0123456789abcdef01234567';
22
+ const SIGNING_KEY = 'test-blob-signing-key';
23
+ const REGISTRATION_ID = 'reg_0123456789abcdef01234567';
24
+ const REST = 'gmail/attachments/report.pdf';
25
+ const OBJECT_KEY = `${REGISTRATION_ID}/${REST}`;
26
+ const EXP = 1767225600000;
27
+ const NOW = EXP - BLOB_URL_DEFAULT_TTL_MS;
28
+
29
+ /** base64url(HMAC-SHA256(SIGNING_KEY, `<key>\n<exp>`)). */
30
+ const READ_SIG = 'KrMn62kcDi-i9wW3JLvbZJdas4gP-EQuYoO-XMhcpNQ';
31
+ /** base64url(HMAC-SHA256(SIGNING_KEY, `put\0<key>\0application/pdf\0<exp>`)). */
32
+ const WRITE_PDF_SIG = 'qHPIRvchnjsbE8lam5dft3pIDBZu6Ae0HWZIG7kC9DQ';
33
+ /** The same key and exp, signed for `text/plain` instead. */
34
+ const WRITE_TEXT_SIG = 'FHtUlUVeEuREdbdKENy8rDU2ZETBQKF6V9IfaIWwWWo';
35
+
36
+ function sign(payload: string): string {
37
+ return createHmac('sha256', SIGNING_KEY)
38
+ .update(payload, 'utf8')
39
+ .digest('base64url');
40
+ }
41
+
42
+ const minter = () => createBlobUrlMinter({ baseUrl: BASE_URL, signingKey: SIGNING_KEY });
43
+
44
+ function params(url: string): URLSearchParams {
45
+ return new URL(url).searchParams;
46
+ }
47
+
48
+ afterEach(() => {
49
+ vi.unstubAllEnvs();
50
+ });
51
+
52
+ // ---------------------------------------------------------------------------
53
+
54
+ describe('payload shapes', () => {
55
+ it('signs a read as `<key>\\n<exp>` — a newline, never a NUL', () => {
56
+ const payload = readPayload(OBJECT_KEY, EXP);
57
+ expect(payload).toBe('reg_0123456789abcdef01234567/gmail/attachments/report.pdf\n1767225600000');
58
+ expect(payload).toContain('\n');
59
+ expect(payload).not.toContain('\0');
60
+ });
61
+
62
+ it('signs a write as `put\\0<key>\\0<ct>\\0<exp>` — NUL-separated, content type inside', () => {
63
+ const payload = writePayload(OBJECT_KEY, 'application/pdf', EXP);
64
+ // The NUL is written as a `\u0000` escape, never `\0`: `\0` followed by a
65
+ // DIGIT is a legacy octal escape, so `'\01767…'` is U+0001 + "767…" — the
66
+ // vector then silently pins the wrong bytes (it did, on this test's first run).
67
+ expect(payload).toBe(
68
+ 'put\u0000reg_0123456789abcdef01234567/gmail/attachments/report.pdf\u0000application/pdf\u00001767225600000',
69
+ );
70
+ expect(payload.split('\0')).toEqual([
71
+ 'put',
72
+ OBJECT_KEY,
73
+ 'application/pdf',
74
+ String(EXP),
75
+ ]);
76
+ expect(payload).not.toContain('\n');
77
+ });
78
+
79
+ it('never lets the two shapes converge', () => {
80
+ expect(readPayload(OBJECT_KEY, EXP)).not.toBe(writePayload(OBJECT_KEY, 'application/pdf', EXP));
81
+ // A read signature must be unreplayable as a write, whatever the content type.
82
+ expect(sign(readPayload(OBJECT_KEY, EXP))).not.toBe(
83
+ sign(writePayload(OBJECT_KEY, '', EXP)),
84
+ );
85
+ });
86
+ });
87
+
88
+ describe('createBlobUrlMinter', () => {
89
+ it('takes the registration id from the base URL\'s last path segment', () => {
90
+ expect(minter().registrationId).toBe(REGISTRATION_ID);
91
+ });
92
+
93
+ it('mints a GET URL matching the pinned read vector', () => {
94
+ const url = minter().getUrl(REST, { now: NOW });
95
+ expect(url).toBe(
96
+ `${BASE_URL}/gmail/attachments/report.pdf?exp=${EXP}&sig=${READ_SIG}`,
97
+ );
98
+ expect(params(url).get('sig')).toBe(sign(readPayload(OBJECT_KEY, EXP)));
99
+ });
100
+
101
+ it('mints a PUT URL matching the pinned write vector', () => {
102
+ const target = minter().putUrl(REST, 'application/pdf', { now: NOW });
103
+ expect(target.url).toBe(
104
+ `${BASE_URL}/gmail/attachments/report.pdf?exp=${EXP}&sig=${WRITE_PDF_SIG}`,
105
+ );
106
+ expect(params(target.url).get('sig')).toBe(
107
+ sign(writePayload(OBJECT_KEY, 'application/pdf', EXP)),
108
+ );
109
+ });
110
+
111
+ it('carries the content type it signed BACK with the URL', () => {
112
+ // The one field the gateway checks that no test in this repo can: it
113
+ // rebuilds the payload from the PUT's own `content-type` header
114
+ // (`gateway/src/blob.ts`, defaulting to application/octet-stream when the
115
+ // header is absent), so a caller that drops the header, lets an HTTP
116
+ // library default it, or re-cases it gets a 404 signature mismatch. Handing
117
+ // back a URL alone makes that a thing the uploader has to REMEMBER; handing
118
+ // back the pair makes it a thing it cannot drop.
119
+ const target = minter().putUrl(REST, 'application/pdf', { now: NOW });
120
+ expect(target.contentType).toBe('application/pdf');
121
+ expect(Object.keys(target).sort()).toEqual(['contentType', 'url']);
122
+ });
123
+
124
+ it('changes the signature when the content type changes', () => {
125
+ const pdf = params(minter().putUrl(REST, 'application/pdf', { now: NOW }).url).get('sig');
126
+ const text = params(minter().putUrl(REST, 'text/plain', { now: NOW }).url).get('sig');
127
+ expect(pdf).toBe(WRITE_PDF_SIG);
128
+ expect(text).toBe(WRITE_TEXT_SIG);
129
+ expect(pdf).not.toBe(text);
130
+ });
131
+
132
+ it('builds the object key as `<registrationId>/<rest>`, slashes and all', () => {
133
+ const url = minter().getUrl('a/b/c.pdf', { now: NOW });
134
+ // Raw, not `new URL(url).pathname` — same reason as the encoding cases below.
135
+ expect(url.split('?')[0]).toBe(`${BASE_URL}/a/b/c.pdf`);
136
+ expect(params(url).get('sig')).toBe(
137
+ sign(readPayload(`${REGISTRATION_ID}/a/b/c.pdf`, EXP)),
138
+ );
139
+ });
140
+
141
+ it('percent-encodes each URL segment while signing the logical key', () => {
142
+ const url = minter().getUrl('in box/a b.pdf', { now: NOW });
143
+ // Asserted on the RAW string the module returned, never through
144
+ // `new URL(url).pathname`: the parser percent-encodes a space itself on the
145
+ // way in, so a parsed pathname reads identically whether this module
146
+ // encoded anything or not — deleting `encodeURIComponent` from the wire
147
+ // left that assertion green (the one mutation of 25 that survived review).
148
+ expect(url).toBe(
149
+ `${BASE_URL}/in%20box/a%20b.pdf?exp=${EXP}&sig=${sign(readPayload(`${REGISTRATION_ID}/in box/a b.pdf`, EXP))}`,
150
+ );
151
+ // The gateway decodes per segment before it verifies, so the SIGNED key is
152
+ // the logical one — an encoded key here would 404 on every space.
153
+ expect(params(url).get('sig')).toBe(
154
+ sign(readPayload(`${REGISTRATION_ID}/in box/a b.pdf`, EXP)),
155
+ );
156
+ });
157
+
158
+ it('percent-encodes a `#` on the wire, so the query is still a query', () => {
159
+ // `Invoice #1234.pdf` is an ordinary Gmail attachment name, and it is the
160
+ // case a URL parser will NOT repair for us: unencoded, everything from the
161
+ // `#` is a FRAGMENT, so `?exp=&sig=` never reaches the gateway and the
162
+ // answer is 403 "missing signature" with nothing to debug. `?` truncates
163
+ // the path the same way. Raw string again, for the reason above.
164
+ const url = minter().getUrl('Invoice #1234.pdf', { now: NOW });
165
+ expect(url).toBe(
166
+ `${BASE_URL}/Invoice%20%231234.pdf?exp=${EXP}&sig=${sign(readPayload(`${REGISTRATION_ID}/Invoice #1234.pdf`, EXP))}`,
167
+ );
168
+ expect(url).not.toContain('#');
169
+ expect(params(url).get('sig')).toBe(
170
+ sign(readPayload(`${REGISTRATION_ID}/Invoice #1234.pdf`, EXP)),
171
+ );
172
+ });
173
+
174
+ it('tolerates a trailing slash on the base URL', () => {
175
+ const m = createBlobUrlMinter({ baseUrl: `${BASE_URL}/`, signingKey: SIGNING_KEY });
176
+ expect(m.registrationId).toBe(REGISTRATION_ID);
177
+ expect(m.getUrl(REST, { now: NOW })).toBe(
178
+ `${BASE_URL}/gmail/attachments/report.pdf?exp=${EXP}&sig=${READ_SIG}`,
179
+ );
180
+ });
181
+
182
+ it('defaults the expiry to one hour', () => {
183
+ const exp = Number(params(minter().getUrl(REST, { now: NOW })).get('exp'));
184
+ expect(exp - NOW).toBe(BLOB_URL_DEFAULT_TTL_MS);
185
+ expect(BLOB_URL_DEFAULT_TTL_MS).toBe(60 * 60 * 1000);
186
+ });
187
+
188
+ it('honours a shorter ttl', () => {
189
+ const exp = Number(params(minter().getUrl(REST, { now: NOW, ttlMs: 5000 })).get('exp'));
190
+ expect(exp).toBe(NOW + 5000);
191
+ });
192
+
193
+ it('clamps the expiry UNDER the gateway\'s 24-hour ceiling rather than trusting the caller', () => {
194
+ const exp = Number(
195
+ params(minter().getUrl(REST, { now: NOW, ttlMs: 7 * 24 * 60 * 60 * 1000 })).get('exp'),
196
+ );
197
+ expect(exp).toBe(NOW + BLOB_URL_MAX_TTL_MS - BLOB_URL_CEILING_MARGIN_MS);
198
+ expect(BLOB_URL_MAX_TTL_MS).toBe(24 * 60 * 60 * 1000);
199
+ });
200
+
201
+ it('never mints an exp AT the ceiling, so a clock a hair ahead is not a 403', () => {
202
+ // `blob.ts` refuses with a strict `exp > Date.now() + MAX_TTL_MS`, judged on
203
+ // the GATEWAY's clock against an exp computed on the CHILD's. Landing
204
+ // exactly on the ceiling leaves zero skew budget: one millisecond of the
205
+ // child running ahead is a hard 403 "expiry out of range", whose message
206
+ // names no cause the caller can act on. And BLOB_URL_MAX_TTL_MS is exported,
207
+ // so `ttlMs: BLOB_URL_MAX_TTL_MS` is the natural way to ask for the longest
208
+ // legal link — the API invites exactly the request that would break.
209
+ for (const ttlMs of [BLOB_URL_MAX_TTL_MS, BLOB_URL_MAX_TTL_MS + 1, Number.MAX_SAFE_INTEGER]) {
210
+ const exp = Number(params(minter().getUrl(REST, { now: NOW, ttlMs })).get('exp'));
211
+ expect(exp).toBeLessThan(NOW + BLOB_URL_MAX_TTL_MS);
212
+ expect(exp).toBe(NOW + BLOB_URL_MAX_TTL_MS - BLOB_URL_CEILING_MARGIN_MS);
213
+ }
214
+ expect(BLOB_URL_CEILING_MARGIN_MS).toBeGreaterThan(0);
215
+ });
216
+
217
+ it('clamps a zero or negative ttl up to one second, never to an already-dead link', () => {
218
+ expect(Number(params(minter().getUrl(REST, { now: NOW, ttlMs: 0 })).get('exp'))).toBe(NOW + 1000);
219
+ expect(Number(params(minter().getUrl(REST, { now: NOW, ttlMs: -1 })).get('exp'))).toBe(NOW + 1000);
220
+ });
221
+
222
+ it('reads the clock when no `now` is given', () => {
223
+ vi.useFakeTimers();
224
+ try {
225
+ vi.setSystemTime(NOW);
226
+ expect(minter().getUrl(REST)).toBe(
227
+ `${BASE_URL}/gmail/attachments/report.pdf?exp=${EXP}&sig=${READ_SIG}`,
228
+ );
229
+ } finally {
230
+ vi.useRealTimers();
231
+ }
232
+ });
233
+
234
+ it.each([
235
+ 'https://mcp.example.com/b/',
236
+ 'https://mcp.example.com/b',
237
+ 'https://mcp.example.com/',
238
+ 'https://mcp.example.com',
239
+ ])('refuses the base URL %j — no registration id in the path', (baseUrl) => {
240
+ expect(() => createBlobUrlMinter({ baseUrl, signingKey: 'k' }))
241
+ .toThrow(/registration id/i);
242
+ });
243
+
244
+ it('refuses a base URL that is not a URL', () => {
245
+ expect(() => createBlobUrlMinter({ baseUrl: 'not a url', signingKey: 'k' }))
246
+ .toThrow(/MCP_BLOB_BASE_URL/);
247
+ });
248
+
249
+ it('refuses an empty signing key', () => {
250
+ expect(() => createBlobUrlMinter({ baseUrl: BASE_URL, signingKey: '' }))
251
+ .toThrow(/MCP_BLOB_SIGNING_KEY/);
252
+ });
253
+
254
+ it.each([
255
+ ['', 'empty'],
256
+ ['/', 'empty segment'],
257
+ ['a//b', 'empty segment'],
258
+ ['../etc/passwd', 'traversal'],
259
+ ['a/./b', 'traversal'],
260
+ ['a/../b', 'traversal'],
261
+ ])('refuses the object path %j (%s)', (rest) => {
262
+ // The gateway refuses these outright rather than normalising them, so a URL
263
+ // minted for one would 404 at the door. Fail here, where the message can say why.
264
+ expect(() => minter().getUrl(rest, { now: NOW })).toThrow(/object path/i);
265
+ });
266
+
267
+ // EVERY refusal this module makes, not only the one easiest to reach. The key
268
+ // is a credential and so is a signed URL, while an error message is the one
269
+ // value here that is EXPECTED to be logged — so the guard has to bind the
270
+ // constructor's three refusals as well as the mint-time one. With only the
271
+ // `../escape` case it did not: rewriting the unparseable-base-URL throw to
272
+ // interpolate `config.signingKey` left the whole suite green.
273
+ it.each([
274
+ ['empty signing key', () => createBlobUrlMinter({ baseUrl: BASE_URL, signingKey: '' })],
275
+ ['unparseable base URL', () => createBlobUrlMinter({ baseUrl: 'not a url', signingKey: SIGNING_KEY })],
276
+ ['base URL with no registration id', () => createBlobUrlMinter({ baseUrl: 'https://mcp.example.com/b/', signingKey: SIGNING_KEY })],
277
+ ['refused object path', () => minter().getUrl('../escape', { now: NOW })],
278
+ ])('never puts the signing key or a signed URL in the error for a %s', (_label, refuse) => {
279
+ try {
280
+ refuse();
281
+ expect.unreachable('should have thrown');
282
+ } catch (err) {
283
+ expect(String(err)).not.toContain(SIGNING_KEY);
284
+ expect(String(err)).not.toContain('sig=');
285
+ }
286
+ });
287
+ });
288
+
289
+ describe('blobStoreFromEnv', () => {
290
+ it('returns the pair when both variables are set', () => {
291
+ vi.stubEnv('MCP_BLOB_BASE_URL', BASE_URL);
292
+ vi.stubEnv('MCP_BLOB_SIGNING_KEY', SIGNING_KEY);
293
+ expect(blobStoreFromEnv()).toEqual({ baseUrl: BASE_URL, signingKey: SIGNING_KEY });
294
+ });
295
+
296
+ it('returns undefined on a local stdio install, where neither is set', () => {
297
+ vi.stubEnv('MCP_BLOB_BASE_URL', '');
298
+ vi.stubEnv('MCP_BLOB_SIGNING_KEY', '');
299
+ expect(blobStoreFromEnv()).toBeUndefined();
300
+ });
301
+
302
+ it('returns undefined when only the base URL is set', () => {
303
+ vi.stubEnv('MCP_BLOB_BASE_URL', BASE_URL);
304
+ vi.stubEnv('MCP_BLOB_SIGNING_KEY', '');
305
+ expect(blobStoreFromEnv()).toBeUndefined();
306
+ });
307
+
308
+ it('returns undefined when only the signing key is set', () => {
309
+ vi.stubEnv('MCP_BLOB_BASE_URL', '');
310
+ vi.stubEnv('MCP_BLOB_SIGNING_KEY', SIGNING_KEY);
311
+ expect(blobStoreFromEnv()).toBeUndefined();
312
+ });
313
+
314
+ it('treats an unresolved .mcpb placeholder as unset', () => {
315
+ vi.stubEnv('MCP_BLOB_BASE_URL', '${user_config.blob_base_url}');
316
+ vi.stubEnv('MCP_BLOB_SIGNING_KEY', SIGNING_KEY);
317
+ expect(blobStoreFromEnv()).toBeUndefined();
318
+ });
319
+ });
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { handleAuthorize } from '../src/connector-login.js';
3
+ import type { ConnectorAuth } from '../src/connector-auth.js';
4
+
5
+ function encoded(value: unknown): string {
6
+ return btoa(JSON.stringify(value));
7
+ }
8
+
9
+ function envFor(oauthReq: unknown = { clientId: 'client-1' }) {
10
+ return {
11
+ OAUTH_PROVIDER: {
12
+ parseAuthRequest: vi.fn(async () => oauthReq),
13
+ completeAuthorization: vi.fn(async () => ({ redirectTo: 'https://client.example/callback?code=ok' })),
14
+ },
15
+ };
16
+ }
17
+
18
+ const auth: ConnectorAuth<{ key: string }> = {
19
+ service: 'gogcli <Workspace>',
20
+ accent: '#4285F4',
21
+ privacyNote: 'Stored & encrypted.',
22
+ fields: [
23
+ { name: 'key', label: 'Connector "key"', type: 'password' },
24
+ { name: 'account', label: 'Account' },
25
+ ],
26
+ async login(fields) {
27
+ return { key: fields.key };
28
+ },
29
+ };
30
+
31
+ describe('connector login', () => {
32
+ it('renders the parsed OAuth request in an escaped credential form', async () => {
33
+ const env = envFor();
34
+ const response = await handleAuthorize(new Request('https://connector.example/authorize'), env, auth);
35
+
36
+ expect(response.status).toBe(200);
37
+ expect(response.headers.get('content-type')).toContain('text/html');
38
+ expect(env.OAUTH_PROVIDER.parseAuthRequest).toHaveBeenCalledOnce();
39
+ const html = await response.text();
40
+ expect(html).toContain('gogcli &lt;Workspace&gt;');
41
+ expect(html).toContain('Connector &quot;key&quot;');
42
+ expect(html).toContain('type="password"');
43
+ expect(html).toContain('Stored &amp; encrypted.');
44
+ expect(html).toContain(encoded({ clientId: 'client-1' }));
45
+ });
46
+
47
+ it('verifies submitted fields and completes authorization with an explicit user id', async () => {
48
+ const env = envFor();
49
+ const login = vi.fn(async () => ({ key: 'secret' }));
50
+ const form = new FormData();
51
+ form.set('oauthReq', encoded({ clientId: 'client-1' }));
52
+ form.set('key', 'secret');
53
+ form.set('account', 'person@example.com');
54
+
55
+ const response = await handleAuthorize(
56
+ new Request('https://connector.example/authorize', { method: 'POST', body: form }),
57
+ env,
58
+ { ...auth, userId: 'fixed-user', login },
59
+ );
60
+
61
+ expect(login).toHaveBeenCalledWith(
62
+ { key: 'secret', account: 'person@example.com' },
63
+ env,
64
+ );
65
+ expect(env.OAUTH_PROVIDER.completeAuthorization).toHaveBeenCalledWith({
66
+ request: { clientId: 'client-1' },
67
+ userId: 'fixed-user',
68
+ scope: [],
69
+ metadata: {},
70
+ props: { key: 'secret' },
71
+ });
72
+ expect(response.status).toBe(302);
73
+ expect(response.headers.get('location')).toBe('https://client.example/callback?code=ok');
74
+ });
75
+
76
+ it('uses the first credential as the user id and treats non-text fields as empty', async () => {
77
+ const env = envFor();
78
+ const login = vi.fn(async () => ({ key: 'secret' }));
79
+ const form = new FormData();
80
+ form.set('oauthReq', encoded({ clientId: 'client-1' }));
81
+ form.set('key', 'secret');
82
+ form.set('account', new Blob(['ignored']));
83
+
84
+ await handleAuthorize(
85
+ new Request('https://connector.example/authorize', { method: 'POST', body: form }),
86
+ env,
87
+ { ...auth, login },
88
+ );
89
+
90
+ expect(login).toHaveBeenCalledWith({ key: 'secret', account: '' }, env);
91
+ expect(env.OAUTH_PROVIDER.completeAuthorization).toHaveBeenCalledWith(
92
+ expect.objectContaining({ userId: 'secret' }),
93
+ );
94
+ });
95
+
96
+ it('uses the public identity when there are no credential fields', async () => {
97
+ const env = envFor();
98
+ const form = new FormData();
99
+ const login = vi.fn(async () => ({ key: 'public' }));
100
+
101
+ await handleAuthorize(
102
+ new Request('https://connector.example/authorize', { method: 'POST', body: form }),
103
+ env,
104
+ { service: 'Public', fields: [], login },
105
+ );
106
+
107
+ expect(env.OAUTH_PROVIDER.completeAuthorization).toHaveBeenCalledWith(
108
+ expect.objectContaining({ request: undefined, userId: 'public' }),
109
+ );
110
+ });
111
+
112
+ it('re-renders a safely escaped Error without completing authorization', async () => {
113
+ const env = envFor();
114
+ const form = new FormData();
115
+ form.set('oauthReq', encoded({ clientId: 'client-1' }));
116
+ form.set('key', 'bad');
117
+ const response = await handleAuthorize(
118
+ new Request('https://connector.example/authorize', { method: 'POST', body: form }),
119
+ env,
120
+ {
121
+ service: 'gogcli',
122
+ fields: [{ name: 'key', label: 'Key' }],
123
+ async login() {
124
+ throw new Error('bad <key>');
125
+ },
126
+ },
127
+ );
128
+
129
+ expect(response.status).toBe(200);
130
+ expect(await response.text()).toContain('bad &lt;key&gt;');
131
+ expect(env.OAUTH_PROVIDER.completeAuthorization).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it('renders a non-Error rejection as text', async () => {
135
+ const env = envFor();
136
+ const form = new FormData();
137
+ const response = await handleAuthorize(
138
+ new Request('https://connector.example/authorize', { method: 'POST', body: form }),
139
+ env,
140
+ {
141
+ service: 'gogcli',
142
+ fields: [],
143
+ async login() {
144
+ throw 'try again';
145
+ },
146
+ },
147
+ );
148
+
149
+ expect(await response.text()).toContain('try again');
150
+ });
151
+ });
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, vi, afterEach } from 'vitest';
2
2
  import { run, runExecutor } from '../src/runner.js';
3
3
  import type { GogArg, GogExecutor, GogFileArg } from '../src/runner.js';
4
- import { makeFlyExecutor, wrapServer, RunnerTransportError, isRunnerTransportError } from '../src/connector-runtime.js';
4
+ import { createFlyExecutorResolver, makeFlyExecutor, wrapServer, RunnerTransportError, isRunnerTransportError } from '../src/connector-runtime.js';
5
5
  import { runOrDiagnose } from '../src/tools/utils.js';
6
6
  import { makeAccessTokenSource, clearAccessTokenCache } from '../src/google-token.js';
7
7
 
@@ -11,6 +11,26 @@ afterEach(() => {
11
11
  vi.restoreAllMocks();
12
12
  });
13
13
 
14
+ describe('createFlyExecutorResolver', () => {
15
+ it('reuses one executor for the same endpoint and key while isolating different credentials', () => {
16
+ const factory = vi.fn((_endpoint: string, _key: string): GogExecutor => vi.fn());
17
+ const resolve = createFlyExecutorResolver(factory);
18
+
19
+ const first = resolve('https://runner.example', 'key-a');
20
+ expect(resolve('https://runner.example', 'key-a')).toBe(first);
21
+ expect(resolve('https://runner.example', 'key-b')).not.toBe(first);
22
+ expect(resolve('https://other.example', 'key-a')).not.toBe(first);
23
+ expect(factory).toHaveBeenCalledTimes(3);
24
+ });
25
+
26
+ it('uses makeFlyExecutor by default', () => {
27
+ const resolve = createFlyExecutorResolver();
28
+ expect(resolve('https://runner.example', 'key-a')).toBe(
29
+ resolve('https://runner.example', 'key-a'),
30
+ );
31
+ });
32
+ });
33
+
14
34
  describe('wrapServer', () => {
15
35
  // Neutralize ambient GOG_ACCOUNT / GOG_READONLY so run()'s assembled arg list
16
36
  // is deterministic regardless of the shell the suite runs in.
@@ -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
+ });