gogcli-mcp 4.5.0 → 4.5.1
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 +537 -117
- package/dist/lib.js +576 -117
- package/manifest.json +7 -7
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/dispatch-confirmation.ts +63 -3
- package/src/gmail-dispatch-guard.ts +19 -12
- package/src/lib.ts +6 -2
- package/src/tools/api.ts +74 -8
- package/src/tools/appscript.ts +63 -2
- package/src/tools/calendar.ts +83 -13
- package/src/tools/chat.ts +35 -4
- package/src/tools/classroom.ts +254 -25
- package/src/tools/docs.ts +7 -1
- package/src/tools/drive.ts +82 -10
- package/src/tools/gmail.ts +37 -1
- package/src/tools/sheets.ts +16 -1
- package/src/tools/utils.ts +10 -4
- package/tests/gmail-dispatch-guard.test.ts +24 -8
- package/tests/tools/api.test.ts +130 -5
- package/tests/tools/appscript.test.ts +6 -2
- package/tests/tools/dispatch-gates-more.test.ts +447 -0
- package/tests/tools/dispatch-gates.test.ts +52 -1
- package/tests/tools/docs.test.ts +5 -6
- package/tests/tools/drive.test.ts +2 -5
- package/tests/tools/run-vets-more.test.ts +269 -0
- package/tests/tools/run-vets.test.ts +158 -4
- package/tests/tools/sheets.test.ts +10 -11
- package/tests/tools/utils.test.ts +38 -41
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.5.
|
|
6
|
+
"version": "4.5.1",
|
|
7
7
|
"description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Hall",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
75
|
"name": "gog_api_call",
|
|
76
|
-
"description": "Call any Discovery-described Google API method (escape hatch; write
|
|
76
|
+
"description": "Call any Discovery-described Google API method (escape hatch; every write asks the user to confirm; dry-run available)."
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
79
|
"name": "gog_auth_add",
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
},
|
|
150
150
|
{
|
|
151
151
|
"name": "gog_calendar_delete",
|
|
152
|
-
"description": "Delete a calendar event"
|
|
152
|
+
"description": "Delete a calendar event (asks the user to confirm when it has guests or recurs)"
|
|
153
153
|
},
|
|
154
154
|
{
|
|
155
155
|
"name": "gog_calendar_respond",
|
|
@@ -209,7 +209,7 @@
|
|
|
209
209
|
},
|
|
210
210
|
{
|
|
211
211
|
"name": "gog_classroom_submissions_return",
|
|
212
|
-
"description": "Return a graded submission"
|
|
212
|
+
"description": "Return a graded submission (asks the user to confirm)"
|
|
213
213
|
},
|
|
214
214
|
{
|
|
215
215
|
"name": "gog_classroom_submissions_turn_in",
|
|
@@ -293,7 +293,7 @@
|
|
|
293
293
|
},
|
|
294
294
|
{
|
|
295
295
|
"name": "gog_drive_delete",
|
|
296
|
-
"description": "Move a file to trash"
|
|
296
|
+
"description": "Move a file to trash (a permanent delete asks the user to confirm)"
|
|
297
297
|
},
|
|
298
298
|
{
|
|
299
299
|
"name": "gog_drive_share",
|
|
@@ -449,7 +449,7 @@
|
|
|
449
449
|
},
|
|
450
450
|
{
|
|
451
451
|
"name": "gog_chat_spaces_create",
|
|
452
|
-
"description": "Create a named Chat space, optionally seeding its membership"
|
|
452
|
+
"description": "Create a named Chat space, optionally seeding its membership (with members, asks the user to confirm)"
|
|
453
453
|
},
|
|
454
454
|
{
|
|
455
455
|
"name": "gog_chat_threads_list",
|
|
@@ -517,7 +517,7 @@
|
|
|
517
517
|
},
|
|
518
518
|
{
|
|
519
519
|
"name": "gog_appscript_run_function",
|
|
520
|
-
"description": "Execute a function in a deployed Apps Script project"
|
|
520
|
+
"description": "Execute a function in a deployed Apps Script project (asks the user to confirm)"
|
|
521
521
|
},
|
|
522
522
|
{
|
|
523
523
|
"name": "gog_appscript_run",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp",
|
|
3
|
-
"version": "4.5.
|
|
3
|
+
"version": "4.5.1",
|
|
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>",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"zod": "^4.6.1"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@types/node": "^26.6.
|
|
49
|
+
"@types/node": "^26.6.2",
|
|
50
50
|
"@vitest/coverage-v8": "^5.0.1",
|
|
51
51
|
"esbuild": "^0.28.2",
|
|
52
52
|
"typescript": "^7.0.2",
|
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/gogcli-mcp"
|
|
9
9
|
},
|
|
10
|
-
"version": "4.5.
|
|
10
|
+
"version": "4.5.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "4.5.
|
|
15
|
+
"version": "4.5.1",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
|
@@ -134,11 +134,21 @@ export interface DispatchTokenFallback {
|
|
|
134
134
|
|
|
135
135
|
/**
|
|
136
136
|
* The refusal an escape hatch (`gog_<service>_run`, `gog_api_call`) gives for an
|
|
137
|
-
* action
|
|
138
|
-
*
|
|
137
|
+
* action it must not perform: the run tool cannot see the guests, the class or
|
|
138
|
+
* the folder tree a change reaches, so it cannot preview it. `instead` names
|
|
139
|
+
* the way through — the dedicated tool, or the user doing it in the product.
|
|
140
|
+
*/
|
|
141
|
+
export function refusedInRun(what: string, via: string, does: string, instead: string): string {
|
|
142
|
+
return `${what} ${does} and is not available through ${via}. ${instead}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* {@link refusedInRun} for an action a dedicated tool gates. Without it, the
|
|
147
|
+
* run tool is a way around the rail: the model forwards the same subcommand
|
|
148
|
+
* and nobody is asked (#400).
|
|
139
149
|
*/
|
|
140
150
|
export function gatedElsewhere(what: string, via: string, does: string, tool: string): string {
|
|
141
|
-
return
|
|
151
|
+
return refusedInRun(what, via, does, `Use ${tool}, which asks the user to confirm.`);
|
|
142
152
|
}
|
|
143
153
|
|
|
144
154
|
/** True when any forwarded token is one of `words` (kong lets flags precede the command word). */
|
|
@@ -146,6 +156,56 @@ export function hasCommandWord(args: readonly string[], words: ReadonlySet<strin
|
|
|
146
156
|
return args.find((a) => words.has(a.toLowerCase()));
|
|
147
157
|
}
|
|
148
158
|
|
|
159
|
+
/**
|
|
160
|
+
* The value of `--name=value` or `--name value` among forwarded args, `''` for
|
|
161
|
+
* a bare `--name`, undefined when the flag is absent. Kong accepts both
|
|
162
|
+
* spellings, so a vet that read only one of them would be a way around itself.
|
|
163
|
+
* Over-inclusive on a bare flag followed by another flag (it reads that flag
|
|
164
|
+
* as the value): a vet only ever asks whether the flag is present or names a
|
|
165
|
+
* specific value, and refusing is the safe direction of error.
|
|
166
|
+
*/
|
|
167
|
+
export function flagValue(args: readonly string[], name: string): string | undefined {
|
|
168
|
+
const flag = `--${name}`;
|
|
169
|
+
for (let i = 0; i < args.length; i++) {
|
|
170
|
+
const a = args[i]!;
|
|
171
|
+
if (a === flag) return args[i + 1] ?? '';
|
|
172
|
+
if (a.startsWith(`${flag}=`)) return a.slice(flag.length + 1);
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// gog's spellings of `comments create|reply` under drive and docs (`gog schema` 0.41.0).
|
|
178
|
+
const COMMENT_ADD_WORDS = new Set(['create', 'add', 'new']);
|
|
179
|
+
const COMMENT_REPLY_WORDS = new Set(['reply', 'respond']);
|
|
180
|
+
|
|
181
|
+
/** True when a forwarded flag list asks for `--<flag>` (bare, or =anything but false). */
|
|
182
|
+
export function hasTrueFlag(args: readonly string[], flag: string): boolean {
|
|
183
|
+
const name = `--${flag}`;
|
|
184
|
+
return args.some((a) => {
|
|
185
|
+
const lower = a.toLowerCase();
|
|
186
|
+
if (lower === name) return true;
|
|
187
|
+
return lower.startsWith(`${name}=`) && lower.slice(name.length + 1) !== 'false';
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* gog_{drive,docs}_run must not post the comments gog_*_comments_add / _reply
|
|
193
|
+
* would ask about: a comment notifies the file's owner and everyone it +mentions.
|
|
194
|
+
*/
|
|
195
|
+
export function vetCommentsRun(service: 'drive' | 'docs', subcommand: string, args: readonly string[]): string | undefined {
|
|
196
|
+
if (subcommand.toLowerCase() !== 'comments') return undefined;
|
|
197
|
+
const reply = hasCommandWord(args, COMMENT_REPLY_WORDS);
|
|
198
|
+
if (reply) {
|
|
199
|
+
return gatedElsewhere(`gog ${service} comments ${reply.toLowerCase()}`, `gog_${service}_run`,
|
|
200
|
+
'notifies the comment thread', `gog_${service}_comments_reply`);
|
|
201
|
+
}
|
|
202
|
+
const add = hasCommandWord(args, COMMENT_ADD_WORDS);
|
|
203
|
+
return add
|
|
204
|
+
? gatedElsewhere(`gog ${service} comments ${add.toLowerCase()}`, `gog_${service}_run`,
|
|
205
|
+
'notifies the file\'s owner and anyone it mentions', `gog_${service}_comments_add`)
|
|
206
|
+
: undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
149
209
|
export interface DispatchConfirmationOptions {
|
|
150
210
|
/** Stable id of the dispatch, echoed in every result (`gmail.send`, `drive.share`, …). */
|
|
151
211
|
action: string;
|
|
@@ -196,25 +196,32 @@ function trustedDomains(account: string | undefined): Set<string> {
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
// A distinguishable, greppable event for every mail dispatch — recipient
|
|
199
|
-
// count plus whichever recipients fall outside the trusted
|
|
200
|
-
// unexpected external send (outside counsel, a wrong-number alias) can
|
|
201
|
-
// caught after the fact even if the confirmation step above is somehow
|
|
202
|
-
// bypassed by a future caller.
|
|
203
|
-
//
|
|
199
|
+
// count plus the DOMAINS of whichever recipients fall outside the trusted list
|
|
200
|
+
// — so an unexpected external send (outside counsel, a wrong-number alias) can
|
|
201
|
+
// be caught after the fact even if the confirmation step above is somehow
|
|
202
|
+
// bypassed by a future caller. Never the addresses (PRIV-1, fleet-audit
|
|
203
|
+
// #1012): on mcp-host this line is host log data the user never sees or
|
|
204
|
+
// controls, and a third party's address is their identity where their domain
|
|
205
|
+
// is the audit signal. stdout is the JSON-RPC channel, so this goes to stderr
|
|
206
|
+
// like every other diagnostic in this repo.
|
|
204
207
|
export function logGmailDispatch(tool: string, recipients: string[], account?: string): void {
|
|
205
208
|
const domains = trustedDomains(account);
|
|
206
|
-
const
|
|
209
|
+
const externalDomains = new Set<string>();
|
|
210
|
+
let externalRecipientCount = 0;
|
|
211
|
+
for (const recipient of recipients) {
|
|
207
212
|
const at = recipient.indexOf('@');
|
|
208
|
-
const domain = at > -1 ? recipient.slice(at + 1) : '';
|
|
209
|
-
|
|
210
|
-
|
|
213
|
+
const domain = at > -1 ? recipient.slice(at + 1).toLowerCase() : '';
|
|
214
|
+
if (domain && domains.has(domain)) continue;
|
|
215
|
+
externalRecipientCount += 1;
|
|
216
|
+
externalDomains.add(domain || '(no domain)');
|
|
217
|
+
}
|
|
211
218
|
const event = {
|
|
212
219
|
event: 'gmail_dispatch',
|
|
213
220
|
tool,
|
|
214
221
|
recipientCount: recipients.length,
|
|
215
|
-
externalRecipientCount
|
|
216
|
-
hasExternalRecipients:
|
|
217
|
-
|
|
222
|
+
externalRecipientCount,
|
|
223
|
+
hasExternalRecipients: externalRecipientCount > 0,
|
|
224
|
+
externalDomains: [...externalDomains].sort(),
|
|
218
225
|
timestamp: new Date().toISOString(),
|
|
219
226
|
};
|
|
220
227
|
process.stderr.write(`${JSON.stringify(event)}\n`);
|
package/src/lib.ts
CHANGED
|
@@ -19,7 +19,7 @@ export {
|
|
|
19
19
|
// The reply/reply-all schema and flag builder live in the base package so the
|
|
20
20
|
// gmail sub-package's draft-side twins reuse ONE definition — registering the
|
|
21
21
|
// same tool name from both registrar lists would be a duplicate-name error.
|
|
22
|
-
export { replySchema, appendReplyFlags } from './tools/gmail.js';
|
|
22
|
+
export { replySchema, appendReplyFlags, parseMetadataHeaders } from './tools/gmail.js';
|
|
23
23
|
export type { ReplyFlags } from './tools/gmail.js';
|
|
24
24
|
// The gmail confirmation gate — gog_gmail_reply/send/forward/autoreply are
|
|
25
25
|
// the only tools that dispatch mail irreversibly on the first call. The
|
|
@@ -40,7 +40,11 @@ export {
|
|
|
40
40
|
} from './gmail-dispatch-guard.js';
|
|
41
41
|
// The service-neutral rail for every other tool that reaches another person.
|
|
42
42
|
export { requireDispatchConfirmation } from './dispatch-confirmation.js';
|
|
43
|
-
export { readCourse } from './tools/classroom.js';
|
|
43
|
+
export { readCourse, readCoursework, readClassroomWork, studentLabel } from './tools/classroom.js';
|
|
44
|
+
export type { ClassroomWork, ClassroomWorkKind } from './tools/classroom.js';
|
|
45
|
+
// Pure parsers a sub-package's confirmation prompt reuses on its own reads.
|
|
46
|
+
export { eventSnapshot } from './tools/calendar.js';
|
|
47
|
+
export { commentMentions, commentSnapshot, shareTargetMeta } from './tools/drive.js';
|
|
44
48
|
export type { AttachmentDetail, DispatchTokenFallback, TokenSubject } from './gmail-dispatch-guard.js';
|
|
45
49
|
export { run, runBinary, isGogFileArg, MIN_GOG_VERSION } from './runner.js';
|
|
46
50
|
// Sub-package tools that read gog JSON through bare `run()` (rather than the
|
package/src/tools/api.ts
CHANGED
|
@@ -6,7 +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
|
+
import { BODY_PREVIEW_MAX, bodyPreview, CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, gatedElsewhere, requireDispatchConfirmation } from '../dispatch-confirmation.js';
|
|
10
10
|
|
|
11
11
|
// Gmail methods gog_api_call refuses outright (audit SEC-2). `allowWrite` is a
|
|
12
12
|
// boolean the MODEL sets, so it cannot stand in for the user's confirmation of
|
|
@@ -20,17 +20,38 @@ const GMAIL_API_BLOCKED = /(?:\.send$|forwarding|filters\.create|filters\.update
|
|
|
20
20
|
// the dedicated tool asks the user, so the raw API method must not be a way
|
|
21
21
|
// around it. Anchored on a `.` or the start so a Discovery id with the API
|
|
22
22
|
// prefix (`chat.spaces.messages.create`) matches too.
|
|
23
|
+
// Fleet audit 2026-09-24 SEC-6 added the methods that notify someone, grant
|
|
24
|
+
// access or run code under the account's authority (moving an event with
|
|
25
|
+
// sendUpdates, roster adds, returning work, posting coursework, comments,
|
|
26
|
+
// send-as aliases, the vacation responder, member-seeded spaces, script runs).
|
|
23
27
|
const DISPATCH_API_BLOCKED: Record<string, Array<{ method: RegExp; does: string; tool: string }>> = {
|
|
24
|
-
chat: [
|
|
25
|
-
|
|
28
|
+
chat: [
|
|
29
|
+
{ method: /(?:^|\.)spaces\.messages\.create$/i, does: 'posts a Chat message', tool: 'gog_chat_messages_send / gog_chat_dm_send' },
|
|
30
|
+
{ method: /(?:^|\.)spaces\.(?:setup|members\.create)$/i, does: 'adds people to a space', tool: 'gog_chat_spaces_create' },
|
|
31
|
+
],
|
|
32
|
+
drive: [
|
|
33
|
+
{ method: /(?:^|\.)permissions\.(?:create|update)$/i, does: 'grants access to a file', tool: 'gog_drive_share' },
|
|
34
|
+
{ method: /(?:^|\.)comments\.create$/i, does: 'notifies the file\'s owner and anyone it mentions', tool: 'gog_drive_comments_add / gog_docs_comments_add' },
|
|
35
|
+
{ method: /(?:^|\.)replies\.create$/i, does: 'notifies the comment thread', tool: 'gog_drive_comments_reply / gog_docs_comments_reply' },
|
|
36
|
+
],
|
|
26
37
|
classroom: [
|
|
27
38
|
{ method: /(?:^|\.)courses\.announcements\.create$/i, does: 'posts to a class', tool: 'gog_classroom_announcements_create' },
|
|
28
39
|
{ method: /(?:^|\.)invitations\.create$/i, does: 'invites someone to a class', tool: 'gog_classroom_invitations_create' },
|
|
40
|
+
{ method: /(?:^|\.)courses\.students\.create$/i, does: 'adds someone to a class', tool: 'gog_classroom_students_add' },
|
|
41
|
+
{ method: /(?:^|\.)courses\.teachers\.create$/i, does: 'gives someone teacher access to a class', tool: 'gog_classroom_teachers_add' },
|
|
42
|
+
{ method: /(?:^|\.)courses\.coursework\.create$/i, does: 'posts coursework to a class', tool: 'gog_classroom_coursework_create' },
|
|
43
|
+
{ method: /(?:^|\.)studentsubmissions\.return$/i, does: 'returns work to a student', tool: 'gog_classroom_submissions_return' },
|
|
29
44
|
],
|
|
30
45
|
calendar: [
|
|
31
46
|
{ method: /(?:^|\.)events\.(?:insert|import|quickadd)$/i, does: 'can put an event on guests\' calendars', tool: 'gog_calendar_create' },
|
|
32
47
|
{ method: /(?:^|\.)events\.(?:update|patch)$/i, does: 'can change what guests see', tool: 'gog_calendar_update / gog_calendar_respond' },
|
|
48
|
+
{ method: /(?:^|\.)events\.move$/i, does: 'can email every guest', tool: 'gog_calendar_move' },
|
|
33
49
|
],
|
|
50
|
+
gmail: [
|
|
51
|
+
{ method: /(?:^|\.)settings\.sendas\.create$/i, does: 'makes Google email the address and adds a sending identity', tool: 'gog_gmail_sendas_create' },
|
|
52
|
+
{ method: /(?:^|\.)settings\.updatevacation$/i, does: 'can turn on an auto-reply to every sender', tool: 'gog_gmail_vacation_update' },
|
|
53
|
+
],
|
|
54
|
+
script: [{ method: /(?:^|\.)scripts\.run$/i, does: 'executes code with this account\'s authority', tool: 'gog_appscript_run_function' }],
|
|
34
55
|
};
|
|
35
56
|
|
|
36
57
|
export function refusedApiCall(api: string, method: string): string | undefined {
|
|
@@ -44,10 +65,25 @@ export function refusedApiCall(api: string, method: string): string | undefined
|
|
|
44
65
|
return hit ? gatedElsewhere(`${name} ${m}`, 'gog_api_call', hit.does, hit.tool) : undefined;
|
|
45
66
|
}
|
|
46
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A params/body string as the confirmation prompt shows it: parsed, when it is
|
|
70
|
+
* JSON, so the user reads a structure rather than an escaped string; otherwise
|
|
71
|
+
* (an `@file` reference, a typo) the string itself. Bounded like every other
|
|
72
|
+
* body on the rail — the token binds the FULL string regardless.
|
|
73
|
+
*/
|
|
74
|
+
export function previewJson(text: string): unknown {
|
|
75
|
+
if (text.length > BODY_PREVIEW_MAX) return bodyPreview(text);
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(text);
|
|
78
|
+
} catch {
|
|
79
|
+
return text;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
47
83
|
// Generic Google Discovery API access (gog 0.31). gog_api_list / gog_api_describe
|
|
48
84
|
// are read-only Discovery lookups; gog_api_call is a Discovery-backed escape
|
|
49
85
|
// hatch for any method gog has no dedicated subcommand for — guarded by an
|
|
50
|
-
// explicit write opt-in
|
|
86
|
+
// explicit write opt-in, a dry-run preview, and the dispatch rail.
|
|
51
87
|
export function registerApiTools(server: McpServer): void {
|
|
52
88
|
server.registerTool('gog_api_list', {
|
|
53
89
|
description: 'List the Google Discovery APIs available for gog_api_call / gog_api_describe (name + version + title).',
|
|
@@ -78,7 +114,7 @@ export function registerApiTools(server: McpServer): void {
|
|
|
78
114
|
});
|
|
79
115
|
|
|
80
116
|
server.registerTool('gog_api_call', {
|
|
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
|
|
117
|
+
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, and every write then asks the MCP host to show the user a confirmation prompt with the exact api/version/method/params/body before anything is sent; set dryRun=true to print the intended request without sending it (no prompt, no changes). Gmail send and forwarding methods (users.messages.send, users.drafts.send, forwarding/auto-forwarding, filters, delegates) are refused outright — use the dedicated gog_gmail_* tools, which ask the user to confirm. So are the other methods a dedicated tool asks about with a better preview: Chat spaces.messages.create, Drive permissions.create/update, Classroom courses.announcements.create and invitations.create, and Calendar events.insert/import/quickAdd/update/patch.' + CONFIRM_FALLBACK_DESCRIPTION,
|
|
82
118
|
annotations: { destructiveHint: true },
|
|
83
119
|
inputSchema: z.object({
|
|
84
120
|
api: z.string().describe('Discovery API name (e.g. drive, gmail, calendar)'),
|
|
@@ -87,11 +123,12 @@ export function registerApiTools(server: McpServer): void {
|
|
|
87
123
|
params: z.string().optional().describe('Query/path parameters as a JSON object string (e.g. {"fileId":"abc","fields":"name"})'),
|
|
88
124
|
body: z.string().optional().describe('Request body as a JSON string (for write methods)'),
|
|
89
125
|
scope: z.string().optional().describe('Override the OAuth scope used for the call'),
|
|
90
|
-
allowWrite: z.boolean().optional().describe('Required to invoke a mutating method (POST/PUT/PATCH/DELETE). Without it, gog refuses write methods. Leave unset for read-only calls.'),
|
|
91
|
-
dryRun: z.boolean().optional().describe('Print the intended request and exit without sending it (no changes made)'),
|
|
126
|
+
allowWrite: z.boolean().optional().describe('Required to invoke a mutating method (POST/PUT/PATCH/DELETE); the user is asked to confirm the exact request first. Without it, gog refuses write methods. Leave unset for read-only calls.'),
|
|
127
|
+
dryRun: z.boolean().optional().describe('Print the intended request and exit without sending it (no changes made, no confirmation prompt)'),
|
|
92
128
|
account: accountParam,
|
|
129
|
+
confirmToken: confirmTokenParam,
|
|
93
130
|
}),
|
|
94
|
-
}, async ({ api, version, method, params, body, scope, allowWrite, dryRun, account }) => {
|
|
131
|
+
}, async ({ api, version, method, params, body, scope, allowWrite, dryRun, account, confirmToken }, ctx) => {
|
|
95
132
|
try {
|
|
96
133
|
assertSafeForwardedArgs([api, version, method]);
|
|
97
134
|
const refusal = refusedApiCall(api, method);
|
|
@@ -103,6 +140,35 @@ export function registerApiTools(server: McpServer): void {
|
|
|
103
140
|
} catch (err) {
|
|
104
141
|
return errorResult(errorText(err));
|
|
105
142
|
}
|
|
143
|
+
// SEC-2 (fleet-audit #931): allowWrite is a boolean the MODEL sets, and
|
|
144
|
+
// the --force below skips gog's own confirmation, so a method blocklist
|
|
145
|
+
// could never be complete (events.move/delete with sendUpdates=all,
|
|
146
|
+
// acl.insert, settings.sendAs.create, updateVacation, batchDelete, ...).
|
|
147
|
+
// Every write that will actually be sent asks the user first, about the
|
|
148
|
+
// exact api/method/params/body, through the same rail as the dedicated
|
|
149
|
+
// tools. A dry run sends nothing and is not gated; a write without
|
|
150
|
+
// allowWrite is refused by gog itself.
|
|
151
|
+
if (allowWrite && !dryRun) {
|
|
152
|
+
const request = { api, version, method, params, body, scope };
|
|
153
|
+
const view: Record<string, unknown> = { api, version, method };
|
|
154
|
+
if (params !== undefined) view.params = previewJson(params);
|
|
155
|
+
if (body !== undefined) view.body = previewJson(body);
|
|
156
|
+
if (scope !== undefined) view.scope = scope;
|
|
157
|
+
const confirmation = await requireDispatchConfirmation(ctx, {
|
|
158
|
+
action: 'api.call',
|
|
159
|
+
message: `Review and confirm this raw Google API write (${api} ${version} ${method}):`,
|
|
160
|
+
confirmationLabel: 'Confirm that this API method should be invoked with exactly these parameters.',
|
|
161
|
+
details: view,
|
|
162
|
+
unsupportedNote: 'Set dryRun=true to see the request gog would send without sending it, or use the dedicated gog_* tool for this operation.',
|
|
163
|
+
fallback: {
|
|
164
|
+
tool: 'gog_api_call',
|
|
165
|
+
account,
|
|
166
|
+
confirmToken,
|
|
167
|
+
subject: () => ({ target: `${api}/${version}/${method}`, payload: request, preview: view }),
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
if (confirmation) return confirmation;
|
|
171
|
+
}
|
|
106
172
|
const args: GogArg[] = ['api', 'call', pos(api), pos(version), pos(method)];
|
|
107
173
|
if (params) args.push(`--params=${params}`);
|
|
108
174
|
if (body) args.push(`--body=${body}`);
|
package/src/tools/appscript.ts
CHANGED
|
@@ -10,6 +10,41 @@ import {
|
|
|
10
10
|
import { pos } from '../argv.js';
|
|
11
11
|
import { confinePath } from '../file-roots.js';
|
|
12
12
|
import type { GogArg } from '../runner.js';
|
|
13
|
+
import {
|
|
14
|
+
CONFIRM_FALLBACK_DESCRIPTION,
|
|
15
|
+
confirmTokenParam,
|
|
16
|
+
gatedElsewhere,
|
|
17
|
+
requireDispatchConfirmation,
|
|
18
|
+
resultText,
|
|
19
|
+
senderPreview,
|
|
20
|
+
} from '../dispatch-confirmation.js';
|
|
21
|
+
|
|
22
|
+
/** gog_appscript_run must not execute code that gog_appscript_run_function would ask about. */
|
|
23
|
+
export function vetAppScriptRun(subcommand: string, _args: readonly string[]): string | undefined {
|
|
24
|
+
return subcommand.toLowerCase() === 'run'
|
|
25
|
+
? gatedElsewhere('gog appscript run', 'gog_appscript_run', 'executes code with this account\'s authority', 'gog_appscript_run_function')
|
|
26
|
+
: undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The project a run executes, for its confirmation prompt: a title the user
|
|
31
|
+
* recognises beside the opaque id. `updateTime` is kept apart as the token's
|
|
32
|
+
* revision, so code saved between the two phases is DRAFT_CHANGED. Unreadable
|
|
33
|
+
* output names nothing rather than throwing.
|
|
34
|
+
*/
|
|
35
|
+
export function projectSnapshot(raw: string, scriptId: string): { scriptId: string; title?: string; updateTime?: string } {
|
|
36
|
+
let project: { title?: unknown; updateTime?: unknown } | undefined;
|
|
37
|
+
try {
|
|
38
|
+
project = (JSON.parse(raw) as { project?: typeof project } | null)?.project;
|
|
39
|
+
} catch {
|
|
40
|
+
project = undefined;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
scriptId,
|
|
44
|
+
...(typeof project?.title === 'string' ? { title: project.title } : {}),
|
|
45
|
+
...(typeof project?.updateTime === 'string' ? { updateTime: project.updateTime } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
13
48
|
|
|
14
49
|
// Google Apps Script (gog >= 0.38.0 for pull/deployments/versions).
|
|
15
50
|
//
|
|
@@ -140,7 +175,9 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
140
175
|
+ 'Requires the project to be deployed as an API executable and to share the OAuth client with the calling '
|
|
141
176
|
+ 'credentials, otherwise Google refuses regardless of scopes. devMode runs the latest saved code instead of the '
|
|
142
177
|
+ 'deployed version, and only works if the account owns the script. '
|
|
143
|
-
+ 'This is NOT the escape hatch — gog_appscript_run is that.
|
|
178
|
+
+ 'This is NOT the escape hatch — gog_appscript_run is that. Because the wrapper cannot tell what the code will '
|
|
179
|
+
+ 'do, it reads the project and asks the MCP host to show the user a confirmation prompt with the project, the '
|
|
180
|
+
+ 'function and its arguments first; nothing runs unless they accept.' + CONFIRM_FALLBACK_DESCRIPTION + apiEnableNote,
|
|
144
181
|
annotations: { destructiveHint: true },
|
|
145
182
|
inputSchema: z.object({
|
|
146
183
|
scriptId: scriptIdParam,
|
|
@@ -148,12 +185,14 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
148
185
|
params: z.string().optional().describe('Function parameters as a JSON ARRAY of positional arguments, e.g. \'["a", 1]\' — not an object'),
|
|
149
186
|
devMode: z.boolean().optional().describe('Run the latest saved code rather than the deployed version (owner only)'),
|
|
150
187
|
account: accountParam,
|
|
188
|
+
confirmToken: confirmTokenParam,
|
|
151
189
|
}),
|
|
152
|
-
}, async ({ scriptId, functionName, params, devMode, account }) => {
|
|
190
|
+
}, async ({ scriptId, functionName, params, devMode, account, confirmToken }, ctx) => {
|
|
153
191
|
// gog passes --params through to the API as-is, so a malformed value comes
|
|
154
192
|
// back as a Google error about the request body rather than about the
|
|
155
193
|
// argument the caller actually got wrong. Checking the shape here is what
|
|
156
194
|
// turns "invalid argument" into "params must be a JSON array".
|
|
195
|
+
let parsedParams: unknown[] | undefined;
|
|
157
196
|
if (params !== undefined) {
|
|
158
197
|
let parsed: unknown;
|
|
159
198
|
try {
|
|
@@ -164,7 +203,28 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
164
203
|
if (!Array.isArray(parsed)) {
|
|
165
204
|
throw new Error(`params must be a JSON ARRAY of positional arguments, e.g. '["a", 1]' — Apps Script takes positional arguments, not named ones. Received: ${params}`);
|
|
166
205
|
}
|
|
206
|
+
parsedParams = parsed;
|
|
167
207
|
}
|
|
208
|
+
// Read on every call: the prompt names the project, and on the token
|
|
209
|
+
// fallback's phase 2 this is the re-read the token is checked against.
|
|
210
|
+
const got = await runOrDiagnose(['appscript', 'get', pos(scriptId)], { account });
|
|
211
|
+
if (got.isError) return got;
|
|
212
|
+
const { updateTime, ...project } = projectSnapshot(resultText(got), scriptId);
|
|
213
|
+
const execution = { project, functionName, params: parsedParams, devMode: Boolean(devMode), runsAs: senderPreview(account) };
|
|
214
|
+
const confirmation = await requireDispatchConfirmation(ctx, {
|
|
215
|
+
action: 'appscript.run-function',
|
|
216
|
+
message: 'Review and confirm running this Apps Script function with the account\'s full authority:',
|
|
217
|
+
confirmationLabel: 'Confirm that this code should run now.',
|
|
218
|
+
details: execution,
|
|
219
|
+
unsupportedNote: 'Ask the user to run it themselves from the Apps Script editor.',
|
|
220
|
+
fallback: {
|
|
221
|
+
tool: 'gog_appscript_run_function',
|
|
222
|
+
account,
|
|
223
|
+
confirmToken,
|
|
224
|
+
subject: () => ({ target: `${scriptId}/${functionName}`, revision: updateTime, payload: execution, preview: execution }),
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
if (confirmation) return confirmation;
|
|
168
228
|
const args: GogArg[] = ['appscript', 'run', pos(scriptId), pos(functionName)];
|
|
169
229
|
if (params !== undefined) args.push(`--params=${params}`);
|
|
170
230
|
if (devMode) args.push('--dev-mode');
|
|
@@ -174,6 +234,7 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
174
234
|
registerRunTool(server, {
|
|
175
235
|
service: 'appscript',
|
|
176
236
|
examples: '"get", "content", "deployments"',
|
|
237
|
+
vet: vetAppScriptRun,
|
|
177
238
|
note: 'To execute a function, use gog_appscript_run_function — this tool is the generic escape hatch.',
|
|
178
239
|
});
|
|
179
240
|
}
|
package/src/tools/calendar.ts
CHANGED
|
@@ -5,22 +5,60 @@ import { accountParam, runOrDiagnose, registerRunTool, pageTokenParam, pageAlias
|
|
|
5
5
|
import { annotateTruncatedList } from '../pagination.js';
|
|
6
6
|
import { pos } from '../argv.js';
|
|
7
7
|
import type { GogArg } from '../runner.js';
|
|
8
|
-
import { CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, gatedElsewhere, requireDispatchConfirmation, resultText } from '../dispatch-confirmation.js';
|
|
8
|
+
import { CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, flagValue, gatedElsewhere, refusedInRun, requireDispatchConfirmation, resultText } from '../dispatch-confirmation.js';
|
|
9
9
|
|
|
10
|
-
// gog's spellings (internal/cmd/calendar.go
|
|
11
|
-
// an event has guests, so it refuses these
|
|
12
|
-
// only when someone else would see the
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
// gog's spellings (internal/cmd/calendar.go; aliases per `gog schema` 0.41.0).
|
|
11
|
+
// The run tool cannot tell whether an event has guests, so it refuses these
|
|
12
|
+
// outright; the dedicated tools ask only when someone else would see the
|
|
13
|
+
// change. Move, delete, delete-calendar and out-of-office joined in the fleet
|
|
14
|
+
// audit of 2026-09-24 (SEC-4 #933, SEC-6): --send-updates on a move emails
|
|
15
|
+
// every guest, out-of-office auto-declines conflicting invitations by default,
|
|
16
|
+
// and delete's --scope defaults to the whole recurring series.
|
|
17
|
+
const ASKS = (tool: string) => `Use ${tool}, which asks the user to confirm.`;
|
|
18
|
+
const CALENDAR_REFUSED: Record<string, { does: string; instead: string }> = {
|
|
19
|
+
create: { does: 'can change what guests see', instead: ASKS('gog_calendar_create') },
|
|
20
|
+
add: { does: 'can change what guests see', instead: ASKS('gog_calendar_create') },
|
|
21
|
+
new: { does: 'can change what guests see', instead: ASKS('gog_calendar_create') },
|
|
22
|
+
update: { does: 'can change what guests see', instead: ASKS('gog_calendar_update') },
|
|
23
|
+
edit: { does: 'can change what guests see', instead: ASKS('gog_calendar_update') },
|
|
24
|
+
set: { does: 'can change what guests see', instead: ASKS('gog_calendar_update') },
|
|
25
|
+
respond: { does: 'can change what guests see', instead: ASKS('gog_calendar_respond') },
|
|
26
|
+
rsvp: { does: 'can change what guests see', instead: ASKS('gog_calendar_respond') },
|
|
27
|
+
reply: { does: 'can change what guests see', instead: ASKS('gog_calendar_respond') },
|
|
28
|
+
move: { does: "moves an event off guests' calendars (and can email them)", instead: ASKS('gog_calendar_move') },
|
|
29
|
+
transfer: { does: "moves an event off guests' calendars (and can email them)", instead: ASKS('gog_calendar_move') },
|
|
30
|
+
delete: { does: "removes an event from every guest's calendar (the whole series by default)", instead: ASKS('gog_calendar_delete') },
|
|
31
|
+
del: { does: "removes an event from every guest's calendar (the whole series by default)", instead: ASKS('gog_calendar_delete') },
|
|
32
|
+
rm: { does: "removes an event from every guest's calendar (the whole series by default)", instead: ASKS('gog_calendar_delete') },
|
|
33
|
+
remove: { does: "removes an event from every guest's calendar (the whole series by default)", instead: ASKS('gog_calendar_delete') },
|
|
34
|
+
'delete-calendar': { does: 'deletes a calendar and every event on it', instead: ASKS('gog_calendar_delete_calendar') },
|
|
35
|
+
'out-of-office': { does: 'auto-declines invitations and notifies their organizers', instead: ASKS('gog_calendar_out_of_office') },
|
|
36
|
+
ooo: { does: 'auto-declines invitations and notifies their organizers', instead: ASKS('gog_calendar_out_of_office') },
|
|
17
37
|
};
|
|
18
38
|
|
|
19
39
|
/** gog_calendar_run must not make the changes gog_calendar_create/update/respond would ask about. */
|
|
20
|
-
export function vetCalendarRun(subcommand: string,
|
|
40
|
+
export function vetCalendarRun(subcommand: string, args: readonly string[]): string | undefined {
|
|
21
41
|
const sub = subcommand.toLowerCase();
|
|
22
|
-
const
|
|
23
|
-
|
|
42
|
+
const what = `gog calendar ${sub}`;
|
|
43
|
+
const via = 'gog_calendar_run';
|
|
44
|
+
if (Object.hasOwn(CALENDAR_REFUSED, sub)) {
|
|
45
|
+
const { does, instead } = CALENDAR_REFUSED[sub]!;
|
|
46
|
+
return refusedInRun(what, via, does, instead);
|
|
47
|
+
}
|
|
48
|
+
// `propose-time` alone generates a URL. With --decline (or --comment, which
|
|
49
|
+
// implies it) it declines the event and notifies the organizer — exactly
|
|
50
|
+
// what gog_calendar_respond asks about (SEC-4, fleet-audit #933).
|
|
51
|
+
if (sub === 'propose-time' && (flagValue(args, 'decline') !== undefined || flagValue(args, 'comment') !== undefined)) {
|
|
52
|
+
return gatedElsewhere(`${what} --decline`, via, 'declines the event and notifies the organizer', 'gog_calendar_respond');
|
|
53
|
+
}
|
|
54
|
+
// A Focus Time block auto-declines every conflicting invitation by default
|
|
55
|
+
// (gog's --auto-decline defaults to all) and there is no dedicated tool for
|
|
56
|
+
// it; one that declines nobody is a private block and passes.
|
|
57
|
+
if ((sub === 'focus-time' || sub === 'focus') && flagValue(args, 'auto-decline')?.toLowerCase() !== 'none') {
|
|
58
|
+
return refusedInRun(what, via, 'auto-declines invitations and notifies their organizers (gog defaults --auto-decline to all)',
|
|
59
|
+
'Pass --auto-decline=none, or ask the user to create it from Google Calendar.');
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
24
62
|
}
|
|
25
63
|
|
|
26
64
|
// Reminder params, shared by create and update (gog >= 0.38.0 for
|
|
@@ -70,11 +108,13 @@ export function eventSnapshot(raw: string): {
|
|
|
70
108
|
end?: string;
|
|
71
109
|
organizer?: string;
|
|
72
110
|
guests: string[];
|
|
111
|
+
recurring?: true;
|
|
73
112
|
etag?: string;
|
|
74
113
|
} {
|
|
75
114
|
let event: {
|
|
76
115
|
summary?: unknown; start?: EventTime; end?: EventTime; etag?: unknown;
|
|
77
116
|
organizer?: { email?: unknown }; attendees?: EventAttendee[];
|
|
117
|
+
recurrence?: unknown; recurringEventId?: unknown;
|
|
78
118
|
} | undefined;
|
|
79
119
|
try {
|
|
80
120
|
event = (JSON.parse(raw) as { event?: typeof event } | null)?.event;
|
|
@@ -91,6 +131,10 @@ export function eventSnapshot(raw: string): {
|
|
|
91
131
|
guests: (Array.isArray(event?.attendees) ? event.attendees : [])
|
|
92
132
|
.filter((a) => a.self !== true && a.resource !== true && typeof a.email === 'string')
|
|
93
133
|
.map((a) => a.email as string),
|
|
134
|
+
// A series, or one instance of one: gog's delete --scope defaults to all.
|
|
135
|
+
...((Array.isArray(event?.recurrence) && event.recurrence.length > 0) || typeof event?.recurringEventId === 'string'
|
|
136
|
+
? { recurring: true as const }
|
|
137
|
+
: {}),
|
|
94
138
|
etag: str(event?.etag),
|
|
95
139
|
};
|
|
96
140
|
}
|
|
@@ -357,14 +401,40 @@ export function registerCalendarTools(server: McpServer): void {
|
|
|
357
401
|
});
|
|
358
402
|
|
|
359
403
|
server.registerTool('gog_calendar_delete', {
|
|
360
|
-
description: 'Delete a calendar event.',
|
|
404
|
+
description: 'Delete a calendar event. It disappears from every guest\'s calendar too, and for a recurring event gog '
|
|
405
|
+
+ 'deletes the WHOLE series, so when the event has guests or recurs this reads it and asks the MCP host to show '
|
|
406
|
+
+ 'the user a confirmation prompt with the event as it stands; a guest-free, one-off event is deleted without asking.'
|
|
407
|
+
+ CONFIRM_FALLBACK_DESCRIPTION,
|
|
361
408
|
annotations: { destructiveHint: true },
|
|
362
409
|
inputSchema: z.object({
|
|
363
410
|
calendarId: z.string().describe('Calendar ID'),
|
|
364
411
|
eventId: z.string().describe('Event ID'),
|
|
365
412
|
account: accountParam,
|
|
413
|
+
confirmToken: confirmTokenParam,
|
|
366
414
|
}),
|
|
367
|
-
}, async ({ calendarId, eventId, account }) => {
|
|
415
|
+
}, async ({ calendarId, eventId, account, confirmToken }, ctx) => {
|
|
416
|
+
const read = await readEvent(calendarId, eventId, account);
|
|
417
|
+
if (read.error) return read.error;
|
|
418
|
+
const { etag, ...event } = read.event;
|
|
419
|
+
if (event.guests.length > 0 || event.recurring) {
|
|
420
|
+
const view = { calendarId, eventId, event, scope: event.recurring ? 'the whole recurring series' : 'this event' };
|
|
421
|
+
const confirmation = await requireDispatchConfirmation(ctx, {
|
|
422
|
+
action: 'calendar.delete',
|
|
423
|
+
message: event.recurring
|
|
424
|
+
? 'Review and confirm deleting this ENTIRE recurring series:'
|
|
425
|
+
: 'Review and confirm deleting this event from every guest\'s calendar:',
|
|
426
|
+
confirmationLabel: 'Confirm that this event should be deleted now.',
|
|
427
|
+
details: view,
|
|
428
|
+
unsupportedNote: 'Ask the user to delete it from Google Calendar.',
|
|
429
|
+
fallback: {
|
|
430
|
+
tool: 'gog_calendar_delete',
|
|
431
|
+
account,
|
|
432
|
+
confirmToken,
|
|
433
|
+
subject: () => ({ target: `${calendarId}/${eventId}`, revision: etag, payload: view, preview: view }),
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
if (confirmation) return confirmation;
|
|
437
|
+
}
|
|
368
438
|
// gog gates this delete behind a confirmation; the runner injects
|
|
369
439
|
// --no-input, so without --force it refuses at runtime.
|
|
370
440
|
return runOrDiagnose(['calendar', 'delete', pos(calendarId), pos(eventId), '--force'], { account });
|