gogcli-mcp 4.5.0 → 4.5.2

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/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.0",
6
+ "version": "4.5.2",
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 opt-in + dry-run)."
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.0",
3
+ "version": "4.5.2",
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>",
@@ -42,11 +42,11 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "@chrischall/mcp-utils": "^2.6.0",
45
- "@modelcontextprotocol/server": "^2.0.0",
45
+ "@modelcontextprotocol/server": "^2.1.0",
46
46
  "zod": "^4.6.1"
47
47
  },
48
48
  "devDependencies": {
49
- "@types/node": "^26.6.1",
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.0",
10
+ "version": "4.5.2",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "4.5.0",
15
+ "version": "4.5.2",
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 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).
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 `${what} ${does} and is not available through ${via}. Use ${tool}, which asks the user to confirm.`;
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-domain list — so an
200
- // unexpected external send (outside counsel, a wrong-number alias) can be
201
- // caught after the fact even if the confirmation step above is somehow
202
- // bypassed by a future caller. stdout is the JSON-RPC channel, so this goes to
203
- // stderr like every other diagnostic in this repo.
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 externalRecipients = recipients.filter((recipient) => {
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
- return !domain || !domains.has(domain);
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: externalRecipients.length,
216
- hasExternalRecipients: externalRecipients.length > 0,
217
- externalRecipients,
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: [{ 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' }],
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 and a dry-run preview.
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 — 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.',
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}`);
@@ -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.' + apiEnableNote,
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
  }
@@ -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). The run tool cannot tell whether
11
- // an event has guests, so it refuses these outright; the dedicated tools ask
12
- // only when someone else would see the change.
13
- const CALENDAR_GATED: Record<string, string> = {
14
- create: 'gog_calendar_create', add: 'gog_calendar_create', new: 'gog_calendar_create',
15
- update: 'gog_calendar_update', edit: 'gog_calendar_update', set: 'gog_calendar_update',
16
- respond: 'gog_calendar_respond', rsvp: 'gog_calendar_respond', reply: 'gog_calendar_respond',
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, _args: readonly string[]): string | undefined {
40
+ export function vetCalendarRun(subcommand: string, args: readonly string[]): string | undefined {
21
41
  const sub = subcommand.toLowerCase();
22
- const tool = Object.hasOwn(CALENDAR_GATED, sub) ? CALENDAR_GATED[sub] : undefined;
23
- return tool ? gatedElsewhere(`gog calendar ${sub}`, 'gog_calendar_run', 'can change what guests see', tool) : undefined;
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 });