gogcli-mcp 2.30.0 → 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 (40) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +15416 -18013
  4. package/dist/lib.js +13476 -8827
  5. package/manifest.json +1 -1
  6. package/package.json +3 -3
  7. package/server.json +2 -2
  8. package/src/connector-auth.ts +22 -6
  9. package/src/connector-login.ts +87 -0
  10. package/src/connector-runtime.ts +33 -10
  11. package/src/gmail-dispatch-guard.ts +106 -0
  12. package/src/gmail-results.ts +1 -1
  13. package/src/lib.ts +10 -0
  14. package/src/pagination.ts +1 -1
  15. package/src/tools/api.ts +7 -7
  16. package/src/tools/appscript.ts +15 -15
  17. package/src/tools/auth.ts +11 -11
  18. package/src/tools/calendar.ts +13 -13
  19. package/src/tools/chat.ts +25 -25
  20. package/src/tools/classroom.ts +49 -49
  21. package/src/tools/contacts.ts +9 -9
  22. package/src/tools/docs.ts +13 -13
  23. package/src/tools/drive.ts +22 -22
  24. package/src/tools/gmail.ts +136 -23
  25. package/src/tools/sheets.ts +15 -15
  26. package/src/tools/slides.ts +13 -13
  27. package/src/tools/tasks.ts +13 -13
  28. package/src/tools/utils.ts +2 -3
  29. package/src/worker.ts +52 -64
  30. package/tests/connector-login.test.ts +151 -0
  31. package/tests/connector-runtime.test.ts +21 -1
  32. package/tests/gmail-dispatch-guard.test.ts +132 -0
  33. package/tests/sdk-single-copy.test.ts +18 -36
  34. package/tests/tools/appscript.test.ts +1 -1
  35. package/tests/tools/chat.test.ts +1 -1
  36. package/tests/tools/gmail.test.ts +242 -12
  37. package/tests/tools/sheets.test.ts +1 -1
  38. package/tests/zod-single-copy.test.ts +2 -2
  39. package/tsconfig.json +1 -3
  40. package/vitest.config.ts +3 -5
@@ -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
+ });
@@ -3,46 +3,28 @@ import { createRequire } from 'node:module';
3
3
  import { realpathSync } from 'node:fs';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
6
- // Guards the invariant that broke dependabot #220: the whole monorepo must
7
- // resolve ONE copy of @modelcontextprotocol/sdk.
8
- //
9
- // `McpServer` carries a `private _serverInfo`, so TypeScript compares it
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
+ // The Worker runtime still installs the legacy SDK through Cloudflare Agents,
7
+ // while this project itself now uses the modular SDK v2 server.
8
+ // Keep each lane on one physical copy: both server classes carry private state,
9
+ // so duplicate copies produce nominal TypeScript failures that look like API
10
+ // incompatibilities.
11
+ const here = createRequire(import.meta.url);
23
12
 
24
- // An exported subpath — the SDK's `exports` map does not expose package.json.
25
- const SDK_SUBPATH = '@modelcontextprotocol/sdk/server/mcp.js';
13
+ const resolveFrom = (specifier: string, target: string): string =>
14
+ realpathSync(createRequire(fileURLToPath(import.meta.resolve(specifier))).resolve(target));
26
15
 
27
- // `import.meta.resolve`, not `require.resolve`, to reach the dependency's own
28
- // entry: these packages are ESM-only, so their `exports` maps carry no
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
- );
16
+ describe('MCP SDK packages are each installed exactly once', () => {
17
+ const LEGACY_SERVER = '@modelcontextprotocol/sdk/server/mcp.js';
18
+ const MODERN_SERVER = '@modelcontextprotocol/server';
34
19
 
35
- it('resolves to the same file for this package and for @chrischall/mcp-utils', () => {
36
- // mcp-utils declares the SDK as a peer and hands our registrars the
37
- // McpServer it built, so its copy is the one they must be typed against.
38
- expect(resolveFrom('@chrischall/mcp-utils')).toBe(
39
- realpathSync(here.resolve(SDK_SUBPATH)),
40
- );
20
+ it('keeps the retained legacy lane used by agents at the root', () => {
21
+ const root = realpathSync(here.resolve(LEGACY_SERVER));
22
+ expect(resolveFrom('agents', LEGACY_SERVER)).toBe(root);
41
23
  });
42
24
 
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)));
25
+ it('keeps the SDK v2 server shared by this package and mcp-utils', () => {
26
+ expect(resolveFrom('@chrischall/mcp-utils', MODERN_SERVER)).toBe(
27
+ realpathSync(here.resolve(MODERN_SERVER)),
28
+ );
47
29
  });
48
30
  });
