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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +17105 -18892
- package/dist/lib.js +15157 -9562
- package/manifest.json +1 -1
- package/mint.yaml +2 -2
- package/package.json +5 -5
- package/server.json +2 -2
- package/src/blob-upload.ts +179 -0
- package/src/blob-urls.ts +280 -0
- package/src/connector-auth.ts +22 -6
- package/src/connector-login.ts +87 -0
- package/src/connector-runtime.ts +33 -10
- package/src/gmail-dispatch-guard.ts +106 -0
- package/src/gmail-results.ts +1 -1
- package/src/lib.ts +29 -0
- package/src/pagination.ts +1 -1
- package/src/runner.ts +19 -2
- package/src/tools/api.ts +7 -7
- package/src/tools/appscript.ts +15 -15
- package/src/tools/auth.ts +11 -11
- 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 +22 -22
- package/src/tools/gmail.ts +136 -23
- 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 +2 -3
- package/src/worker.ts +52 -64
- package/tests/blob-upload.test.ts +204 -0
- package/tests/blob-urls.test.ts +319 -0
- package/tests/connector-login.test.ts +151 -0
- package/tests/connector-runtime.test.ts +21 -1
- package/tests/gmail-dispatch-guard.test.ts +132 -0
- package/tests/runner.test.ts +62 -1
- package/tests/sdk-single-copy.test.ts +18 -36
- package/tests/tools/appscript.test.ts +1 -1
- package/tests/tools/chat.test.ts +1 -1
- package/tests/tools/gmail.test.ts +242 -12
- package/tests/tools/sheets.test.ts +1 -1
- package/tests/zod-single-copy.test.ts +2 -2
- package/tsconfig.json +1 -3
- package/vitest.config.ts +3 -5
package/tests/runner.test.ts
CHANGED
|
@@ -499,7 +499,7 @@ describe('run', () => {
|
|
|
499
499
|
}
|
|
500
500
|
});
|
|
501
501
|
|
|
502
|
-
it('strips GOOGLE_APPLICATION_CREDENTIALS and *_TOKEN/*_SECRET/*
|
|
502
|
+
it('strips GOOGLE_APPLICATION_CREDENTIALS and *_TOKEN/*_SECRET/*_KEY/*_CREDENTIALS vars', async () => {
|
|
503
503
|
const spawner = makeSpawner(0, '{}');
|
|
504
504
|
const snapshot = {
|
|
505
505
|
GOOGLE_APPLICATION_CREDENTIALS: process.env.GOOGLE_APPLICATION_CREDENTIALS,
|
|
@@ -532,6 +532,67 @@ describe('run', () => {
|
|
|
532
532
|
}
|
|
533
533
|
});
|
|
534
534
|
|
|
535
|
+
// The suffix list was `_TOKEN|_SECRET|_API_KEY|_PRIVATE_KEY` — four spellings
|
|
536
|
+
// of "a key", none of which is a bare `_KEY`. Two credentials this repo hands
|
|
537
|
+
// its own process sit in exactly that gap: `MCP_BLOB_SIGNING_KEY` (mints the
|
|
538
|
+
// signed blob URLs a `deliver="url"` download is uploaded to) and
|
|
539
|
+
// `GOG_RUNNER_KEY` (the bearer for the Fly backend, and for `POST /upload`,
|
|
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 () => {
|
|
542
|
+
const spawner = makeSpawner(0, '{}');
|
|
543
|
+
const snapshot = {
|
|
544
|
+
MCP_BLOB_SIGNING_KEY: process.env.MCP_BLOB_SIGNING_KEY,
|
|
545
|
+
GOG_RUNNER_KEY: process.env.GOG_RUNNER_KEY,
|
|
546
|
+
STRIPE_KEY: process.env.STRIPE_KEY,
|
|
547
|
+
AWS_CREDENTIALS: process.env.AWS_CREDENTIALS,
|
|
548
|
+
};
|
|
549
|
+
process.env.MCP_BLOB_SIGNING_KEY = 'blob-signing-secret';
|
|
550
|
+
process.env.GOG_RUNNER_KEY = 'runner-bearer-secret';
|
|
551
|
+
process.env.STRIPE_KEY = 'sk-live-secret';
|
|
552
|
+
process.env.AWS_CREDENTIALS = '/path/to/creds';
|
|
553
|
+
try {
|
|
554
|
+
await run(['docs', 'cat', 'id'], { spawner });
|
|
555
|
+
const envPassed = (spawner as ReturnType<typeof vi.fn>).mock.calls[0][2].env as NodeJS.ProcessEnv;
|
|
556
|
+
expect(envPassed.MCP_BLOB_SIGNING_KEY).toBeUndefined();
|
|
557
|
+
expect(envPassed.GOG_RUNNER_KEY).toBeUndefined();
|
|
558
|
+
expect(envPassed.STRIPE_KEY).toBeUndefined();
|
|
559
|
+
// `_CREDENTIALS`, generalising the one named GOOGLE_APPLICATION_CREDENTIALS.
|
|
560
|
+
expect(envPassed.AWS_CREDENTIALS).toBeUndefined();
|
|
561
|
+
} finally {
|
|
562
|
+
for (const [k, v] of Object.entries(snapshot)) {
|
|
563
|
+
if (v === undefined) delete process.env[k];
|
|
564
|
+
else process.env[k] = v;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// The CONTROL on widening, and the reason the list is not simply "anything
|
|
570
|
+
// that smells like a credential": `GOG_KEYRING_PASSWORD` is how gog decrypts
|
|
571
|
+
// its own file keyring (`GOG_KEYRING_BACKEND=file`), so a `_PASSWORD` rule
|
|
572
|
+
// would strip the one credential the child legitimately needs and turn every
|
|
573
|
+
// call into an auth failure. `GOG_KEYRING_BACKEND` is the same story without
|
|
574
|
+
// the secret.
|
|
575
|
+
it('keeps the credentials gog itself reads from the environment', async () => {
|
|
576
|
+
const spawner = makeSpawner(0, '{}');
|
|
577
|
+
const snapshot = {
|
|
578
|
+
GOG_KEYRING_PASSWORD: process.env.GOG_KEYRING_PASSWORD,
|
|
579
|
+
GOG_KEYRING_BACKEND: process.env.GOG_KEYRING_BACKEND,
|
|
580
|
+
};
|
|
581
|
+
process.env.GOG_KEYRING_PASSWORD = 'keyring-pass';
|
|
582
|
+
process.env.GOG_KEYRING_BACKEND = 'file';
|
|
583
|
+
try {
|
|
584
|
+
await run(['docs', 'cat', 'id'], { spawner });
|
|
585
|
+
const envPassed = (spawner as ReturnType<typeof vi.fn>).mock.calls[0][2].env as NodeJS.ProcessEnv;
|
|
586
|
+
expect(envPassed.GOG_KEYRING_PASSWORD).toBe('keyring-pass');
|
|
587
|
+
expect(envPassed.GOG_KEYRING_BACKEND).toBe('file');
|
|
588
|
+
} finally {
|
|
589
|
+
for (const [k, v] of Object.entries(snapshot)) {
|
|
590
|
+
if (v === undefined) delete process.env[k];
|
|
591
|
+
else process.env[k] = v;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
535
596
|
it('redacts Bearer with quoted/encoded characters', async () => {
|
|
536
597
|
const stderrLeak = 'http 401: header was Bearer eyJ.test+slash/equal=padding more text';
|
|
537
598
|
const spawner = makeSpawner(1, '', stderrLeak);
|
|
@@ -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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
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
|
-
|
|
25
|
-
|
|
13
|
+
const resolveFrom = (specifier: string, target: string): string =>
|
|
14
|
+
realpathSync(createRequire(fileURLToPath(import.meta.resolve(specifier))).resolve(target));
|
|
26
15
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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('
|
|
36
|
-
|
|
37
|
-
|
|
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('
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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/
|
|
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) => {
|
package/tests/tools/chat.test.ts
CHANGED
|
@@ -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/
|
|
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 = (
|
|
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', {
|
|
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', {
|
|
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
|
|
733
|
-
const
|
|
734
|
-
const
|
|
735
|
-
|
|
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/
|
|
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/
|
|
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
|
-
//
|
|
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
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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: {
|