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,17 +1,14 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import type { CallToolResult, InputRequiredResult, ServerContext } from '@modelcontextprotocol/server';
4
- import { callerAcceptsFormElicitation, readEnvVar, requireConfirmation, textResult } from '@chrischall/mcp-utils';
5
- import { z } from 'zod';
6
4
  import {
7
- confirmTokenTtlSeconds,
8
- hashSendPayload,
9
- issueConfirmToken,
10
- sendConfirmFallbackEnabled,
11
- verifyConfirmToken,
12
- type ConfirmBinding,
13
- type ConfirmTokenError,
14
- } from './send-confirm-token.js';
5
+ CONFIRM_TOKEN_INSTRUCTION,
6
+ confirmationFromEnv,
7
+ readEnvVar,
8
+ requireConfirmationWithFallback,
9
+ type ConfirmSubject,
10
+ } from '@chrischall/mcp-utils';
11
+ import { confirmSpentStore } from './send-confirm-token.js';
15
12
 
16
13
  // ============================================================================
17
14
  // THE DISPATCH RAIL, service-neutral: every tool that reaches another person
@@ -20,9 +17,11 @@ import {
20
17
  //
21
18
  // Elicitation is primary: the host shows the preview and the model never holds
22
19
  // the approval. On a client that declares no elicitation (claude.ai, measured),
23
- // the call is refused — unless the call site supplies a `DispatchTokenFallback`
24
- // and the server opted in with GOG_SEND_CONFIRM_FALLBACK=token, in which case
25
- // the two-phase preview + confirmToken flow (send-confirm-token.ts) runs instead.
20
+ // the call site's `DispatchTokenFallback` runs the two-phase preview +
21
+ // confirmToken flow instead (mcp-utils' confirmationFromEnv +
22
+ // requireConfirmationWithFallback), as the fleet's MCP_CONFIRM_MODE says:
23
+ // ask-user (default), auto, or refuse. A call site with no fallback (the
24
+ // forwarding filter) is always refused on such a client.
26
25
  // ============================================================================
27
26
 
28
27
  // Bound on the body text shown in a confirmation prompt. Enough to read what is
@@ -100,39 +99,23 @@ export const CONFIRM_SEND_INSTRUCTION =
100
99
  + 'Then call again with confirmToken.';
101
100
 
102
101
  /** The same instruction for a dispatch that is not mail (a share, an invitation, a post). */
103
- export const CONFIRM_ACTION_INSTRUCTION =
104
- 'Show this preview to the user verbatim and proceed only after they explicitly approve in chat. '
105
- + 'Then call again with confirmToken.';
106
-
107
- const FALLBACK_HINT = 'Or set GOG_SEND_CONFIRM_FALLBACK=token to enable two-step confirmation.';
102
+ export const CONFIRM_ACTION_INSTRUCTION = CONFIRM_TOKEN_INSTRUCTION;
108
103
 
109
104
  /** Appended to each gated tool's description. */
110
105
  export const CONFIRM_FALLBACK_DESCRIPTION =
111
- ' If the client cannot show that prompt (no MCP elicitation, e.g. claude.ai) and the server sets '
112
- + 'GOG_SEND_CONFIRM_FALLBACK=token, a two-step flow applies instead: call WITHOUT confirmToken and nothing is '
113
- + 'sent or changed — the result has status "confirmation-required", the full preview and a confirmToken. Show that preview '
114
- + 'to the user verbatim; only after they explicitly approve it in chat, call again with the SAME arguments plus '
115
- + 'confirmToken. The tool re-reads what it would act on and refuses (DRAFT_CHANGED, with a fresh preview and token) '
116
- + 'if it changed; TOKEN_EXPIRED / TOKEN_REUSED / TOKEN_INVALID also do nothing.';
106
+ ' If the client cannot show that prompt (no MCP elicitation, e.g. claude.ai), a two-step flow applies instead: call '
107
+ + 'WITHOUT confirmToken and nothing is sent or changed — the result has status "confirmation-required", the full '
108
+ + 'preview and a confirmToken. Follow its instruction (by default: show the preview to the user verbatim and only '
109
+ + 'after they explicitly approve it in chat, call again with the SAME arguments plus confirmToken). The tool re-reads '
110
+ + 'what it would act on and refuses (DRAFT_CHANGED, with a fresh preview and token) if it changed; TOKEN_EXPIRED / '
111
+ + 'TOKEN_REUSED / TOKEN_INVALID also do nothing. A server set to MCP_CONFIRM_MODE=refuse refuses instead.';
117
112
 
118
- export const confirmTokenParam = z.string().optional().describe(
119
- 'ONLY for the two-step fallback (client without MCP elicitation, server with GOG_SEND_CONFIRM_FALLBACK=token). '
120
- + 'The confirmToken from this same tool\'s phase-1 "confirmation-required" response, passed back ONLY after the user '
121
- + 'has seen that preview and explicitly approved it in chat — never on the first call, never invented, never '
122
- + 'reused. Call again with the same arguments. Ignored when the client supports elicitation.',
123
- );
113
+ // The schema input every gated tool adds: mcp-utils' own, so its wording and
114
+ // the helper that reads it cannot drift apart.
115
+ export { confirmTokenParam } from '@chrischall/mcp-utils';
124
116
 
125
117
  /** What the fallback binds a token to — recomputed from a fresh read on every call. */
126
- export interface TokenSubject {
127
- /** The draftId / messageId / fileId / eventId / query the dispatch acts on. */
128
- target: string;
129
- /** A version that rotates on edit: a draft's messageId, an event's etag. */
130
- revision?: string;
131
- /** Canonical send payload; its SHA-256 is bound into the token. */
132
- payload: unknown;
133
- /** The complete preview shown to the user. */
134
- preview: Record<string, unknown>;
135
- }
118
+ export type TokenSubject = ConfirmSubject;
136
119
 
137
120
  /**
138
121
  * Opt-in second rail for a client that cannot be prompted. `subject` is only
@@ -149,66 +132,6 @@ export interface DispatchTokenFallback {
149
132
  subject: () => TokenSubject | CallToolResult | Promise<TokenSubject | CallToolResult>;
150
133
  }
151
134
 
152
- const TOKEN_ERROR_NOTE: Record<Exclude<ConfirmTokenError, 'DRAFT_CHANGED'>, string> = {
153
- TOKEN_EXPIRED: 'Nothing was sent or changed: the confirmToken expired. Call again WITHOUT confirmToken for a fresh preview, '
154
- + 'and ask the user to approve it again.',
155
- TOKEN_REUSED: 'Nothing was sent or changed by this call: this confirmToken was already used, and one approval acts once. '
156
- + 'If doing it again is really intended, call again WITHOUT confirmToken and get a new approval.',
157
- TOKEN_INVALID: 'Nothing was sent or changed: this confirmToken was not issued by this server for this tool, account and '
158
- + 'target (or the server has restarted since). Call again WITHOUT confirmToken for a fresh preview and approval.',
159
- };
160
-
161
- const DRAFT_CHANGED_NOTE = {
162
- 'message-id-rotated': 'Nothing was sent or changed: the target was edited since the user approved it (a draft\'s '
163
- + 'messageId or an event\'s version rotated), so what would happen is not what they saw.',
164
- 'payload-changed': 'Nothing was sent or changed: what would happen no longer matches what the user approved.',
165
- } as const;
166
-
167
- function isToolResult(value: TokenSubject | CallToolResult): value is CallToolResult {
168
- return Array.isArray((value as CallToolResult).content);
169
- }
170
-
171
- function rejection(data: Record<string, unknown>): CallToolResult {
172
- return { ...textResult({ status: 'confirmation-rejected', confirmed: false, dispatched: false, ...data }), isError: true };
173
- }
174
-
175
- async function tokenConfirmation(op: string, fallback: DispatchTokenFallback): Promise<CallToolResult | undefined> {
176
- const subject = await fallback.subject();
177
- if (isToolResult(subject)) return subject;
178
- const binding: ConfirmBinding = {
179
- tool: fallback.tool,
180
- account: fallback.account ?? readEnvVar('GOG_ACCOUNT') ?? '',
181
- target: subject.target,
182
- ...(subject.revision === undefined ? {} : { revision: subject.revision }),
183
- payloadHash: hashSendPayload(subject.payload),
184
- };
185
- const phaseOne = () => {
186
- const { token, expiresAt } = issueConfirmToken(binding);
187
- return {
188
- action: op,
189
- preview: subject.preview,
190
- confirmToken: token,
191
- expiresAt,
192
- ttlSeconds: confirmTokenTtlSeconds(),
193
- instruction: fallback.instruction ?? CONFIRM_ACTION_INSTRUCTION,
194
- };
195
- };
196
- if (!fallback.confirmToken) {
197
- return textResult({ status: 'confirmation-required', confirmed: false, dispatched: false, ...phaseOne() });
198
- }
199
- const verdict = verifyConfirmToken(fallback.confirmToken, binding);
200
- if (verdict.ok) return undefined;
201
- if (verdict.error === 'DRAFT_CHANGED') {
202
- return rejection({
203
- error: 'DRAFT_CHANGED',
204
- reason: verdict.reason,
205
- note: `${DRAFT_CHANGED_NOTE[verdict.reason!]} The current preview and a fresh confirmToken are below.`,
206
- ...phaseOne(),
207
- });
208
- }
209
- return rejection({ error: verdict.error, action: op, note: TOKEN_ERROR_NOTE[verdict.error] });
210
- }
211
-
212
135
  /**
213
136
  * The refusal an escape hatch (`gog_<service>_run`, `gog_api_call`) gives for an
214
137
  * action a dedicated tool gates. Without it, the run tool is a way around the
@@ -242,29 +165,33 @@ export interface DispatchConfirmationOptions {
242
165
  * Ask the user before a dispatch. `undefined` means proceed; anything else is
243
166
  * the result to return unchanged.
244
167
  *
245
- * Elicitation stays the primary path and is untouched. Only when the caller
246
- * declares it cannot be prompted AND a `fallback` is supplied AND
247
- * GOG_SEND_CONFIRM_FALLBACK=token does the two-phase token flow run instead of
248
- * the refusal; with the env unset, the refusal names that switch.
168
+ * Elicitation stays the primary path and is untouched. When the caller
169
+ * declares it cannot be prompted AND a `fallback` is supplied, MCP_CONFIRM_MODE
170
+ * decides: ask-user (default) or auto run the two-phase token flow; refuse
171
+ * refuses and names the switch. Without a fallback it is always refused.
249
172
  */
250
173
  export async function requireDispatchConfirmation(
251
174
  ctx: ServerContext,
252
175
  options: DispatchConfirmationOptions,
253
176
  ): Promise<InputRequiredResult | CallToolResult | undefined> {
254
177
  const { action, fallback } = options;
255
- if (fallback && callerAcceptsFormElicitation(ctx) === false && sendConfirmFallbackEnabled()) {
256
- return tokenConfirmation(action, fallback);
257
- }
258
- const note = fallback
259
- ? [options.unsupportedNote, FALLBACK_HINT].filter(Boolean).join(' ')
260
- : options.unsupportedNote;
261
- return requireConfirmation(ctx, {
178
+ const confirmation = {
262
179
  action,
263
180
  message: options.message,
264
181
  details: options.details,
265
182
  confirmationLabel: options.confirmationLabel,
266
- ...(note ? { unsupportedNote: note } : {}),
267
- });
183
+ ...(options.unsupportedNote ? { unsupportedNote: options.unsupportedNote } : {}),
184
+ };
185
+ if (!fallback) return requireConfirmationWithFallback(ctx, confirmation);
186
+ return requireConfirmationWithFallback(ctx, confirmationFromEnv({
187
+ ...confirmation,
188
+ tool: fallback.tool,
189
+ account: fallback.account ?? readEnvVar('GOG_ACCOUNT') ?? '',
190
+ confirmToken: fallback.confirmToken,
191
+ subject: fallback.subject,
192
+ instruction: fallback.instruction ?? CONFIRM_ACTION_INSTRUCTION,
193
+ spent: confirmSpentStore(),
194
+ }));
268
195
  }
269
196
 
270
197
  // The single place a CallToolResult's text is pulled back out, for the tools
@@ -32,11 +32,11 @@ export type { AttachmentDetail, DispatchTokenFallback, TokenSubject } from './di
32
32
  // user's accepted confirmation dispatches. The confirmation is never a tool
33
33
  // argument, so a model cannot bypass the user by setting a boolean itself.
34
34
  //
35
- // The one exception is OPT-IN: with GOG_SEND_CONFIRM_FALLBACK=token, a client
36
- // that cannot be prompted gets a two-phase preview + confirmToken instead of a
37
- // refusal (`DispatchTokenFallback`, send-confirm-token.ts). There the approval
38
- // IS a tool argument; see that file for exactly what the token does and does
39
- // not guarantee.
35
+ // The one exception: a client that cannot be prompted gets a two-phase preview
36
+ // + confirmToken instead of a refusal (`DispatchTokenFallback`; mcp-utils'
37
+ // confirmationFromEnv), unless the server sets MCP_CONFIRM_MODE=refuse. There
38
+ // the approval IS a tool argument; see mcp-utils' confirm-token module for
39
+ // exactly what the token does and does not guarantee.
40
40
  //
41
41
  // A CLIENT THAT CANNOT SHOW THAT PROMPT gets a sentence rather than a prompt
42
42
  // it will refuse to deliver (`unsupportedNote`, mcp-utils `requireConfirmation`).
@@ -123,10 +123,10 @@ const UNSUPPORTED_NOTE: Partial<Record<GmailDispatchOp, string>> = {
123
123
  /**
124
124
  * Apply the shared stateless confirmation flow with Gmail-specific copy.
125
125
  *
126
- * Elicitation stays the primary path and is untouched. Only when the caller
127
- * declares it cannot be prompted AND a `fallback` is supplied AND
128
- * GOG_SEND_CONFIRM_FALLBACK=token does the two-phase token flow run instead of
129
- * the refusal; with the env unset, the refusal names that switch.
126
+ * Elicitation stays the primary path and is untouched. When the caller
127
+ * declares it cannot be prompted AND a `fallback` is supplied, MCP_CONFIRM_MODE
128
+ * decides: ask-user (default) or auto run the two-phase token flow; refuse
129
+ * refuses and names the switch.
130
130
  */
131
131
  export async function requireGmailDispatchConfirmation(
132
132
  ctx: ServerContext,
@@ -1,167 +1,29 @@
1
- import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
- import { readEnvVar } from '@chrischall/mcp-utils';
1
+ import { createSpentTokenStore, type SpentTokenStore } from '@chrischall/mcp-utils';
3
2
 
4
3
  // ============================================================================
5
- // THE TOKEN FALLBACK for the Gmail dispatch rail (gmail-dispatch-guard.ts).
4
+ // THE TOKEN FALLBACK's gogcli-mcp half, which is now only its spent-token store.
5
+ // Everything else is the fleet's shared layer in @chrischall/mcp-utils
6
+ // (`confirmationFromEnv` + `requireConfirmationWithFallback`), configured by the
7
+ // same three variables as every other fleet server:
6
8
  //
7
- // A client with no MCP elicitation (claude.ai, measured) cannot be shown the
8
- // confirmation prompt, so every send is refused there. With
9
- // GOG_SEND_CONFIRM_FALLBACK=token the rail instead runs two phases: phase 1
10
- // returns the full preview plus a token and sends nothing; phase 2 presents the
11
- // token, the tool RE-READS what it would send, and dispatches only if that still
12
- // hashes to what the token was issued for.
9
+ // MCP_CONFIRM_MODE ask-user (default) | auto | refuse
10
+ // MCP_CONFIRM_TTL_SECONDS token lifetime, default 600
11
+ // MCP_CONFIRM_SECRET HMAC key; default random per process
13
12
  //
14
- // WHAT THIS DOES AND DOES NOT PROVE. Unlike elicitation, the approval here is a
15
- // tool argument, so the gate is the model honouring "show this to the user and
16
- // wait". The token cannot make the model ask; what it guarantees is that what
17
- // is sent matches what was previewed — a draft edited in a mail client (its
18
- // messageId rotates), a changed recipient or body, or a swapped attachment
19
- // between the two calls all refuse. Caller-supplied files are bound by content
20
- // hash; a stored draft's attachments by name and size, since Gmail's
21
- // attachment ids are unstable and any edit rotates the messageId anyway. One
22
- // approval acts once, for one tool, account and target, within the TTL — per
23
- // PROCESS: spent tokens live in memory, so with a shared GOG_CONFIRM_SECRET a
24
- // restart or a second instance would accept a spent token again until it
25
- // expires. That is why the mode is opt-in.
13
+ // WHAT THIS DOES AND DOES NOT PROVE: see mcp-utils' confirm-token module. The
14
+ // approval is a tool argument, so under ask-user the gate is the model honouring
15
+ // "show this to the user and wait"; the token guarantees what happens matches
16
+ // what was previewed, once, for one tool, account and target.
26
17
  // ============================================================================
27
18
 
28
- export type ConfirmTokenError = 'DRAFT_CHANGED' | 'TOKEN_EXPIRED' | 'TOKEN_REUSED' | 'TOKEN_INVALID';
19
+ const spent = createSpentTokenStore();
29
20
 
30
- /** What a token is bound to. Every field must match on phase 2. */
31
- export interface ConfirmBinding {
32
- tool: string;
33
- account: string;
34
- /** The draftId / messageId / query the dispatch acts on. */
35
- target: string;
36
- /** A version of the target that rotates on edit — a draft's messageId. */
37
- revision?: string;
38
- /** {@link hashSendPayload} of the canonical send payload. */
39
- payloadHash: string;
21
+ /** This server's spent-token store (one per process). */
22
+ export function confirmSpentStore(): SpentTokenStore {
23
+ return spent;
40
24
  }
41
25
 
42
- export type ConfirmTokenVerdict =
43
- | { ok: true }
44
- | { ok: false; error: ConfirmTokenError; reason?: 'payload-changed' | 'message-id-rotated' };
45
-
46
- export const CONFIRM_TOKEN_TTL_DEFAULT_SECONDS = 600;
47
-
48
- const PREFIX = 'gct1';
49
-
50
- type Claims = { t: string; a: string; g: string; r?: string; h: string; iat: number; exp: number; n: string };
51
-
52
- let secret: Buffer | undefined;
53
- // nonce → expiry (ms). Entries are dropped once they would have expired
54
- // anyway, so the set is bounded by the tokens spent within one TTL. In memory
55
- // only: see the header on GOG_CONFIRM_SECRET and restarts.
56
- const spent = new Map<string, number>();
57
-
58
- /** GOG_SEND_CONFIRM_FALLBACK=token turns the fallback on. Anything else is off. */
59
- export function sendConfirmFallbackEnabled(): boolean {
60
- return readEnvVar('GOG_SEND_CONFIRM_FALLBACK')?.trim().toLowerCase() === 'token';
61
- }
62
-
63
- export function confirmTokenTtlSeconds(): number {
64
- const raw = readEnvVar('GOG_CONFIRM_TTL_SECONDS')?.trim();
65
- if (raw && /^\d+$/.test(raw)) {
66
- const n = Number(raw);
67
- if (n > 0) return n;
68
- }
69
- return CONFIRM_TOKEN_TTL_DEFAULT_SECONDS;
70
- }
71
-
72
- // GOG_CONFIRM_SECRET ends in _SECRET, so runner.ts strips it from every gog
73
- // child — it never leaves this process.
74
- function hmacKey(): Buffer {
75
- if (!secret) {
76
- const configured = readEnvVar('GOG_CONFIRM_SECRET');
77
- secret = configured ? Buffer.from(configured, 'utf8') : randomBytes(32);
78
- }
79
- return secret;
80
- }
81
-
82
- /** Test seam: forget the secret (a "restart") and every spent token. */
26
+ /** Test seam: forget every spent token. */
83
27
  export function resetConfirmTokenState(): void {
84
- secret = undefined;
85
28
  spent.clear();
86
29
  }
87
-
88
- // Sorted keys, undefined dropped: the same payload re-read on phase 2 must
89
- // hash identically however its object was assembled.
90
- function canonicalize(value: unknown): unknown {
91
- if (Array.isArray(value)) return value.map(canonicalize);
92
- if (value && typeof value === 'object') {
93
- const out: Record<string, unknown> = {};
94
- for (const key of Object.keys(value as Record<string, unknown>).sort()) {
95
- const v = (value as Record<string, unknown>)[key];
96
- if (v !== undefined) out[key] = canonicalize(v);
97
- }
98
- return out;
99
- }
100
- return value;
101
- }
102
-
103
- /** SHA-256 (hex) of the canonical JSON of a send payload. */
104
- export function hashSendPayload(payload: unknown): string {
105
- return createHash('sha256').update(JSON.stringify(canonicalize(payload))).digest('hex');
106
- }
107
-
108
- function sign(body: string): string {
109
- return createHmac('sha256', hmacKey()).update(`${PREFIX}.${body}`).digest('base64url');
110
- }
111
-
112
- export function issueConfirmToken(binding: ConfirmBinding, now = Date.now()): { token: string; expiresAt: string } {
113
- const exp = now + confirmTokenTtlSeconds() * 1000;
114
- const claims: Claims = {
115
- t: binding.tool,
116
- a: binding.account,
117
- g: binding.target,
118
- ...(binding.revision === undefined ? {} : { r: binding.revision }),
119
- h: binding.payloadHash,
120
- iat: now,
121
- exp,
122
- n: randomBytes(16).toString('base64url'),
123
- };
124
- const body = Buffer.from(JSON.stringify(claims), 'utf8').toString('base64url');
125
- return { token: `${PREFIX}.${body}.${sign(body)}`, expiresAt: new Date(exp).toISOString() };
126
- }
127
-
128
- function signatureMatches(body: string, sig: string): boolean {
129
- const expected = Buffer.from(sign(body), 'utf8');
130
- const actual = Buffer.from(sig, 'utf8');
131
- return expected.length === actual.length && timingSafeEqual(expected, actual);
132
- }
133
-
134
- function parseClaims(body: string): Claims | undefined {
135
- try {
136
- return JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as Claims;
137
- } catch {
138
- return undefined;
139
- }
140
- }
141
-
142
- /**
143
- * Check a phase-2 token against the binding recomputed from a fresh re-read.
144
- * Only an `ok` verdict consumes the token; a DRAFT_CHANGED leaves it unspent,
145
- * because the approval it carries is still true of the content it names.
146
- */
147
- export function verifyConfirmToken(token: string, binding: ConfirmBinding, now = Date.now()): ConfirmTokenVerdict {
148
- for (const [nonce, exp] of spent) if (exp < now) spent.delete(nonce);
149
-
150
- const parts = token.split('.');
151
- if (parts.length !== 3 || parts[0] !== PREFIX || !parts[1] || !parts[2]) return { ok: false, error: 'TOKEN_INVALID' };
152
- const [, body, sig] = parts as [string, string, string];
153
- if (!signatureMatches(body, sig)) return { ok: false, error: 'TOKEN_INVALID' };
154
- const claims = parseClaims(body);
155
- if (!claims) return { ok: false, error: 'TOKEN_INVALID' };
156
-
157
- if (claims.t !== binding.tool || claims.a !== binding.account || claims.g !== binding.target) {
158
- return { ok: false, error: 'TOKEN_INVALID' };
159
- }
160
- if (spent.has(claims.n)) return { ok: false, error: 'TOKEN_REUSED' };
161
- if (now > claims.exp) return { ok: false, error: 'TOKEN_EXPIRED' };
162
- if (claims.r !== binding.revision) return { ok: false, error: 'DRAFT_CHANGED', reason: 'message-id-rotated' };
163
- if (claims.h !== binding.payloadHash) return { ok: false, error: 'DRAFT_CHANGED', reason: 'payload-changed' };
164
-
165
- spent.set(claims.n, claims.exp);
166
- return { ok: true };
167
- }
@@ -298,7 +298,7 @@ describe('attachmentDetails', () => {
298
298
  });
299
299
 
300
300
  // ============================================================================
301
- // THE TOKEN FALLBACK (GOG_SEND_CONFIRM_FALLBACK=token). Elicitation stays the
301
+ // THE TOKEN FALLBACK (MCP_CONFIRM_MODE, default ask-user). Elicitation stays the
302
302
  // primary rail; this only replaces the REFUSAL a client that cannot be prompted
303
303
  // would otherwise get.
304
304
  // ============================================================================
@@ -308,9 +308,9 @@ describe('requireGmailDispatchConfirmation — token fallback', () => {
308
308
 
309
309
  beforeEach(() => {
310
310
  process.env = { ...ORIGINAL_ENV };
311
- delete process.env.GOG_SEND_CONFIRM_FALLBACK;
312
- delete process.env.GOG_CONFIRM_TTL_SECONDS;
313
- delete process.env.GOG_CONFIRM_SECRET;
311
+ delete process.env.MCP_CONFIRM_MODE;
312
+ delete process.env.MCP_CONFIRM_TTL_SECONDS;
313
+ delete process.env.MCP_CONFIRM_SECRET;
314
314
  process.env.GOG_ACCOUNT = 'me@example.com';
315
315
  resetConfirmTokenState();
316
316
  });
@@ -341,37 +341,56 @@ describe('requireGmailDispatchConfirmation — token fallback', () => {
341
341
  return parse(r);
342
342
  }
343
343
 
344
- it('with the env unset, keeps the refusal and names the switch', async () => {
344
+ it('with MCP_CONFIRM_MODE=refuse, keeps the refusal and names the switch', async () => {
345
+
346
+ process.env.MCP_CONFIRM_MODE = 'refuse';
345
347
  const fb = fallback();
346
348
  const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fb));
347
349
  expect(r.reason).toBe('confirmation-unsupported');
348
- expect(r.note).toContain('set GOG_SEND_CONFIRM_FALLBACK=token to enable two-step confirmation');
350
+ expect(r.note).toContain('Set MCP_CONFIRM_MODE=ask-user');
349
351
  expect(r.note).toContain('still saved');
350
352
  expect(fb.subject).not.toHaveBeenCalled();
351
353
  });
352
354
 
353
355
  it('names the switch even for an op with no other note (autoreply)', async () => {
356
+
357
+ process.env.MCP_CONFIRM_MODE = 'refuse';
354
358
  const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.autoreply', {}, fallback()));
355
- expect(r.note).toContain('GOG_SEND_CONFIRM_FALLBACK=token');
359
+ expect(r.note).toContain('MCP_CONFIRM_MODE=ask-user');
356
360
  });
357
361
 
358
362
  it('never offers the switch to a caller that passed no fallback (a forwarding filter)', async () => {
359
- process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
363
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
360
364
  const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.filter-forward', {}));
361
365
  expect(r.reason).toBe('confirmation-unsupported');
362
- expect(r.note).not.toContain('GOG_SEND_CONFIRM_FALLBACK');
366
+ expect(r.note).not.toContain('MCP_CONFIRM_MODE');
363
367
  });
364
368
 
365
369
  it('leaves elicitation untouched even with the env set and a token passed', async () => {
366
- process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
367
- const fb = fallback('gct1.anything.here');
370
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
371
+ const fb = fallback('not-a-real-token');
368
372
  expect(await requireGmailDispatchConfirmation(CAN_BE_ASKED, 'gmail.drafts-send', {}, fb))
369
373
  .toMatchObject({ resultType: 'input_required' });
370
374
  expect(fb.subject).not.toHaveBeenCalled();
371
375
  });
372
376
 
373
- describe('with GOG_SEND_CONFIRM_FALLBACK=token', () => {
374
- beforeEach(() => { process.env.GOG_SEND_CONFIRM_FALLBACK = 'token'; });
377
+ // The fleet default: with MCP_CONFIRM_MODE unset, a client that cannot be
378
+ // prompted gets the two-step flow rather than a refusal.
379
+ it('with MCP_CONFIRM_MODE unset, runs the two-step flow (ask-user is the default)', async () => {
380
+ const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback()));
381
+ expect(r).toMatchObject({ status: 'confirmation-required', instruction: CONFIRM_INSTRUCTION });
382
+ });
383
+
384
+ it('with MCP_CONFIRM_MODE=auto, tells the model it may proceed after reviewing the preview', async () => {
385
+ process.env.MCP_CONFIRM_MODE = 'auto';
386
+ const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback()));
387
+ expect(r.status).toBe('confirmation-required');
388
+ expect(r.instruction).toMatch(/MCP_CONFIRM_MODE=auto/);
389
+ expect(r.instruction).not.toBe(CONFIRM_INSTRUCTION);
390
+ });
391
+
392
+ describe('with MCP_CONFIRM_MODE=ask-user', () => {
393
+ beforeEach(() => { process.env.MCP_CONFIRM_MODE = 'ask-user'; });
375
394
 
376
395
  it('phase 1 returns the full preview, a token and the instruction, and does not proceed', async () => {
377
396
  const r = await phaseOne();
@@ -385,7 +404,7 @@ describe('requireGmailDispatchConfirmation — token fallback', () => {
385
404
  instruction: CONFIRM_INSTRUCTION,
386
405
  });
387
406
  expect(r.instruction).toBe('Show this preview to the user verbatim and send only after they explicitly approve in chat. Then call again with confirmToken.');
388
- expect(r.confirmToken).toMatch(/^gct1\./);
407
+ expect(r.confirmToken).toMatch(/^mcpu\.token\.v1\./);
389
408
  expect(typeof r.expiresAt).toBe('string');
390
409
  });
391
410
 
@@ -400,8 +419,8 @@ describe('requireGmailDispatchConfirmation — token fallback', () => {
400
419
  const r = await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken, rotated));
401
420
  expect((r as CallToolResult).isError).toBe(true);
402
421
  const body = parse(r);
403
- expect(body).toMatchObject({ status: 'confirmation-rejected', error: 'DRAFT_CHANGED', reason: 'message-id-rotated', dispatched: false, instruction: CONFIRM_INSTRUCTION });
404
- expect(body.note).toMatch(/messageId/);
422
+ expect(body).toMatchObject({ status: 'confirmation-rejected', error: 'DRAFT_CHANGED', reason: 'revision-changed', dispatched: false, instruction: CONFIRM_INSTRUCTION });
423
+ expect(body.note).toMatch(/edited since/);
405
424
  expect(body.confirmToken).not.toBe(confirmToken);
406
425
  expect(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(body.confirmToken, rotated))).toBeUndefined();
407
426
  });
@@ -422,7 +441,7 @@ describe('requireGmailDispatchConfirmation — token fallback', () => {
422
441
  });
423
442
 
424
443
  it('TOKEN_EXPIRED after the TTL', async () => {
425
- process.env.GOG_CONFIRM_TTL_SECONDS = '60';
444
+ process.env.MCP_CONFIRM_TTL_SECONDS = '60';
426
445
  vi.useFakeTimers();
427
446
  vi.setSystemTime(new Date('2026-09-24T10:00:00Z'));
428
447
  const { confirmToken } = await phaseOne();