gogcli-mcp 4.3.0 → 4.4.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 +647 -67
- package/dist/lib.js +654 -67
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/dispatch-confirmation.ts +277 -0
- package/src/gmail-dispatch-guard.ts +36 -36
- package/src/lib.ts +9 -0
- package/src/send-confirm-token.ts +167 -0
- package/src/tools/api.ts +24 -3
- package/src/tools/calendar.ts +168 -7
- package/src/tools/chat.ts +78 -5
- package/src/tools/classroom.ts +83 -3
- package/src/tools/drive.ts +55 -3
- package/src/tools/gmail.ts +111 -20
- package/tests/gmail-dispatch-guard.test.ts +227 -14
- package/tests/send-confirm-token.test.ts +200 -0
- package/tests/tools/calendar.test.ts +4 -1
- package/tests/tools/chat.test.ts +4 -1
- package/tests/tools/classroom.test.ts +4 -1
- package/tests/tools/dispatch-gates.test.ts +396 -0
- package/tests/tools/drive.test.ts +4 -1
- package/tests/tools/gmail-confirm-token.test.ts +274 -0
- package/tests/tools/run-vets.test.ts +131 -0
package/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"manifest_version": "0.3",
|
|
4
4
|
"name": "gogcli-mcp",
|
|
5
5
|
"display_name": "gogcli",
|
|
6
|
-
"version": "4.
|
|
6
|
+
"version": "4.4.0",
|
|
7
7
|
"description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Hall",
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/gogcli-mcp"
|
|
9
9
|
},
|
|
10
|
-
"version": "4.
|
|
10
|
+
"version": "4.4.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "4.
|
|
15
|
+
"version": "4.4.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
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
|
+
import {
|
|
7
|
+
confirmTokenTtlSeconds,
|
|
8
|
+
hashSendPayload,
|
|
9
|
+
issueConfirmToken,
|
|
10
|
+
sendConfirmFallbackEnabled,
|
|
11
|
+
verifyConfirmToken,
|
|
12
|
+
type ConfirmBinding,
|
|
13
|
+
type ConfirmTokenError,
|
|
14
|
+
} from './send-confirm-token.js';
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// THE DISPATCH RAIL, service-neutral: every tool that reaches another person
|
|
18
|
+
// (Gmail sends, Chat posts, guest-visible Calendar changes, Drive shares,
|
|
19
|
+
// Classroom announcements and invitations) asks the user first.
|
|
20
|
+
//
|
|
21
|
+
// Elicitation is primary: the host shows the preview and the model never holds
|
|
22
|
+
// 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.
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
// Bound on the body text shown in a confirmation prompt. Enough to read what is
|
|
29
|
+
// actually being sent (the point of SEC-5), small enough to keep the prompt a
|
|
30
|
+
// prompt rather than a copy of the message.
|
|
31
|
+
export const BODY_PREVIEW_MAX = 2048;
|
|
32
|
+
|
|
33
|
+
/** The first BODY_PREVIEW_MAX characters of a body, marked when cut. */
|
|
34
|
+
export function bodyPreview(text: string | undefined): string | undefined {
|
|
35
|
+
if (!text) return undefined;
|
|
36
|
+
if (text.length <= BODY_PREVIEW_MAX) return text;
|
|
37
|
+
return `${text.slice(0, BODY_PREVIEW_MAX)}… [${text.length - BODY_PREVIEW_MAX} more characters not shown]`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Every attachment a dispatch will carry: a server path in full (so the user
|
|
42
|
+
* sees WHICH file on the gog host is leaving), an inline one by its filename.
|
|
43
|
+
*/
|
|
44
|
+
export function attachmentNames(
|
|
45
|
+
paths: readonly string[] | undefined,
|
|
46
|
+
inline: ReadonlyArray<{ filename: string }> | undefined,
|
|
47
|
+
): string[] {
|
|
48
|
+
return [...(paths ?? []), ...(inline ?? []).map((a) => a.filename)];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One attachment as the fallback preview shows it and its hash binds it. */
|
|
52
|
+
export type AttachmentDetail = { name: string; size: number | null; sha256?: string };
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Name, byte size and content SHA-256 of every file a dispatch carries, for the
|
|
56
|
+
* token fallback's preview and payload hash. A server path is read (it is
|
|
57
|
+
* already confined to GOG_FILE_ROOTS) so a same-size swap between the phases is
|
|
58
|
+
* still a changed payload; an unreadable one reports `size: null` and gog will
|
|
59
|
+
* fail on it anyway.
|
|
60
|
+
*/
|
|
61
|
+
export function attachmentDetails(
|
|
62
|
+
paths: readonly string[] | undefined,
|
|
63
|
+
inline: ReadonlyArray<{ filename: string; contentBase64: string }> | undefined,
|
|
64
|
+
): AttachmentDetail[] {
|
|
65
|
+
const out: AttachmentDetail[] = [];
|
|
66
|
+
for (const path of paths ?? []) {
|
|
67
|
+
let bytes: Buffer | undefined;
|
|
68
|
+
try {
|
|
69
|
+
bytes = readFileSync(path);
|
|
70
|
+
} catch {
|
|
71
|
+
bytes = undefined;
|
|
72
|
+
}
|
|
73
|
+
out.push(bytes
|
|
74
|
+
? { name: path, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }
|
|
75
|
+
: { name: path, size: null });
|
|
76
|
+
}
|
|
77
|
+
for (const a of inline ?? []) {
|
|
78
|
+
out.push({
|
|
79
|
+
name: a.filename,
|
|
80
|
+
size: Buffer.from(a.contentBase64, 'base64').length,
|
|
81
|
+
sha256: createHash('sha256').update(a.contentBase64).digest('hex'),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Who a dispatch goes out as, for the fallback preview: an explicit alias, else the account. */
|
|
88
|
+
export function senderPreview(account: string | undefined, from?: string): string {
|
|
89
|
+
return from ?? account ?? readEnvVar('GOG_ACCOUNT') ?? "the gog account's default address";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Drop the fingerprint for display: the user needs a name and a size. */
|
|
93
|
+
export function attachmentPreview(details: readonly AttachmentDetail[]): Array<{ name: string; size: number | null }> {
|
|
94
|
+
return details.map(({ name, size }) => ({ name, size }));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The instruction a mail dispatch's phase 1 carries — verbatim from the spec that introduced the fallback. */
|
|
98
|
+
export const CONFIRM_SEND_INSTRUCTION =
|
|
99
|
+
'Show this preview to the user verbatim and send only after they explicitly approve in chat. '
|
|
100
|
+
+ 'Then call again with confirmToken.';
|
|
101
|
+
|
|
102
|
+
/** 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.';
|
|
108
|
+
|
|
109
|
+
/** Appended to each gated tool's description. */
|
|
110
|
+
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.';
|
|
117
|
+
|
|
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
|
+
);
|
|
124
|
+
|
|
125
|
+
/** 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
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Opt-in second rail for a client that cannot be prompted. `subject` is only
|
|
139
|
+
* called when the fallback actually runs, so a tool may do an extra read there
|
|
140
|
+
* without changing the elicitation path at all. It may return an error result
|
|
141
|
+
* (a failed read), which is passed back unchanged.
|
|
142
|
+
*/
|
|
143
|
+
export interface DispatchTokenFallback {
|
|
144
|
+
tool: string;
|
|
145
|
+
account?: string;
|
|
146
|
+
confirmToken?: string;
|
|
147
|
+
/** Phase 1's instruction to the model. Defaults to {@link CONFIRM_ACTION_INSTRUCTION}. */
|
|
148
|
+
instruction?: string;
|
|
149
|
+
subject: () => TokenSubject | CallToolResult | Promise<TokenSubject | CallToolResult>;
|
|
150
|
+
}
|
|
151
|
+
|
|
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
|
+
/**
|
|
213
|
+
* The refusal an escape hatch (`gog_<service>_run`, `gog_api_call`) gives for an
|
|
214
|
+
* action a dedicated tool gates. Without it, the run tool is a way around the
|
|
215
|
+
* rail: the model forwards the same subcommand and nobody is asked (#400).
|
|
216
|
+
*/
|
|
217
|
+
export function gatedElsewhere(what: string, via: string, does: string, tool: string): string {
|
|
218
|
+
return `${what} ${does} and is not available through ${via}. Use ${tool}, which asks the user to confirm.`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** True when any forwarded token is one of `words` (kong lets flags precede the command word). */
|
|
222
|
+
export function hasCommandWord(args: readonly string[], words: ReadonlySet<string>): string | undefined {
|
|
223
|
+
return args.find((a) => words.has(a.toLowerCase()));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface DispatchConfirmationOptions {
|
|
227
|
+
/** Stable id of the dispatch, echoed in every result (`gmail.send`, `drive.share`, …). */
|
|
228
|
+
action: string;
|
|
229
|
+
/** Heading of the elicitation prompt. */
|
|
230
|
+
message: string;
|
|
231
|
+
/** Label beside the prompt's confirmation checkbox. */
|
|
232
|
+
confirmationLabel: string;
|
|
233
|
+
/** What the elicitation prompt shows. */
|
|
234
|
+
details: Record<string, unknown>;
|
|
235
|
+
/** The way through on a client that cannot be prompted, when there is one. */
|
|
236
|
+
unsupportedNote?: string;
|
|
237
|
+
/** Opt-in second rail; omit it and a client that cannot be prompted is always refused. */
|
|
238
|
+
fallback?: DispatchTokenFallback;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Ask the user before a dispatch. `undefined` means proceed; anything else is
|
|
243
|
+
* the result to return unchanged.
|
|
244
|
+
*
|
|
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.
|
|
249
|
+
*/
|
|
250
|
+
export async function requireDispatchConfirmation(
|
|
251
|
+
ctx: ServerContext,
|
|
252
|
+
options: DispatchConfirmationOptions,
|
|
253
|
+
): Promise<InputRequiredResult | CallToolResult | undefined> {
|
|
254
|
+
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, {
|
|
262
|
+
action,
|
|
263
|
+
message: options.message,
|
|
264
|
+
details: options.details,
|
|
265
|
+
confirmationLabel: options.confirmationLabel,
|
|
266
|
+
...(note ? { unsupportedNote: note } : {}),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The single place a CallToolResult's text is pulled back out, for the tools
|
|
271
|
+
// here that need to read gog's own JSON before deciding what to preview or
|
|
272
|
+
// log. Mirrors the shape every runOrDiagnose result actually returns
|
|
273
|
+
// (content[0].text); never throws on an unexpected shape.
|
|
274
|
+
export function resultText(result: CallToolResult): string {
|
|
275
|
+
const first = result.content[0];
|
|
276
|
+
return first && first.type === 'text' && typeof first.text === 'string' ? first.text : '{}';
|
|
277
|
+
}
|
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import type { CallToolResult, InputRequiredResult, ServerContext } from '@modelcontextprotocol/server';
|
|
2
|
-
import { readEnvVar
|
|
2
|
+
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
3
|
+
import { CONFIRM_SEND_INSTRUCTION, requireDispatchConfirmation, type DispatchTokenFallback } from './dispatch-confirmation.js';
|
|
4
|
+
|
|
5
|
+
// The generic pieces moved to dispatch-confirmation.ts when Chat, Calendar,
|
|
6
|
+
// Drive and Classroom joined the rail; re-exported so Gmail callers keep one import.
|
|
7
|
+
export {
|
|
8
|
+
attachmentDetails,
|
|
9
|
+
attachmentNames,
|
|
10
|
+
BODY_PREVIEW_MAX,
|
|
11
|
+
bodyPreview,
|
|
12
|
+
attachmentPreview,
|
|
13
|
+
CONFIRM_FALLBACK_DESCRIPTION,
|
|
14
|
+
CONFIRM_SEND_INSTRUCTION as CONFIRM_INSTRUCTION,
|
|
15
|
+
confirmTokenParam,
|
|
16
|
+
resultText,
|
|
17
|
+
senderPreview,
|
|
18
|
+
} from './dispatch-confirmation.js';
|
|
19
|
+
export type { AttachmentDetail, DispatchTokenFallback, TokenSubject } from './dispatch-confirmation.js';
|
|
3
20
|
|
|
4
21
|
// ============================================================================
|
|
5
22
|
// THE SAFETY RAIL. gog_gmail_reply / reply_all / send / forward / autoreply /
|
|
@@ -15,6 +32,12 @@ import { readEnvVar, requireConfirmation } from '@chrischall/mcp-utils';
|
|
|
15
32
|
// user's accepted confirmation dispatches. The confirmation is never a tool
|
|
16
33
|
// argument, so a model cannot bypass the user by setting a boolean itself.
|
|
17
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.
|
|
40
|
+
//
|
|
18
41
|
// A CLIENT THAT CANNOT SHOW THAT PROMPT gets a sentence rather than a prompt
|
|
19
42
|
// it will refuse to deliver (`unsupportedNote`, mcp-utils `requireConfirmation`).
|
|
20
43
|
// Measured on the mcp-host fleet 2026-09-20: claude.ai declares no MCP
|
|
@@ -97,41 +120,26 @@ const UNSUPPORTED_NOTE: Partial<Record<GmailDispatchOp, string>> = {
|
|
|
97
120
|
'gmail.drafts-send': 'The draft is still saved: ask the user to review it and send it from Gmail.',
|
|
98
121
|
};
|
|
99
122
|
|
|
100
|
-
// Bound on the body text shown in a confirmation prompt. Enough to read what is
|
|
101
|
-
// actually being sent (the point of SEC-5), small enough to keep the prompt a
|
|
102
|
-
// prompt rather than a copy of the message.
|
|
103
|
-
export const BODY_PREVIEW_MAX = 2048;
|
|
104
|
-
|
|
105
|
-
/** The first BODY_PREVIEW_MAX characters of a body, marked when cut. */
|
|
106
|
-
export function bodyPreview(text: string | undefined): string | undefined {
|
|
107
|
-
if (!text) return undefined;
|
|
108
|
-
if (text.length <= BODY_PREVIEW_MAX) return text;
|
|
109
|
-
return `${text.slice(0, BODY_PREVIEW_MAX)}… [${text.length - BODY_PREVIEW_MAX} more characters not shown]`;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
123
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
124
|
+
* Apply the shared stateless confirmation flow with Gmail-specific copy.
|
|
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.
|
|
115
130
|
*/
|
|
116
|
-
export function
|
|
117
|
-
paths: readonly string[] | undefined,
|
|
118
|
-
inline: ReadonlyArray<{ filename: string }> | undefined,
|
|
119
|
-
): string[] {
|
|
120
|
-
return [...(paths ?? []), ...(inline ?? []).map((a) => a.filename)];
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Apply the shared stateless confirmation flow with Gmail-specific copy. */
|
|
124
|
-
export function requireGmailDispatchConfirmation(
|
|
131
|
+
export async function requireGmailDispatchConfirmation(
|
|
125
132
|
ctx: ServerContext,
|
|
126
133
|
op: GmailDispatchOp,
|
|
127
134
|
details: Record<string, unknown>,
|
|
128
|
-
|
|
135
|
+
fallback?: DispatchTokenFallback,
|
|
136
|
+
): Promise<InputRequiredResult | CallToolResult | undefined> {
|
|
129
137
|
const twin = STAGING_TWIN[op];
|
|
130
138
|
const note = twin
|
|
131
139
|
? `Stage it with ${twin} instead; the user can review the draft and send it from Gmail `
|
|
132
140
|
+ '(gog_gmail_drafts_send also asks for confirmation).'
|
|
133
141
|
: UNSUPPORTED_NOTE[op];
|
|
134
|
-
return
|
|
142
|
+
return requireDispatchConfirmation(ctx, {
|
|
135
143
|
action: op,
|
|
136
144
|
message: op === 'gmail.filter-forward'
|
|
137
145
|
? 'Review and confirm this mail-forwarding filter:'
|
|
@@ -140,7 +148,8 @@ export function requireGmailDispatchConfirmation(
|
|
|
140
148
|
confirmationLabel: op === 'gmail.filter-forward'
|
|
141
149
|
? 'Confirm that matching mail should be forwarded automatically from now on.'
|
|
142
150
|
: 'Confirm that this email should be sent now.',
|
|
143
|
-
|
|
151
|
+
unsupportedNote: note,
|
|
152
|
+
...(fallback ? { fallback: { instruction: CONFIRM_SEND_INSTRUCTION, ...fallback } } : {}),
|
|
144
153
|
});
|
|
145
154
|
}
|
|
146
155
|
|
|
@@ -210,12 +219,3 @@ export function logGmailDispatch(tool: string, recipients: string[], account?: s
|
|
|
210
219
|
};
|
|
211
220
|
process.stderr.write(`${JSON.stringify(event)}\n`);
|
|
212
221
|
}
|
|
213
|
-
|
|
214
|
-
// The single place a CallToolResult's text is pulled back out, for the tools
|
|
215
|
-
// here that need to read gog's own JSON before deciding what to preview or
|
|
216
|
-
// log. Mirrors the shape every runOrDiagnose result actually returns
|
|
217
|
-
// (content[0].text); never throws on an unexpected shape.
|
|
218
|
-
export function resultText(result: CallToolResult): string {
|
|
219
|
-
const first = result.content[0];
|
|
220
|
-
return first && first.type === 'text' && typeof first.text === 'string' ? first.text : '{}';
|
|
221
|
-
}
|
package/src/lib.ts
CHANGED
|
@@ -26,13 +26,22 @@ export type { ReplyFlags } from './tools/gmail.js';
|
|
|
26
26
|
// gmail sub-package's send-side forward/autoreply tools reuse these directly
|
|
27
27
|
// rather than re-declaring the gate; the draft-side twins never import them.
|
|
28
28
|
export {
|
|
29
|
+
attachmentDetails,
|
|
29
30
|
attachmentNames,
|
|
31
|
+
attachmentPreview,
|
|
30
32
|
bodyPreview,
|
|
33
|
+
CONFIRM_FALLBACK_DESCRIPTION,
|
|
34
|
+
confirmTokenParam,
|
|
31
35
|
extractEmails,
|
|
32
36
|
logGmailDispatch,
|
|
33
37
|
requireGmailDispatchConfirmation,
|
|
34
38
|
resultText,
|
|
39
|
+
senderPreview,
|
|
35
40
|
} from './gmail-dispatch-guard.js';
|
|
41
|
+
// The service-neutral rail for every other tool that reaches another person.
|
|
42
|
+
export { requireDispatchConfirmation } from './dispatch-confirmation.js';
|
|
43
|
+
export { readCourse } from './tools/classroom.js';
|
|
44
|
+
export type { AttachmentDetail, DispatchTokenFallback, TokenSubject } from './gmail-dispatch-guard.js';
|
|
36
45
|
export { run, runBinary, isGogFileArg, MIN_GOG_VERSION } from './runner.js';
|
|
37
46
|
// Sub-package tools that read gog JSON through bare `run()` (rather than the
|
|
38
47
|
// `runOrDiagnose` seam) must still apply this, or their timestamps skip the
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// THE TOKEN FALLBACK for the Gmail dispatch rail (gmail-dispatch-guard.ts).
|
|
6
|
+
//
|
|
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.
|
|
13
|
+
//
|
|
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.
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
export type ConfirmTokenError = 'DRAFT_CHANGED' | 'TOKEN_EXPIRED' | 'TOKEN_REUSED' | 'TOKEN_INVALID';
|
|
29
|
+
|
|
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;
|
|
40
|
+
}
|
|
41
|
+
|
|
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. */
|
|
83
|
+
export function resetConfirmTokenState(): void {
|
|
84
|
+
secret = undefined;
|
|
85
|
+
spent.clear();
|
|
86
|
+
}
|
|
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
|
+
}
|
package/src/tools/api.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { assertSafeForwardedArgs } from '../arg-guard.js';
|
|
|
6
6
|
import { confineAtFile } from '../file-roots.js';
|
|
7
7
|
import { pos } from '../argv.js';
|
|
8
8
|
import type { GogArg } from '../runner.js';
|
|
9
|
+
import { gatedElsewhere } from '../dispatch-confirmation.js';
|
|
9
10
|
|
|
10
11
|
// Gmail methods gog_api_call refuses outright (audit SEC-2). `allowWrite` is a
|
|
11
12
|
// boolean the MODEL sets, so it cannot stand in for the user's confirmation of
|
|
@@ -15,12 +16,32 @@ import type { GogArg } from '../runner.js';
|
|
|
15
16
|
// the same reason. --gmail-no-send is pinned on as a runtime backstop too.
|
|
16
17
|
const GMAIL_API_BLOCKED = /(?:\.send$|forwarding|filters\.create|filters\.update|delegates\.create)/i;
|
|
17
18
|
|
|
19
|
+
// The same reasoning for every other action the dispatch rail gates (#400):
|
|
20
|
+
// the dedicated tool asks the user, so the raw API method must not be a way
|
|
21
|
+
// around it. Anchored on a `.` or the start so a Discovery id with the API
|
|
22
|
+
// prefix (`chat.spaces.messages.create`) matches too.
|
|
23
|
+
const DISPATCH_API_BLOCKED: Record<string, Array<{ method: RegExp; does: string; tool: string }>> = {
|
|
24
|
+
chat: [{ method: /(?:^|\.)spaces\.messages\.create$/i, does: 'posts a Chat message', tool: 'gog_chat_messages_send / gog_chat_dm_send' }],
|
|
25
|
+
drive: [{ method: /(?:^|\.)permissions\.(?:create|update)$/i, does: 'grants access to a file', tool: 'gog_drive_share' }],
|
|
26
|
+
classroom: [
|
|
27
|
+
{ method: /(?:^|\.)courses\.announcements\.create$/i, does: 'posts to a class', tool: 'gog_classroom_announcements_create' },
|
|
28
|
+
{ method: /(?:^|\.)invitations\.create$/i, does: 'invites someone to a class', tool: 'gog_classroom_invitations_create' },
|
|
29
|
+
],
|
|
30
|
+
calendar: [
|
|
31
|
+
{ method: /(?:^|\.)events\.(?:insert|import|quickadd)$/i, does: 'can put an event on guests\' calendars', tool: 'gog_calendar_create' },
|
|
32
|
+
{ method: /(?:^|\.)events\.(?:update|patch)$/i, does: 'can change what guests see', tool: 'gog_calendar_update / gog_calendar_respond' },
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
|
|
18
36
|
export function refusedApiCall(api: string, method: string): string | undefined {
|
|
19
|
-
|
|
37
|
+
const name = api.trim().toLowerCase();
|
|
38
|
+
const m = method.trim();
|
|
39
|
+
if (name === 'gmail' && GMAIL_API_BLOCKED.test(m)) {
|
|
20
40
|
return `gmail ${method} is not available through gog_api_call: it sends or forwards mail. `
|
|
21
41
|
+ 'Use gog_gmail_send / gog_gmail_drafts_send (which ask the user to confirm) or the dedicated gog_gmail_* tool.';
|
|
22
42
|
}
|
|
23
|
-
|
|
43
|
+
const hit = (Object.hasOwn(DISPATCH_API_BLOCKED, name) ? DISPATCH_API_BLOCKED[name] : [])!.find((b) => b.method.test(m));
|
|
44
|
+
return hit ? gatedElsewhere(`${name} ${m}`, 'gog_api_call', hit.does, hit.tool) : undefined;
|
|
24
45
|
}
|
|
25
46
|
|
|
26
47
|
// Generic Google Discovery API access (gog 0.31). gog_api_list / gog_api_describe
|
|
@@ -57,7 +78,7 @@ export function registerApiTools(server: McpServer): void {
|
|
|
57
78
|
});
|
|
58
79
|
|
|
59
80
|
server.registerTool('gog_api_call', {
|
|
60
|
-
description: 'Call any Discovery-described Google API method directly — an escape hatch for endpoints gog has no dedicated tool for. Find the exact api/version/method/params with gog_api_describe first. Read methods (GET/LIST) run as-is. Mutating methods (POST/PUT/PATCH/DELETE) are refused unless you set allowWrite=true — keep it false to preview, or set dryRun=true to print the intended request without sending it. Gmail send and forwarding methods (users.messages.send, users.drafts.send, forwarding/auto-forwarding, filters, delegates) are refused — use the dedicated gog_gmail_* tools, which ask the user to confirm.',
|
|
81
|
+
description: 'Call any Discovery-described Google API method directly — an escape hatch for endpoints gog has no dedicated tool for. Find the exact api/version/method/params with gog_api_describe first. Read methods (GET/LIST) run as-is. Mutating methods (POST/PUT/PATCH/DELETE) are refused unless you set allowWrite=true — keep it false to preview, or set dryRun=true to print the intended request without sending it. Gmail send and forwarding methods (users.messages.send, users.drafts.send, forwarding/auto-forwarding, filters, delegates) are refused — use the dedicated gog_gmail_* tools, which ask the user to confirm. So are the other methods a dedicated tool asks about: Chat spaces.messages.create, Drive permissions.create/update, Classroom courses.announcements.create and invitations.create, and Calendar events.insert/import/quickAdd/update/patch.',
|
|
61
82
|
annotations: { destructiveHint: true },
|
|
62
83
|
inputSchema: z.object({
|
|
63
84
|
api: z.string().describe('Discovery API name (e.g. drive, gmail, calendar)'),
|