gogcli-mcp 2.30.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +15345 -18417
- package/dist/lib.js +13720 -9481
- package/manifest.json +2 -2
- package/mint.yaml +37 -33
- package/package.json +5 -5
- package/server.json +2 -2
- package/src/attachments.ts +28 -34
- package/src/blob-upload.ts +165 -134
- package/src/blob-urls.ts +3 -5
- package/src/bootstrap-auth.ts +97 -0
- package/src/gmail-dispatch-guard.ts +106 -0
- package/src/gmail-results.ts +1 -1
- package/src/index.ts +3 -4
- package/src/lib.ts +24 -9
- package/src/pagination.ts +1 -1
- package/src/runner.ts +23 -175
- package/src/tools/api.ts +7 -7
- package/src/tools/appscript.ts +16 -16
- package/src/tools/auth.ts +16 -16
- package/src/tools/calendar.ts +13 -13
- package/src/tools/chat.ts +25 -25
- package/src/tools/classroom.ts +49 -49
- package/src/tools/contacts.ts +9 -9
- package/src/tools/docs.ts +13 -13
- package/src/tools/drive.ts +23 -25
- package/src/tools/gmail.ts +145 -32
- package/src/tools/sheets.ts +15 -15
- package/src/tools/slides.ts +13 -13
- package/src/tools/tasks.ts +13 -13
- package/src/tools/utils.ts +14 -61
- package/tests/attachments.test.ts +11 -14
- package/tests/blob-upload.test.ts +235 -160
- package/tests/bootstrap-auth.test.ts +245 -0
- package/tests/gmail-dispatch-guard.test.ts +132 -0
- package/tests/runner-file-args.test.ts +1 -13
- package/tests/runner.test.ts +8 -95
- package/tests/sdk-single-copy.test.ts +11 -37
- package/tests/tools/appscript.test.ts +1 -1
- package/tests/tools/auth-401-shapes.test.ts +2 -3
- package/tests/tools/auth.test.ts +5 -4
- package/tests/tools/chat.test.ts +1 -1
- package/tests/tools/drive.test.ts +11 -3
- package/tests/tools/gmail.test.ts +244 -14
- package/tests/tools/sheets.test.ts +1 -1
- package/tests/tools/utils.test.ts +1 -50
- package/tests/zod-single-copy.test.ts +8 -16
- package/tsconfig.json +1 -4
- package/vitest.config.ts +2 -14
- package/src/auth-log.ts +0 -205
- package/src/connector-auth.ts +0 -303
- package/src/connector-runtime.ts +0 -887
- package/src/google-probe.ts +0 -113
- package/src/google-token.ts +0 -391
- package/src/remote-runner.ts +0 -77
- package/src/worker.ts +0 -129
- package/tests/auth-log.test.ts +0 -530
- package/tests/connector-auth.test.ts +0 -559
- package/tests/connector-runtime.test.ts +0 -1644
- package/tests/google-probe.test.ts +0 -116
- package/tests/google-token.test.ts +0 -425
- package/tests/remote-runner.test.ts +0 -202
- package/tests/worker.test.ts +0 -167
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) => {
|
|
@@ -468,14 +468,22 @@ describe('gog_drive_read_bytes', () => {
|
|
|
468
468
|
expect(res.resource.uri).toBe('gogdrive://f2/file');
|
|
469
469
|
});
|
|
470
470
|
|
|
471
|
-
it('surfaces
|
|
471
|
+
it('surfaces a byte-fetch failure via diagnose', async () => {
|
|
472
472
|
vi.mocked(runner.run)
|
|
473
473
|
.mockResolvedValueOnce(JSON.stringify({ file: { name: 'a.pdf', mimeType: 'application/pdf' } }))
|
|
474
474
|
.mockResolvedValueOnce('user@x.com'); // diagnose -> auth list
|
|
475
|
-
vi.mocked(runner.runBinary).mockRejectedValueOnce(new Error('
|
|
475
|
+
vi.mocked(runner.runBinary).mockRejectedValueOnce(new Error('gog exited with code 1'));
|
|
476
476
|
const harness = await setupHandlers();
|
|
477
477
|
const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f1' });
|
|
478
478
|
expect(result.isError).toBe(true);
|
|
479
|
-
expect(result.content[0].text).toContain('
|
|
479
|
+
expect(result.content[0].text).toContain('gog exited with code 1');
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('no longer claims the hosted transport cannot carry bytes', async () => {
|
|
483
|
+
const harness = await setupHandlers();
|
|
484
|
+
const { tools } = await harness.client.listTools();
|
|
485
|
+
const desc = tools.find((t) => t.name === 'gog_drive_read_bytes')!.description!;
|
|
486
|
+
expect(desc).not.toMatch(/text-only|hosted connector/i);
|
|
487
|
+
await harness.close();
|
|
480
488
|
});
|
|
481
489
|
});
|
|
@@ -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
|
|
|
@@ -300,8 +304,8 @@ describe('gog_gmail_send', () => {
|
|
|
300
304
|
|
|
301
305
|
// ==========================================================================
|
|
302
306
|
// INLINE ATTACHMENT BYTES — for callers with no filesystem in common with gog
|
|
303
|
-
// (
|
|
304
|
-
//
|
|
307
|
+
// (a hosted deployment such as mcp-host). The bytes ride with the call and
|
|
308
|
+
// the runner materializes them beside gog.
|
|
305
309
|
// ==========================================================================
|
|
306
310
|
it('turns attachInline bytes into repeatable --attach file args', async () => {
|
|
307
311
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
@@ -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';
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import * as runner from '../../src/runner.js';
|
|
3
3
|
import { runOrDiagnose, pushPaginationFlags, formatAccountList, formatAuthHealth } from '../../src/tools/utils.js';
|
|
4
|
-
import { RunnerTransportError } from '../../src/connector-runtime.js';
|
|
5
4
|
|
|
6
|
-
// PARTIAL mock: only `run` is stubbed
|
|
7
|
-
// `RunnerTransportError` and `isRunnerTransportError`, and diagnose()'s whole
|
|
8
|
-
// point is that it classifies the REAL class structurally — an automocked
|
|
9
|
-
// brand check answers undefined for everything and the tests below would pass
|
|
10
|
-
// while proving nothing.
|
|
5
|
+
// PARTIAL mock: only `run` is stubbed; everything else stays real.
|
|
11
6
|
vi.mock('../../src/runner.js', async (importOriginal) => ({
|
|
12
7
|
...(await importOriginal<typeof runner>()),
|
|
13
8
|
run: vi.fn(),
|
|
@@ -605,47 +600,3 @@ describe('formatAuthHealth', () => {
|
|
|
605
600
|
expect(formatAuthHealth('{"foo":1}', NOW)).toBe('{"foo":1}');
|
|
606
601
|
});
|
|
607
602
|
});
|
|
608
|
-
|
|
609
|
-
// The connector's own transport failing is NOT the Google credential failing.
|
|
610
|
-
// diagnose() must recognise that structurally, from the error's type, because
|
|
611
|
-
// the runner's prose ("unauthorized") is indistinguishable from Google's.
|
|
612
|
-
describe('diagnose: runner-authored transport failures', () => {
|
|
613
|
-
it('blames the runner key, not the Google account, for a transport-auth failure', async () => {
|
|
614
|
-
vi.mocked(runner.run)
|
|
615
|
-
.mockRejectedValueOnce(
|
|
616
|
-
new RunnerTransportError('gog-runner rejected the bearer token; GOG_RUNNER_KEY vs RUNNER_KEY', 'transport-auth', 401),
|
|
617
|
-
)
|
|
618
|
-
.mockResolvedValueOnce('user@gmail.com');
|
|
619
|
-
const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
|
|
620
|
-
const text = result.content[0].text as string;
|
|
621
|
-
expect(text).not.toContain('gog_auth_add');
|
|
622
|
-
expect(text).not.toMatch(/re-authorize the account/i);
|
|
623
|
-
expect(text).toContain('GOG_RUNNER_KEY');
|
|
624
|
-
expect(text).toContain('RUNNER_KEY');
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
it('lets the TYPE beat the prose: runner words that read like Google words get no re-auth hint', async () => {
|
|
628
|
-
// The regression this whole change exists to prevent. A runner-authored
|
|
629
|
-
// failure whose text happens to contain every Google auth signal must still
|
|
630
|
-
// not produce Google auth advice — the request never reached Google.
|
|
631
|
-
vi.mocked(runner.run)
|
|
632
|
-
.mockRejectedValueOnce(
|
|
633
|
-
new RunnerTransportError('unauthorized: 401 invalid_grant, token has been expired or revoked', 'transport-request', 400),
|
|
634
|
-
)
|
|
635
|
-
.mockResolvedValueOnce('user@gmail.com');
|
|
636
|
-
const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
|
|
637
|
-
const text = result.content[0].text as string;
|
|
638
|
-
expect(text).not.toContain('gog_auth_add');
|
|
639
|
-
expect(text).not.toContain('often transient');
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
it('advises a retry for a retryable transport failure', async () => {
|
|
643
|
-
vi.mocked(runner.run)
|
|
644
|
-
.mockRejectedValueOnce(new RunnerTransportError('gog-runner is restarting; retry this call.', 'transport-retryable', 503))
|
|
645
|
-
.mockResolvedValueOnce('user@gmail.com');
|
|
646
|
-
const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
|
|
647
|
-
const text = result.content[0].text as string;
|
|
648
|
-
expect(text).toContain('often transient');
|
|
649
|
-
expect(text).not.toContain('gog_auth_add');
|
|
650
|
-
});
|
|
651
|
-
});
|
|
@@ -6,12 +6,12 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
// The zod counterpart of sdk-single-copy.test.ts, guarding the invariant that
|
|
7
7
|
// broke dependabot #333: the whole monorepo must resolve ONE copy of zod.
|
|
8
8
|
//
|
|
9
|
-
// Same shape of failure, a different package.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
9
|
+
// Same shape of failure, a different package. Any dependency that pins zod
|
|
10
|
+
// can take the hoisted root slot that `@chrischall/mcp-utils` and the MCP SDK
|
|
11
|
+
// resolve their zod peer from (#333: a since-removed devDependency's exact pin).
|
|
12
|
+
// The moment our workspaces ask for a newer zod than that pin, each nests its
|
|
13
|
+
// own copy — and because a `ZodType` carries brand-bearing internals,
|
|
14
|
+
// TypeScript compares the two NOMINALLY: every schema our tools hand
|
|
15
15
|
// `registerTool` fails with `TS2322: Type 'ZodString' is not assignable to type
|
|
16
16
|
// 'AnySchema'`, with no API change and nothing to fix in the source. #333 split
|
|
17
17
|
// the tree exactly that way and produced 11,024 type errors from a bump of one
|
|
@@ -38,18 +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/
|
|
45
|
-
realpathSync(here.resolve('zod')),
|
|
46
|
-
);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('resolves to the same file for @cloudflare/vitest-pool-workers, which exact-pins zod', () => {
|
|
50
|
-
// The exact pin here is what captured the root hoist slot in #333, and it
|
|
51
|
-
// is why the root `overrides` block carries a zod entry.
|
|
52
|
-
expect(resolveFrom('@cloudflare/vitest-pool-workers')).toBe(
|
|
44
|
+
expect(resolveFrom('@modelcontextprotocol/server')).toBe(
|
|
53
45
|
realpathSync(here.resolve('zod')),
|
|
54
46
|
);
|
|
55
47
|
});
|
package/tsconfig.json
CHANGED
|
@@ -5,8 +5,5 @@
|
|
|
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.
|
|
11
|
-
"exclude": ["node_modules", "dist", "src/worker.ts"]
|
|
8
|
+
"exclude": ["node_modules", "dist"]
|
|
12
9
|
}
|
package/vitest.config.ts
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
2
|
|
|
3
3
|
export default defineConfig({
|
|
4
4
|
test: {
|
|
5
|
-
// `tests/worker.test.ts` only runs under the Workers runtime pool
|
|
6
|
-
// (root `vitest.workers.config.mts` / `npm run worker:test`), which provides
|
|
7
|
-
// the virtual `cloudflare:test` module it imports. The node pool must skip it.
|
|
8
|
-
exclude: [...configDefaults.exclude, 'tests/worker.test.ts'],
|
|
9
5
|
// Neutralize the gog env vars for the whole suite.
|
|
10
6
|
//
|
|
11
7
|
// THE BUG THIS FIXES: the runner tests assert the exact argv `run()` builds,
|
|
@@ -28,15 +24,7 @@ export default defineConfig({
|
|
|
28
24
|
coverage: {
|
|
29
25
|
provider: 'v8',
|
|
30
26
|
include: ['src/**/*.ts'],
|
|
31
|
-
exclude: [
|
|
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.
|
|
38
|
-
'src/worker.ts',
|
|
39
|
-
],
|
|
27
|
+
exclude: ['src/index.ts'],
|
|
40
28
|
thresholds: {
|
|
41
29
|
lines: 100,
|
|
42
30
|
functions: 100,
|