gogcli-mcp 4.3.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 +784 -112
- package/dist/lib.js +757 -78
- package/manifest.json +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/dispatch-confirmation.ts +204 -0
- package/src/gmail-dispatch-guard.ts +36 -36
- package/src/lib.ts +9 -0
- package/src/send-confirm-token.ts +29 -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 +246 -14
- package/tests/send-confirm-token.test.ts +20 -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 +406 -0
- package/tests/tools/drive.test.ts +4 -1
- package/tests/tools/gmail-confirm-token.test.ts +276 -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.5.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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/gogcli-mcp",
|
|
5
5
|
"description": "MCP server wrapping gogcli for Google service access",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"test:coverage": "vitest run --coverage"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@chrischall/mcp-utils": "^2.
|
|
44
|
+
"@chrischall/mcp-utils": "^2.6.0",
|
|
45
45
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
46
46
|
"zod": "^4.6.1"
|
|
47
47
|
},
|
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.5.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "4.
|
|
15
|
+
"version": "4.5.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import type { CallToolResult, InputRequiredResult, ServerContext } from '@modelcontextprotocol/server';
|
|
4
|
+
import {
|
|
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';
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// THE DISPATCH RAIL, service-neutral: every tool that reaches another person
|
|
15
|
+
// (Gmail sends, Chat posts, guest-visible Calendar changes, Drive shares,
|
|
16
|
+
// Classroom announcements and invitations) asks the user first.
|
|
17
|
+
//
|
|
18
|
+
// Elicitation is primary: the host shows the preview and the model never holds
|
|
19
|
+
// the approval. On a client that declares no elicitation (claude.ai, measured),
|
|
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.
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
// Bound on the body text shown in a confirmation prompt. Enough to read what is
|
|
28
|
+
// actually being sent (the point of SEC-5), small enough to keep the prompt a
|
|
29
|
+
// prompt rather than a copy of the message.
|
|
30
|
+
export const BODY_PREVIEW_MAX = 2048;
|
|
31
|
+
|
|
32
|
+
/** The first BODY_PREVIEW_MAX characters of a body, marked when cut. */
|
|
33
|
+
export function bodyPreview(text: string | undefined): string | undefined {
|
|
34
|
+
if (!text) return undefined;
|
|
35
|
+
if (text.length <= BODY_PREVIEW_MAX) return text;
|
|
36
|
+
return `${text.slice(0, BODY_PREVIEW_MAX)}… [${text.length - BODY_PREVIEW_MAX} more characters not shown]`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every attachment a dispatch will carry: a server path in full (so the user
|
|
41
|
+
* sees WHICH file on the gog host is leaving), an inline one by its filename.
|
|
42
|
+
*/
|
|
43
|
+
export function attachmentNames(
|
|
44
|
+
paths: readonly string[] | undefined,
|
|
45
|
+
inline: ReadonlyArray<{ filename: string }> | undefined,
|
|
46
|
+
): string[] {
|
|
47
|
+
return [...(paths ?? []), ...(inline ?? []).map((a) => a.filename)];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One attachment as the fallback preview shows it and its hash binds it. */
|
|
51
|
+
export type AttachmentDetail = { name: string; size: number | null; sha256?: string };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Name, byte size and content SHA-256 of every file a dispatch carries, for the
|
|
55
|
+
* token fallback's preview and payload hash. A server path is read (it is
|
|
56
|
+
* already confined to GOG_FILE_ROOTS) so a same-size swap between the phases is
|
|
57
|
+
* still a changed payload; an unreadable one reports `size: null` and gog will
|
|
58
|
+
* fail on it anyway.
|
|
59
|
+
*/
|
|
60
|
+
export function attachmentDetails(
|
|
61
|
+
paths: readonly string[] | undefined,
|
|
62
|
+
inline: ReadonlyArray<{ filename: string; contentBase64: string }> | undefined,
|
|
63
|
+
): AttachmentDetail[] {
|
|
64
|
+
const out: AttachmentDetail[] = [];
|
|
65
|
+
for (const path of paths ?? []) {
|
|
66
|
+
let bytes: Buffer | undefined;
|
|
67
|
+
try {
|
|
68
|
+
bytes = readFileSync(path);
|
|
69
|
+
} catch {
|
|
70
|
+
bytes = undefined;
|
|
71
|
+
}
|
|
72
|
+
out.push(bytes
|
|
73
|
+
? { name: path, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }
|
|
74
|
+
: { name: path, size: null });
|
|
75
|
+
}
|
|
76
|
+
for (const a of inline ?? []) {
|
|
77
|
+
out.push({
|
|
78
|
+
name: a.filename,
|
|
79
|
+
size: Buffer.from(a.contentBase64, 'base64').length,
|
|
80
|
+
sha256: createHash('sha256').update(a.contentBase64).digest('hex'),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Who a dispatch goes out as, for the fallback preview: an explicit alias, else the account. */
|
|
87
|
+
export function senderPreview(account: string | undefined, from?: string): string {
|
|
88
|
+
return from ?? account ?? readEnvVar('GOG_ACCOUNT') ?? "the gog account's default address";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Drop the fingerprint for display: the user needs a name and a size. */
|
|
92
|
+
export function attachmentPreview(details: readonly AttachmentDetail[]): Array<{ name: string; size: number | null }> {
|
|
93
|
+
return details.map(({ name, size }) => ({ name, size }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The instruction a mail dispatch's phase 1 carries — verbatim from the spec that introduced the fallback. */
|
|
97
|
+
export const CONFIRM_SEND_INSTRUCTION =
|
|
98
|
+
'Show this preview to the user verbatim and send only after they explicitly approve in chat. '
|
|
99
|
+
+ 'Then call again with confirmToken.';
|
|
100
|
+
|
|
101
|
+
/** The same instruction for a dispatch that is not mail (a share, an invitation, a post). */
|
|
102
|
+
export const CONFIRM_ACTION_INSTRUCTION = CONFIRM_TOKEN_INSTRUCTION;
|
|
103
|
+
|
|
104
|
+
/** Appended to each gated tool's description. */
|
|
105
|
+
export const CONFIRM_FALLBACK_DESCRIPTION =
|
|
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.';
|
|
112
|
+
|
|
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';
|
|
116
|
+
|
|
117
|
+
/** What the fallback binds a token to — recomputed from a fresh read on every call. */
|
|
118
|
+
export type TokenSubject = ConfirmSubject;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Opt-in second rail for a client that cannot be prompted. `subject` is only
|
|
122
|
+
* called when the fallback actually runs, so a tool may do an extra read there
|
|
123
|
+
* without changing the elicitation path at all. It may return an error result
|
|
124
|
+
* (a failed read), which is passed back unchanged.
|
|
125
|
+
*/
|
|
126
|
+
export interface DispatchTokenFallback {
|
|
127
|
+
tool: string;
|
|
128
|
+
account?: string;
|
|
129
|
+
confirmToken?: string;
|
|
130
|
+
/** Phase 1's instruction to the model. Defaults to {@link CONFIRM_ACTION_INSTRUCTION}. */
|
|
131
|
+
instruction?: string;
|
|
132
|
+
subject: () => TokenSubject | CallToolResult | Promise<TokenSubject | CallToolResult>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The refusal an escape hatch (`gog_<service>_run`, `gog_api_call`) gives for an
|
|
137
|
+
* action a dedicated tool gates. Without it, the run tool is a way around the
|
|
138
|
+
* rail: the model forwards the same subcommand and nobody is asked (#400).
|
|
139
|
+
*/
|
|
140
|
+
export function gatedElsewhere(what: string, via: string, does: string, tool: string): string {
|
|
141
|
+
return `${what} ${does} and is not available through ${via}. Use ${tool}, which asks the user to confirm.`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** True when any forwarded token is one of `words` (kong lets flags precede the command word). */
|
|
145
|
+
export function hasCommandWord(args: readonly string[], words: ReadonlySet<string>): string | undefined {
|
|
146
|
+
return args.find((a) => words.has(a.toLowerCase()));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface DispatchConfirmationOptions {
|
|
150
|
+
/** Stable id of the dispatch, echoed in every result (`gmail.send`, `drive.share`, …). */
|
|
151
|
+
action: string;
|
|
152
|
+
/** Heading of the elicitation prompt. */
|
|
153
|
+
message: string;
|
|
154
|
+
/** Label beside the prompt's confirmation checkbox. */
|
|
155
|
+
confirmationLabel: string;
|
|
156
|
+
/** What the elicitation prompt shows. */
|
|
157
|
+
details: Record<string, unknown>;
|
|
158
|
+
/** The way through on a client that cannot be prompted, when there is one. */
|
|
159
|
+
unsupportedNote?: string;
|
|
160
|
+
/** Opt-in second rail; omit it and a client that cannot be prompted is always refused. */
|
|
161
|
+
fallback?: DispatchTokenFallback;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Ask the user before a dispatch. `undefined` means proceed; anything else is
|
|
166
|
+
* the result to return unchanged.
|
|
167
|
+
*
|
|
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.
|
|
172
|
+
*/
|
|
173
|
+
export async function requireDispatchConfirmation(
|
|
174
|
+
ctx: ServerContext,
|
|
175
|
+
options: DispatchConfirmationOptions,
|
|
176
|
+
): Promise<InputRequiredResult | CallToolResult | undefined> {
|
|
177
|
+
const { action, fallback } = options;
|
|
178
|
+
const confirmation = {
|
|
179
|
+
action,
|
|
180
|
+
message: options.message,
|
|
181
|
+
details: options.details,
|
|
182
|
+
confirmationLabel: options.confirmationLabel,
|
|
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
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The single place a CallToolResult's text is pulled back out, for the tools
|
|
198
|
+
// here that need to read gog's own JSON before deciding what to preview or
|
|
199
|
+
// log. Mirrors the shape every runOrDiagnose result actually returns
|
|
200
|
+
// (content[0].text); never throws on an unexpected shape.
|
|
201
|
+
export function resultText(result: CallToolResult): string {
|
|
202
|
+
const first = result.content[0];
|
|
203
|
+
return first && first.type === 'text' && typeof first.text === 'string' ? first.text : '{}';
|
|
204
|
+
}
|
|
@@ -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: 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
|
+
//
|
|
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. 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.
|
|
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,29 @@
|
|
|
1
|
+
import { createSpentTokenStore, type SpentTokenStore } from '@chrischall/mcp-utils';
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
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:
|
|
8
|
+
//
|
|
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
|
|
12
|
+
//
|
|
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.
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
const spent = createSpentTokenStore();
|
|
20
|
+
|
|
21
|
+
/** This server's spent-token store (one per process). */
|
|
22
|
+
export function confirmSpentStore(): SpentTokenStore {
|
|
23
|
+
return spent;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Test seam: forget every spent token. */
|
|
27
|
+
export function resetConfirmTokenState(): void {
|
|
28
|
+
spent.clear();
|
|
29
|
+
}
|
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)'),
|