gogcli-mcp 4.4.0 → 4.5.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.
@@ -1,200 +1,20 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import {
3
- CONFIRM_TOKEN_TTL_DEFAULT_SECONDS,
4
- confirmTokenTtlSeconds,
5
- hashSendPayload,
6
- issueConfirmToken,
7
- resetConfirmTokenState,
8
- sendConfirmFallbackEnabled,
9
- verifyConfirmToken,
10
- type ConfirmBinding,
11
- } from '../src/send-confirm-token.js';
12
-
13
- const ORIGINAL_ENV = { ...process.env };
14
-
15
- const BINDING: ConfirmBinding = {
16
- tool: 'gog_gmail_drafts_send',
17
- account: 'me@example.com',
18
- target: 'r123',
19
- revision: 'msg-1',
20
- payloadHash: hashSendPayload({ to: 'a@example.com', body: 'hello' }),
21
- };
22
-
23
- const NOW = 1_800_000_000_000;
24
-
25
- beforeEach(() => {
26
- process.env = { ...ORIGINAL_ENV };
27
- delete process.env.GOG_SEND_CONFIRM_FALLBACK;
28
- delete process.env.GOG_CONFIRM_SECRET;
29
- delete process.env.GOG_CONFIRM_TTL_SECONDS;
30
- resetConfirmTokenState();
31
- });
32
-
33
- afterEach(() => {
34
- process.env = ORIGINAL_ENV;
35
- resetConfirmTokenState();
36
- });
37
-
38
- describe('hashSendPayload', () => {
39
- it('is independent of key order, so a re-read payload hashes the same', () => {
40
- expect(hashSendPayload({ a: 1, b: { c: 2, d: [1, 2] } }))
41
- .toBe(hashSendPayload({ b: { d: [1, 2], c: 2 }, a: 1 }));
42
- });
43
-
44
- it('changes when any value changes, including array order', () => {
45
- const base = hashSendPayload({ to: ['a', 'b'], body: 'x' });
46
- expect(hashSendPayload({ to: ['b', 'a'], body: 'x' })).not.toBe(base);
47
- expect(hashSendPayload({ to: ['a', 'b'], body: 'x ' })).not.toBe(base);
48
- });
49
-
50
- it('treats an undefined field as absent', () => {
51
- expect(hashSendPayload({ a: 1, b: undefined })).toBe(hashSendPayload({ a: 1 }));
52
- });
53
-
54
- it('distinguishes null from absent', () => {
55
- expect(hashSendPayload({ a: null })).not.toBe(hashSendPayload({}));
56
- });
57
- });
58
-
59
- describe('sendConfirmFallbackEnabled', () => {
60
- it('is off unless GOG_SEND_CONFIRM_FALLBACK is exactly "token" (case-insensitive)', () => {
61
- expect(sendConfirmFallbackEnabled()).toBe(false);
62
- process.env.GOG_SEND_CONFIRM_FALLBACK = '1';
63
- expect(sendConfirmFallbackEnabled()).toBe(false);
64
- process.env.GOG_SEND_CONFIRM_FALLBACK = ' Token ';
65
- expect(sendConfirmFallbackEnabled()).toBe(true);
66
- });
67
-
68
- it('treats an unresolved .mcpb placeholder as unset', () => {
69
- process.env.GOG_SEND_CONFIRM_FALLBACK = '${user_config.send_confirm_fallback}';
70
- expect(sendConfirmFallbackEnabled()).toBe(false);
71
- });
72
- });
73
-
74
- describe('confirmTokenTtlSeconds', () => {
75
- it('defaults to ten minutes', () => {
76
- expect(CONFIRM_TOKEN_TTL_DEFAULT_SECONDS).toBe(600);
77
- expect(confirmTokenTtlSeconds()).toBe(600);
78
- });
79
-
80
- it('honours a positive integer GOG_CONFIRM_TTL_SECONDS', () => {
81
- process.env.GOG_CONFIRM_TTL_SECONDS = '30';
82
- expect(confirmTokenTtlSeconds()).toBe(30);
83
- });
84
-
85
- it.each(['0', '-5', 'abc', '1.5', ''])('falls back to the default for %j', (value) => {
86
- process.env.GOG_CONFIRM_TTL_SECONDS = value;
87
- expect(confirmTokenTtlSeconds()).toBe(600);
88
- });
89
- });
90
-
91
- describe('issueConfirmToken / verifyConfirmToken', () => {
92
- it('accepts a fresh token for the exact binding it was issued for', () => {
93
- const { token, expiresAt } = issueConfirmToken(BINDING, NOW);
94
- expect(expiresAt).toBe(new Date(NOW + 600_000).toISOString());
95
- expect(verifyConfirmToken(token, BINDING, NOW + 1_000)).toEqual({ ok: true });
96
- });
97
-
98
- it('is single-use: the second presentation is TOKEN_REUSED', () => {
99
- const { token } = issueConfirmToken(BINDING, NOW);
100
- expect(verifyConfirmToken(token, BINDING, NOW)).toEqual({ ok: true });
101
- expect(verifyConfirmToken(token, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_REUSED' });
102
- });
103
-
104
- it('expires after the TTL', () => {
105
- const { token } = issueConfirmToken(BINDING, NOW);
106
- expect(verifyConfirmToken(token, BINDING, NOW + 600_001)).toMatchObject({ ok: false, error: 'TOKEN_EXPIRED' });
107
- });
108
-
109
- it('uses GOG_CONFIRM_TTL_SECONDS at issue time', () => {
110
- process.env.GOG_CONFIRM_TTL_SECONDS = '5';
111
- const { token } = issueConfirmToken(BINDING, NOW);
112
- expect(verifyConfirmToken(token, BINDING, NOW + 5_001)).toMatchObject({ ok: false, error: 'TOKEN_EXPIRED' });
113
- });
114
-
115
- it('does not consume a token that failed verification', () => {
116
- const { token } = issueConfirmToken(BINDING, NOW);
117
- const changed = { ...BINDING, payloadHash: hashSendPayload({ to: 'b@example.com' }) };
118
- expect(verifyConfirmToken(token, changed, NOW)).toMatchObject({ ok: false, error: 'DRAFT_CHANGED' });
119
- expect(verifyConfirmToken(token, BINDING, NOW)).toEqual({ ok: true });
120
- });
121
-
122
- it('reports DRAFT_CHANGED with reason payload-changed when the payload hash differs', () => {
123
- const { token } = issueConfirmToken(BINDING, NOW);
124
- const changed = { ...BINDING, payloadHash: hashSendPayload({ to: 'a@example.com', body: 'HELLO' }) };
125
- expect(verifyConfirmToken(token, changed, NOW)).toEqual({ ok: false, error: 'DRAFT_CHANGED', reason: 'payload-changed' });
126
- });
127
-
128
- it('reports DRAFT_CHANGED with reason message-id-rotated when the revision rotated', () => {
129
- const { token } = issueConfirmToken(BINDING, NOW);
130
- expect(verifyConfirmToken(token, { ...BINDING, revision: 'msg-2' }, NOW))
131
- .toEqual({ ok: false, error: 'DRAFT_CHANGED', reason: 'message-id-rotated' });
132
- });
133
-
134
- it.each([
135
- ['tool', { tool: 'gog_gmail_send' }],
136
- ['account', { account: 'other@example.com' }],
137
- ['draft/message', { target: 'r999' }],
138
- ] as const)('never accepts a token issued for a different %s', (_label, override) => {
139
- const { token } = issueConfirmToken(BINDING, NOW);
140
- expect(verifyConfirmToken(token, { ...BINDING, ...override }, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
141
- });
142
-
143
- it('rejects a tampered claim as TOKEN_INVALID', () => {
144
- const { token } = issueConfirmToken(BINDING, NOW);
145
- const [prefix, claims, sig] = token.split('.');
146
- const decoded = JSON.parse(Buffer.from(claims!, 'base64url').toString('utf8'));
147
- decoded.exp += 3_600_000;
148
- const forged = `${prefix}.${Buffer.from(JSON.stringify(decoded)).toString('base64url')}.${sig}`;
149
- expect(verifyConfirmToken(forged, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
150
- });
151
-
152
- it('rejects a tampered signature as TOKEN_INVALID', () => {
153
- const { token } = issueConfirmToken(BINDING, NOW);
154
- const flipped = token.slice(0, -2) + (token.endsWith('AA') ? 'BB' : 'AA');
155
- expect(verifyConfirmToken(flipped, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
156
- });
157
-
158
- it.each(['', 'garbage', 'gct1.a.b', 'x.y.z', 'gct1..', 'gct1.a.b.c'])('rejects malformed token %j as TOKEN_INVALID', (token) => {
159
- expect(verifyConfirmToken(token, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
160
- });
161
-
162
- it('rejects a correctly signed token whose claims are not JSON', async () => {
163
- const { createHmac } = await import('node:crypto');
164
- process.env.GOG_CONFIRM_SECRET = 'a-shared-secret';
1
+ import { describe, it, expect } from 'vitest';
2
+ import { confirmKeyFromEnv, issueConfirmToken, verifyConfirmToken } from '@chrischall/mcp-utils';
3
+ import { confirmSpentStore, resetConfirmTokenState } from '../src/send-confirm-token.js';
4
+
5
+ // The token mechanism and the MCP_CONFIRM_* env layer live in
6
+ // @chrischall/mcp-utils and are tested there. What stays here is this server's
7
+ // spent-token store and the seam its tests reset it with.
8
+ describe('confirmSpentStore', () => {
9
+ it('is one store for the process, emptied by resetConfirmTokenState', () => {
10
+ const binding = { tool: 'gog_gmail_send', account: 'me@example.com', target: '', payloadHash: 'h' };
11
+ const key = confirmKeyFromEnv({});
12
+ const { token } = issueConfirmToken(key, binding);
13
+ expect(verifyConfirmToken(key, token, binding, { spent: confirmSpentStore() })).toEqual({ ok: true });
14
+ expect(confirmSpentStore()).toBe(confirmSpentStore());
15
+ expect(confirmSpentStore().size).toBe(1);
16
+ expect(verifyConfirmToken(key, token, binding, { spent: confirmSpentStore() })).toEqual({ ok: false, error: 'TOKEN_REUSED' });
165
17
  resetConfirmTokenState();
166
- const claims = Buffer.from('not json').toString('base64url');
167
- const sig = createHmac('sha256', 'a-shared-secret').update(`gct1.${claims}`).digest('base64url');
168
- expect(verifyConfirmToken(`gct1.${claims}.${sig}`, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
169
- });
170
-
171
- it('rejects a token signed by a previous process (per-process secret)', () => {
172
- const { token } = issueConfirmToken(BINDING, NOW);
173
- resetConfirmTokenState();
174
- expect(verifyConfirmToken(token, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
175
- });
176
-
177
- it('with GOG_CONFIRM_SECRET set, a token survives a restart', () => {
178
- process.env.GOG_CONFIRM_SECRET = 'a-shared-secret';
179
- resetConfirmTokenState();
180
- const { token } = issueConfirmToken(BINDING, NOW);
181
- resetConfirmTokenState();
182
- expect(verifyConfirmToken(token, BINDING, NOW)).toEqual({ ok: true });
183
- });
184
-
185
- it('binds a revision-less subject too', () => {
186
- const noRevision = { ...BINDING, revision: undefined };
187
- const { token } = issueConfirmToken(noRevision, NOW);
188
- expect(verifyConfirmToken(token, noRevision, NOW)).toEqual({ ok: true });
189
- });
190
-
191
- it('forgets used tokens once they would have expired anyway', () => {
192
- const first = issueConfirmToken(BINDING, NOW);
193
- expect(verifyConfirmToken(first.token, BINDING, NOW)).toEqual({ ok: true });
194
- // A later verification prunes the spent entry; the old token is then
195
- // rejected as expired rather than remembered forever.
196
- const second = issueConfirmToken(BINDING, NOW + 700_000);
197
- expect(verifyConfirmToken(second.token, BINDING, NOW + 700_000)).toEqual({ ok: true });
198
- expect(verifyConfirmToken(first.token, BINDING, NOW + 700_000)).toMatchObject({ ok: false, error: 'TOKEN_EXPIRED' });
18
+ expect(confirmSpentStore().size).toBe(0);
199
19
  });
200
20
  });
@@ -24,9 +24,9 @@ const ORIGINAL_ENV = { ...process.env };
24
24
  beforeEach(() => {
25
25
  vi.clearAllMocks();
26
26
  process.env = { ...ORIGINAL_ENV };
27
- delete process.env.GOG_SEND_CONFIRM_FALLBACK;
28
- delete process.env.GOG_CONFIRM_TTL_SECONDS;
29
- delete process.env.GOG_CONFIRM_SECRET;
27
+ delete process.env.MCP_CONFIRM_MODE;
28
+ delete process.env.MCP_CONFIRM_TTL_SECONDS;
29
+ delete process.env.MCP_CONFIRM_SECRET;
30
30
  process.env.GOG_ACCOUNT = 'me@example.com';
31
31
  resetConfirmTokenState();
32
32
  });
@@ -75,15 +75,17 @@ describe('gog_chat_messages_send / gog_chat_dm_send', () => {
75
75
  });
76
76
 
77
77
  it('refuses a client that cannot be prompted, naming the fallback switch', async () => {
78
+
79
+ process.env.MCP_CONFIRM_MODE = 'refuse';
78
80
  const harness = await unprompted(registerChatTools);
79
81
  const r = json(await harness.callTool('gog_chat_messages_send', { space: 'spaces/AAA', text: 'x' }));
80
82
  expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'chat.message-send' });
81
- expect(r.note).toContain('GOG_SEND_CONFIRM_FALLBACK=token');
83
+ expect(r.note).toContain('MCP_CONFIRM_MODE=ask-user');
82
84
  expect(runner.run).not.toHaveBeenCalled();
83
85
  });
84
86
 
85
87
  it('token fallback: previews in full, posts on phase 2, refuses a changed text', async () => {
86
- process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
88
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
87
89
  stubReads({});
88
90
  const harness = await unprompted(registerChatTools);
89
91
  const args = { space: 'spaces/AAA', text: 'Ship it', attachInline: [{ filename: 'a.txt', contentBase64: Buffer.from('hey').toString('base64') }] };
@@ -137,15 +139,17 @@ describe('gog_drive_share', () => {
137
139
  });
138
140
 
139
141
  it('refuses a client that cannot be prompted, and says how the user can share it', async () => {
142
+
143
+ process.env.MCP_CONFIRM_MODE = 'refuse';
140
144
  stubReads({ 'drive get': FILE });
141
145
  const harness = await unprompted(registerDriveTools);
142
146
  const r = json(await harness.callTool('gog_drive_share', { fileId: 'f1', to: 'domain', domain: 'example.com' }));
143
147
  expect(r.note).toMatch(/share it themselves from Google Drive/);
144
- expect(r.note).toContain('GOG_SEND_CONFIRM_FALLBACK=token');
148
+ expect(r.note).toContain('MCP_CONFIRM_MODE=ask-user');
145
149
  });
146
150
 
147
151
  it('token fallback: binds the file as read — a renamed file is DRAFT_CHANGED', async () => {
148
- process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
152
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
149
153
  stubReads({ 'drive get': FILE });
150
154
  const harness = await unprompted(registerDriveTools);
151
155
  const args = { fileId: 'f1', to: 'anyone' as const };
@@ -189,6 +193,8 @@ describe('gog_classroom_announcements_create', () => {
189
193
  });
190
194
 
191
195
  it('refuses a client that cannot be prompted, pointing at DRAFT', async () => {
196
+
197
+ process.env.MCP_CONFIRM_MODE = 'refuse';
192
198
  stubReads({ 'classroom courses get': COURSE });
193
199
  const harness = await unprompted(registerClassroomTools);
194
200
  const r = json(await harness.callTool('gog_classroom_announcements_create', { courseId: 'c1', text: 'x' }));
@@ -197,7 +203,7 @@ describe('gog_classroom_announcements_create', () => {
197
203
  });
198
204
 
199
205
  it('token fallback: phase 1 previews, phase 2 posts', async () => {
200
- process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
206
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
201
207
  stubReads({ 'classroom courses get': COURSE });
202
208
  const harness = await unprompted(registerClassroomTools);
203
209
  const p1 = json(await harness.callTool('gog_classroom_announcements_create', { courseId: 'c1', text: 'Quiz Friday' }));
@@ -264,6 +270,8 @@ describe('calendar', () => {
264
270
  });
265
271
 
266
272
  it('refuses a client that cannot be prompted, pointing at a guest-free create', async () => {
273
+
274
+ process.env.MCP_CONFIRM_MODE = 'refuse';
267
275
  const harness = await unprompted(registerCalendarTools);
268
276
  const r = json(await harness.callTool('gog_calendar_create', { calendarId: 'primary', summary: 'Sync', from: 'a', to: 'b', attendees: 'alice@example.com' }));
269
277
  expect(r.note).toMatch(/without attendees/);
@@ -271,7 +279,7 @@ describe('calendar', () => {
271
279
  });
272
280
 
273
281
  it('token fallback: previews, then creates on phase 2', async () => {
274
- process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
282
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
275
283
  stubReads({});
276
284
  const harness = await unprompted(registerCalendarTools);
277
285
  const args = { calendarId: 'primary', summary: 'Sync', from: 'a', to: 'b', attendees: 'alice@example.com' };
@@ -335,7 +343,7 @@ describe('calendar', () => {
335
343
  });
336
344
 
337
345
  it('token fallback: an event edited elsewhere between the phases (etag rotated) is DRAFT_CHANGED', async () => {
338
- process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
346
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
339
347
  stubReads({ 'calendar event': EVENT() });
340
348
  const harness = await unprompted(registerCalendarTools);
341
349
  const args = { calendarId: 'primary', eventId: 'e1', summary: 'Budget review (moved)' };
@@ -343,7 +351,7 @@ describe('calendar', () => {
343
351
  expect(p1.status).toBe('confirmation-required');
344
352
  stubReads({ 'calendar event': EVENT({ etag: '"v2"' }) });
345
353
  expect(json(await harness.callTool('gog_calendar_update', { ...args, confirmToken: p1.confirmToken })))
346
- .toMatchObject({ error: 'DRAFT_CHANGED', reason: 'message-id-rotated' });
354
+ .toMatchObject({ error: 'DRAFT_CHANGED', reason: 'revision-changed' });
347
355
  expect(callsTo('calendar', 'update')).toHaveLength(0);
348
356
  });
349
357
  });
@@ -358,6 +366,8 @@ describe('calendar', () => {
358
366
  });
359
367
 
360
368
  it('refuses a client that cannot be prompted', async () => {
369
+
370
+ process.env.MCP_CONFIRM_MODE = 'refuse';
361
371
  stubReads({ 'calendar event': EVENT() });
362
372
  const harness = await unprompted(registerCalendarTools);
363
373
  const r = json(await harness.callTool('gog_calendar_respond', { calendarId: 'primary', eventId: 'e1', status: 'accepted' }));
@@ -372,7 +382,7 @@ describe('calendar', () => {
372
382
  });
373
383
 
374
384
  it('token fallback: phase 2 records the response', async () => {
375
- process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
385
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
376
386
  stubReads({ 'calendar event': EVENT() });
377
387
  const harness = await unprompted(registerCalendarTools);
378
388
  const args = { calendarId: 'primary', eventId: 'e1', status: 'tentative' as const };
@@ -23,9 +23,9 @@ let fileRoot: string;
23
23
  beforeEach(() => {
24
24
  vi.clearAllMocks();
25
25
  process.env = { ...ORIGINAL_ENV };
26
- delete process.env.GOG_SEND_CONFIRM_FALLBACK;
27
- delete process.env.GOG_CONFIRM_TTL_SECONDS;
28
- delete process.env.GOG_CONFIRM_SECRET;
26
+ delete process.env.MCP_CONFIRM_MODE;
27
+ delete process.env.MCP_CONFIRM_TTL_SECONDS;
28
+ delete process.env.MCP_CONFIRM_SECRET;
29
29
  process.env.GOG_ACCOUNT = 'me@example.com';
30
30
  fileRoot = mkdtempSync(join(tmpdir(), 'gct-roots-'));
31
31
  process.env.GOG_FILE_ROOTS = fileRoot;
@@ -58,24 +58,26 @@ const sendCalls = () => vi.mocked(runner.run).mock.calls.filter(([args]) => args
58
58
 
59
59
  describe('gog_gmail_send — token fallback', () => {
60
60
  it('elicitation supported: unchanged — prompts and sends, confirmToken or not', async () => {
61
- process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
61
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
62
62
  vi.mocked(runner.run).mockResolvedValue(SENT);
63
63
  const harness = await withElicitation();
64
- const result = await harness.callTool('gog_gmail_send', { ...SEND_ARGS, confirmToken: 'gct1.not.real' });
64
+ const result = await harness.callTool('gog_gmail_send', { ...SEND_ARGS, confirmToken: 'not-a-real-token' });
65
65
  expect(result.content[0]!.text).toBe(SENT);
66
66
  expect(sendCalls()).toHaveLength(1);
67
67
  });
68
68
 
69
- it('unsupported + env unset: refuses, with the new hint', async () => {
69
+ it('unsupported + MCP_CONFIRM_MODE=refuse: refuses, naming the switch', async () => {
70
+
71
+ process.env.MCP_CONFIRM_MODE = 'refuse';
70
72
  const harness = await noElicitation();
71
73
  const body = json(await harness.callTool('gog_gmail_send', SEND_ARGS));
72
74
  expect(body).toMatchObject({ confirmed: false, dispatched: false, reason: 'confirmation-unsupported' });
73
- expect(body.note).toContain('set GOG_SEND_CONFIRM_FALLBACK=token to enable two-step confirmation');
75
+ expect(body.note).toContain('Set MCP_CONFIRM_MODE=ask-user');
74
76
  expect(runner.run).not.toHaveBeenCalled();
75
77
  });
76
78
 
77
- describe('unsupported + GOG_SEND_CONFIRM_FALLBACK=token', () => {
78
- beforeEach(() => { process.env.GOG_SEND_CONFIRM_FALLBACK = 'token'; });
79
+ describe('unsupported + MCP_CONFIRM_MODE=ask-user', () => {
80
+ beforeEach(() => { process.env.MCP_CONFIRM_MODE = 'ask-user'; });
79
81
 
80
82
  it('phase 1 previews everything (Bcc included, full body) and sends nothing', async () => {
81
83
  const long = `${'x'.repeat(5000)}END`;
@@ -101,7 +103,7 @@ describe('gog_gmail_send — token fallback', () => {
101
103
  inReplyTo: 'm0',
102
104
  quotesOriginal: false,
103
105
  });
104
- expect(body.confirmToken).toMatch(/^gct1\./);
106
+ expect(body.confirmToken).toMatch(/^mcpu\.token\.v1\./);
105
107
  expect(body.instruction).toMatch(/verbatim/);
106
108
  expect(runner.run).not.toHaveBeenCalled();
107
109
  });
@@ -185,7 +187,7 @@ describe.each([
185
187
  });
186
188
  const replyCalls = () => vi.mocked(runner.run).mock.calls.filter(([args]) => args[1] === subcommand);
187
189
 
188
- beforeEach(() => { process.env.GOG_SEND_CONFIRM_FALLBACK = 'token'; });
190
+ beforeEach(() => { process.env.MCP_CONFIRM_MODE = 'ask-user'; });
189
191
 
190
192
  it('phase 1 previews the resolved recipients, Bcc, full body and In-Reply-To; nothing sent', async () => {
191
193
  vi.mocked(runner.run).mockResolvedValue(metadata());