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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +292 -200
- package/dist/lib.js +258 -166
- package/manifest.json +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/dispatch-confirmation.ts +40 -113
- package/src/gmail-dispatch-guard.ts +9 -9
- package/src/send-confirm-token.ts +17 -155
- package/tests/gmail-dispatch-guard.test.ts +36 -17
- package/tests/send-confirm-token.test.ts +17 -197
- package/tests/tools/dispatch-gates.test.ts +22 -12
- package/tests/tools/gmail-confirm-token.test.ts +13 -11
|
@@ -1,200 +1,20 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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.
|
|
28
|
-
delete process.env.
|
|
29
|
-
delete process.env.
|
|
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('
|
|
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.
|
|
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('
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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: '
|
|
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.
|
|
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.
|
|
27
|
-
delete process.env.
|
|
28
|
-
delete process.env.
|
|
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.
|
|
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: '
|
|
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 +
|
|
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('
|
|
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 +
|
|
78
|
-
beforeEach(() => { process.env.
|
|
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(/^
|
|
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.
|
|
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());
|