@@ -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/sdk/server/mcp.js');
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) => {
@@ -38,7 +38,7 @@ describe('gog_chat_spaces_list', () => {
38
38
  // description a model reads — a source comment would not reach it. Every chat
39
39
  // tool carries the note, because any of them can be the first one called.
40
40
  it('warns in EVERY description that Chat is Workspace-only', async () => {
41
- const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
41
+ const { McpServer } = await import('@modelcontextprotocol/server');
42
42
  const server = new McpServer({ name: 'test', version: '0.0.0' });
43
43
  const configs = new Map<string, { description?: string }>();
44
44
  vi.spyOn(server, 'registerTool').mockImplementation((name, config) => {
@@ -3,10 +3,14 @@ import { registerGmailTools } from '../../src/tools/gmail.js';
3
3
  import * as runner from '../../src/runner.js';
4
4
  import { PAYLOAD_INLINE_MAX } from '../../src/tools/utils.js';
5
5
  import { createTestHarness } from '@chrischall/mcp-utils/test';
6
+ import type { ElicitRequest, ElicitResult } from '@modelcontextprotocol/server';
6
7
 
7
8
  vi.mock('../../src/runner.js');
8
9
 
9
- const setupHandlers = () => createTestHarness(registerGmailTools);
10
+ const setupHandlers = (
11
+ elicitation: (request: ElicitRequest) => ElicitResult | Promise<ElicitResult> =
12
+ async () => ({ action: 'accept', content: { confirmed: true } }),
13
+ ) => createTestHarness(registerGmailTools, { elicitation });
10
14
 
11
15
  beforeEach(() => vi.clearAllMocks());
12
16
 
@@ -425,6 +429,88 @@ describe('gog_gmail_send', () => {
425
429
  const result = await harness.callTool('gog_gmail_send', { to: 'x', subject: 'y', body: 'z' });
426
430
  expect(result.content[0].text).toBe('Error: Send failed');
427
431
  });
432
+
433
+ // ==========================================================================
434
+ // THE CONFIRMATION GATE. Unlike reply/reply-all, send's recipients are
435
+ // explicit params, so its preview needs no extra gog call at all.
436
+ // ==========================================================================
437
+ describe('confirmation gate', () => {
438
+ it('elicits a recipient preview and sends only after the user accepts it', async () => {
439
+ vi.mocked(runner.run).mockResolvedValue('{}');
440
+ let request: ElicitRequest | undefined;
441
+ const harness = await setupHandlers(async (value) => {
442
+ request = value;
443
+ return { action: 'accept', content: { confirmed: true } };
444
+ });
445
+ const result = await harness.callTool('gog_gmail_send', {
446
+ to: 'bob@example.com', cc: 'carol@example.com', subject: 'Hi', body: 'Hello',
447
+ replyToMessageId: 'm1', quote: true,
448
+ });
449
+ expect(request?.params.message).toContain('bob@example.com');
450
+ expect(request?.params.message).toContain('carol@example.com');
451
+ expect(request?.params.message).toContain('"threaded": true');
452
+ expect(request?.params.requestedSchema).toEqual(expect.objectContaining({ type: 'object' }));
453
+ expect(result.isError).not.toBe(true);
454
+ expect(runner.run).toHaveBeenCalledTimes(1);
455
+ });
456
+
457
+ it('sends nothing when the user declines the elicitation', async () => {
458
+ const harness = await setupHandlers(async () => ({ action: 'decline' }));
459
+ const result = await harness.callTool('gog_gmail_send', {
460
+ to: 'bob@example.com', subject: 'Hi', body: 'Hello',
461
+ });
462
+ expect(runner.run).not.toHaveBeenCalled();
463
+ expect(JSON.parse(result.content[0].text as string)).toEqual(expect.objectContaining({
464
+ confirmed: false,
465
+ cancelled: true,
466
+ action: 'gmail.send',
467
+ }));
468
+ });
469
+
470
+ it('sends nothing when the user accepts but leaves confirmation false', async () => {
471
+ const harness = await setupHandlers(async () => ({ action: 'accept', content: { confirmed: false } }));
472
+ const result = await harness.callTool('gog_gmail_send', {
473
+ to: 'bob@example.com', subject: 'Hi', body: 'Hello',
474
+ });
475
+ expect(runner.run).not.toHaveBeenCalled();
476
+ expect(JSON.parse(result.content[0].text as string).cancelled).toBe(true);
477
+ });
478
+
479
+ it('logs a gmail_dispatch event with the recipients after an accepted send', async () => {
480
+ vi.mocked(runner.run).mockResolvedValue('{}');
481
+ const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
482
+ const harness = await setupHandlers();
483
+ await harness.callTool('gog_gmail_send', {
484
+ to: 'bob@example.com', subject: 'Hi', body: 'Hello',
485
+ });
486
+ const event = JSON.parse((writeSpy.mock.calls.at(-1)?.[0] as string).trim());
487
+ expect(event.event).toBe('gmail_dispatch');
488
+ expect(event.tool).toBe('gog_gmail_send');
489
+ expect(event.recipientCount).toBe(1);
490
+ writeSpy.mockRestore();
491
+ });
492
+
493
+ it('does not log when the accepted send fails', async () => {
494
+ vi.mocked(runner.run).mockRejectedValue(new Error('Send failed'));
495
+ const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
496
+ const harness = await setupHandlers();
497
+ await harness.callTool('gog_gmail_send', {
498
+ to: 'bob@example.com', subject: 'Hi', body: 'Hello',
499
+ });
500
+ expect(writeSpy).not.toHaveBeenCalled();
501
+ writeSpy.mockRestore();
502
+ });
503
+
504
+ it('still validates attachments before either preview or send', async () => {
505
+ const harness = await setupHandlers();
506
+ const res = await harness.callTool('gog_gmail_send', {
507
+ to: 'bob@example.com', subject: 's', body: 'b',
508
+ attachInline: [{ filename: 'a.png', contentBase64: 'not!valid!base64!' }],
509
+ });
510
+ expect(res.isError).toBe(true);
511
+ expect(runner.run).not.toHaveBeenCalled();
512
+ });
513
+ });
428
514
  });
429
515
 
430
516
  describe('gog_gmail_run', () => {
@@ -630,7 +716,9 @@ describe('gog_gmail_reply — full flag set', () => {
630
716
  it('omits --no-quote and --signature when false', async () => {
631
717
  vi.mocked(runner.run).mockResolvedValue('{}');
632
718
  const harness = await setupHandlers();
633
- await harness.callTool('gog_gmail_reply', { messageId: 'm1', body: 'Hi', noQuote: false, signature: false });
719
+ await harness.callTool('gog_gmail_reply', {
720
+ messageId: 'm1', body: 'Hi', noQuote: false, signature: false,
721
+ });
634
722
  expect(runner.run).toHaveBeenCalledWith(
635
723
  ['gmail', 'reply', 'm1', '--body=Hi', '--auto-from-addressed-alias=false'],
636
724
  { account: undefined },
@@ -672,6 +760,150 @@ describe('gog_gmail_reply_all — full flag set', () => {
672
760
  );
673
761
  });
674
762
  });
763
+
764
+ // ============================================================================
765
+ // THE CONFIRMATION GATE. The first protocol round returns input_required and
766
+ // the harness routes its embedded elicitation through the handler supplied to
767
+ // setupHandlers. Declining lets these tests inspect the prompt while proving
768
+ // the send subcommand was never reached.
769
+ // ============================================================================
770
+ describe.each([
771
+ ['gog_gmail_reply', 'reply' as const],
772
+ ['gog_gmail_reply_all', 'reply-all' as const],
773
+ ])('%s — confirmation gate', (tool, subcommand) => {
774
+ const METADATA = JSON.stringify({
775
+ headers: { from: 'Alice <alice@example.com>', to: 'me@example.com', cc: 'Carol <carol@law-firm.example>', subject: 'Contract terms' },
776
+ });
777
+
778
+ const promptedDetails = async (args: Record<string, unknown>, metadata = METADATA) => {
779
+ vi.mocked(runner.run).mockResolvedValue(METADATA);
780
+ if (metadata !== METADATA) vi.mocked(runner.run).mockResolvedValue(metadata);
781
+ let request: ElicitRequest | undefined;
782
+ const harness = await setupHandlers(async (value) => {
783
+ request = value;
784
+ return { action: 'decline' };
785
+ });
786
+ const result = await harness.callTool(tool, { messageId: 'm1', ...args });
787
+ return {
788
+ result,
789
+ request,
790
+ details: (JSON.parse(request?.params.message.split('\n').slice(1).join('\n') ?? '{}') as {
791
+ details?: Record<string, unknown>;
792
+ }).details ?? {},
793
+ };
794
+ };
795
+
796
+ it('elicits the resolved recipients and sends nothing when the user declines', async () => {
797
+ const { result, request, details } = await promptedDetails({ body: 'Sounds good' });
798
+ expect(runner.run).toHaveBeenCalledTimes(2);
799
+ expect(runner.run).toHaveBeenCalledWith(['gmail', 'get', 'm1', '--format=metadata'], { account: undefined });
800
+ expect(request?.params.requestedSchema).toEqual(expect.objectContaining({ type: 'object' }));
801
+ expect(JSON.parse(result.content[0].text as string)).toEqual(expect.objectContaining({
802
+ confirmed: false,
803
+ cancelled: true,
804
+ action: `gmail.${subcommand}`,
805
+ }));
806
+ if (subcommand === 'reply') {
807
+ expect(details.recipients).toEqual(['alice@example.com']);
808
+ } else {
809
+ expect(details.recipients).toEqual(['alice@example.com', 'me@example.com', 'carol@law-firm.example']);
810
+ }
811
+ });
812
+
813
+ it('sends nothing when the user accepts but leaves confirmation false', async () => {
814
+ vi.mocked(runner.run).mockResolvedValue(METADATA);
815
+ const harness = await setupHandlers(async () => ({ action: 'accept', content: { confirmed: false } }));
816
+ const result = await harness.callTool(tool, { messageId: 'm1', body: 'x' });
817
+ expect(runner.run).toHaveBeenCalledTimes(2);
818
+ expect(JSON.parse(result.content[0].text as string).cancelled).toBe(true);
819
+ });
820
+
821
+ it('falls back to "Re: <original subject>" in the prompt when no subject override is given', async () => {
822
+ const { details } = await promptedDetails({ body: 'x' });
823
+ expect(details.subject).toBe('Re: Contract terms');
824
+ });
825
+
826
+ it('prefers an explicit subject override in the prompt over the inherited one', async () => {
827
+ const { details } = await promptedDetails({ body: 'x', subject: 'Custom subject' });
828
+ expect(details.subject).toBe('Custom subject');
829
+ });
830
+
831
+ it('actually sends and logs the resolved recipients after the user accepts', async () => {
832
+ vi.mocked(runner.run)
833
+ .mockResolvedValueOnce(METADATA)
834
+ .mockResolvedValueOnce(METADATA)
835
+ .mockResolvedValueOnce('{"id":"sent1"}');
836
+ const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
837
+ const harness = await setupHandlers();
838
+ const result = await harness.callTool(tool, { messageId: 'm1', body: 'Sounds good' });
839
+ expect(runner.run).toHaveBeenCalledTimes(3);
840
+ expect(runner.run).toHaveBeenNthCalledWith(1, ['gmail', 'get', 'm1', '--format=metadata'], { account: undefined });
841
+ expect(runner.run).toHaveBeenNthCalledWith(
842
+ 3,
843
+ ['gmail', subcommand, 'm1', '--body=Sounds good', '--auto-from-addressed-alias=false'],
844
+ { account: undefined },
845
+ );
846
+ expect(result.content[0].text).toBe('{"id":"sent1"}');
847
+ const event = JSON.parse((writeSpy.mock.calls.at(-1)?.[0] as string).trim());
848
+ expect(event.event).toBe('gmail_dispatch');
849
+ expect(event.tool).toBe(tool);
850
+ expect(event.recipientCount).toBeGreaterThan(0);
851
+ writeSpy.mockRestore();
852
+ });
853
+
854
+ it('does not log when the accepted send itself fails', async () => {
855
+ vi.mocked(runner.run)
856
+ .mockResolvedValueOnce(METADATA)
857
+ .mockResolvedValueOnce(METADATA)
858
+ .mockRejectedValueOnce(new Error('quota exceeded'));
859
+ const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
860
+ const harness = await setupHandlers();
861
+ const result = await harness.callTool(tool, { messageId: 'm1', body: 'x' });
862
+ expect(result.isError).toBe(true);
863
+ expect(writeSpy).not.toHaveBeenCalled();
864
+ writeSpy.mockRestore();
865
+ });
866
+
867
+ it('surfaces a bad messageId as a diagnosed error, without ever building a preview', async () => {
868
+ vi.mocked(runner.run).mockRejectedValue(new Error('message not found'));
869
+ const harness = await setupHandlers();
870
+ const result = await harness.callTool(tool, { messageId: 'bad', body: 'x' });
871
+ expect(result.isError).toBe(true);
872
+ expect(result.content[0].text).toContain('message not found');
873
+ });
874
+
875
+ it('measures bodyLength from bodyHtml when no plain body is given', async () => {
876
+ const { details } = await promptedDetails({ bodyHtml: '<p>Hi</p>' });
877
+ expect(details.bodyLength).toBe('<p>Hi</p>'.length);
878
+ });
879
+
880
+ it('measures bodyLength as zero when neither body nor bodyHtml is given', async () => {
881
+ const { details } = await promptedDetails({});
882
+ expect(details.bodyLength).toBe(0);
883
+ });
884
+
885
+ it('ignores a non-string header value in the metadata response', async () => {
886
+ const { details } = await promptedDetails({ body: 'x' }, JSON.stringify({
887
+ headers: { from: 'alice@example.com', subject: 42 },
888
+ }));
889
+ expect(details.recipients).toEqual(['alice@example.com']);
890
+ expect(details.subject).toBeUndefined();
891
+ });
892
+
893
+ it('treats an unparseable metadata response as empty headers rather than crashing', async () => {
894
+ const { details } = await promptedDetails({ body: 'x' }, 'not json at all');
895
+ expect(details.recipients).toEqual([]);
896
+ });
897
+
898
+ it('applies to/cc/bcc adds and remove drops on top of the inherited recipients in the prompt', async () => {
899
+ const { details } = await promptedDetails({
900
+ body: 'x', to: ['dave@example.com'], remove: ['carol@law-firm.example'],
901
+ });
902
+ expect(details.recipients).not.toContain('carol@law-firm.example');
903
+ expect(details.recipients).toContain('dave@example.com');
904
+ });
905
+ });
906
+
675
907
  describe('gog_gmail_reply body-vs-file conflicts', () => {
676
908
  it('rejects bodyHtml plus bodyHtmlFile before gog runs', async () => {
677
909
  vi.mocked(runner.run).mockResolvedValue('{}');
@@ -699,7 +931,9 @@ describe('gog_gmail_reply body-vs-file conflicts', () => {
699
931
  it('passes bodyHtmlFile alone through as --body-html-file', async () => {
700
932
  vi.mocked(runner.run).mockResolvedValue('{}');
701
933
  const harness = await setupHandlers();
702
- await harness.callTool('gog_gmail_reply', { messageId: 'm1', body: 'Hi', bodyHtmlFile: '/tmp/b.html' });
934
+ await harness.callTool('gog_gmail_reply', {
935
+ messageId: 'm1', body: 'Hi', bodyHtmlFile: '/tmp/b.html',
936
+ });
703
937
  expect(runner.run).toHaveBeenCalledWith(
704
938
  ['gmail', 'reply', 'm1', '--body=Hi', '--body-html-file=/tmp/b.html', '--auto-from-addressed-alias=false'],
705
939
  { account: undefined },
@@ -729,17 +963,13 @@ describe('gog_gmail_reply body-vs-file conflicts', () => {
729
963
  // timeout. No file param may advertise it as usable.
730
964
  describe('reply file params never advertise stdin as usable', () => {
731
965
  it.each(['gog_gmail_reply', 'gog_gmail_reply_all'])('%s.bodyHtmlFile warns that stdin hangs', async (tool) => {
732
- const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
733
- const server = new McpServer({ name: 'test', version: '0.0.0' });
734
- const configs = new Map<string, Record<string, { description?: string }>>();
735
- vi.spyOn(server, 'registerTool').mockImplementation((name, config) => {
736
- configs.set(name, (config as { inputSchema: Record<string, { description?: string }> }).inputSchema);
737
- return undefined as never;
738
- });
739
- registerGmailTools(server);
740
- const desc = configs.get(tool)?.bodyHtmlFile?.description ?? '';
966
+ const harness = await setupHandlers();
967
+ const listed = (await harness.client.listTools()).tools.find((candidate) => candidate.name === tool);
968
+ const properties = listed?.inputSchema.properties as Record<string, { description?: string }> | undefined;
969
+ const desc = properties?.bodyHtmlFile?.description ?? '';
741
970
  expect(desc).not.toBe('');
742
971
  expect(desc).not.toMatch(/(?:or|use)\s+"?-"?\s+(?:for|to read)/i);
743
972
  expect(desc).toMatch(/stdin/i);
973
+ expect(properties).not.toHaveProperty('confirmed');
744
974
  });
745
975
  });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { McpServer } from '@modelcontextprotocol/server';
3
3
  import { registerSheetsTools } from '../../src/tools/sheets.js';
4
4
  import * as runner from '../../src/runner.js';
5
5
  import { createTestHarness } from '@chrischall/mcp-utils/test';
@@ -38,10 +38,10 @@ describe('zod is installed exactly once', () => {
38
38
  expect(resolveFrom('@chrischall/mcp-utils')).toBe(realpathSync(here.resolve('zod')));
39
39
  });
40
40
 
41
- it('resolves to the same file for the MCP SDK, which types every tool schema', () => {
41
+ it('resolves to the same file for the MCP server SDK, which types every tool schema', () => {
42
42
  // `registerTool` accepts the raw shape and infers the handler's argument
43
43
  // types from it; a second copy makes every one of those schemas foreign.
44
- expect(resolveFrom('@modelcontextprotocol/sdk/server/mcp.js')).toBe(
44
+ expect(resolveFrom('@modelcontextprotocol/server')).toBe(
45
45
  realpathSync(here.resolve('zod')),
46
46
  );
47
47
  });
package/tsconfig.json CHANGED
@@ -5,8 +5,6 @@
5
5
  "rootDir": "./src"
6
6
  },
7
7
  "include": ["src/**/*"],
8
- // src/worker.ts is the Cloudflare-Worker entry: it imports
9
- // @chrischall/mcp-connector (which pulls the `agents` runtime + Cloudflare
10
- // types) and is compiled by wrangler/esbuild, not by this stdio tsc build.
8
+ // The Worker entry is compiled by wrangler/esbuild, not the stdio build.
11
9
  "exclude": ["node_modules", "dist", "src/worker.ts"]
12
10
  }
package/vitest.config.ts CHANGED
@@ -30,11 +30,9 @@ export default defineConfig({
30
30
  include: ['src/**/*.ts'],
31
31
  exclude: [
32
32
  'src/index.ts',
33
- // src/worker.ts is the only Worker-path file that can't load under the
34
- // node pool (it imports the `@chrischall/mcp-connector`/`agents` runtime);
35
- // it's exercised by the Workers pool suite (`npm run worker:test`). Its
36
- // testable helpers live in src/connector-runtime.ts, and src/connector-auth.ts
37
- // is node-loadable — both are unit-tested here and stay in the 100% gate.
33
+ // The Worker-only entry is exercised by the Workers pool suite
34
+ // (`npm run worker:test`). Its node-loadable helpers stay in this
35
+ // suite and its 100% gate.
38
36
  'src/worker.ts',
39
37
  ],
40
38
  thresholds: {