gogcli-mcp 4.4.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/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.4.0",
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 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.4.0",
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>",
@@ -41,12 +41,12 @@
41
41
  "test:coverage": "vitest run --coverage"
42
42
  },
43
43
  "dependencies": {
44
- "@chrischall/mcp-utils": "^2.4.0",
44
+ "@chrischall/mcp-utils": "^2.6.0",
45
45
  "@modelcontextprotocol/server": "^2.0.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.4.0",
10
+ "version": "4.5.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "4.4.0",
15
+ "version": "4.5.1",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -1,17 +1,14 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import type { CallToolResult, InputRequiredResult, ServerContext } from '@modelcontextprotocol/server';
4
- import { callerAcceptsFormElicitation, readEnvVar, requireConfirmation, textResult } from '@chrischall/mcp-utils';
5
- import { z } from 'zod';
6
4
  import {
7
- confirmTokenTtlSeconds,
8
- hashSendPayload,
9
- issueConfirmToken,
10
- sendConfirmFallbackEnabled,
11
- verifyConfirmToken,
12
- type ConfirmBinding,
13
- type ConfirmTokenError,
14
- } from './send-confirm-token.js';
5
+ CONFIRM_TOKEN_INSTRUCTION,
6
+ confirmationFromEnv,
7
+ readEnvVar,
8
+ requireConfirmationWithFallback,
9
+ type ConfirmSubject,
10
+ } from '@chrischall/mcp-utils';
11
+ import { confirmSpentStore } from './send-confirm-token.js';
15
12
 
16
13
  // ============================================================================
17
14
  // THE DISPATCH RAIL, service-neutral: every tool that reaches another person
@@ -20,9 +17,11 @@ import {
20
17
  //
21
18
  // Elicitation is primary: the host shows the preview and the model never holds
22
19
  // the approval. On a client that declares no elicitation (claude.ai, measured),
23
- // the call is refused — unless the call site supplies a `DispatchTokenFallback`
24
- // and the server opted in with GOG_SEND_CONFIRM_FALLBACK=token, in which case
25
- // the two-phase preview + confirmToken flow (send-confirm-token.ts) runs instead.
20
+ // the call site's `DispatchTokenFallback` runs the two-phase preview +
21
+ // confirmToken flow instead (mcp-utils' confirmationFromEnv +
22
+ // requireConfirmationWithFallback), as the fleet's MCP_CONFIRM_MODE says:
23
+ // ask-user (default), auto, or refuse. A call site with no fallback (the
24
+ // forwarding filter) is always refused on such a client.
26
25
  // ============================================================================
27
26
 
28
27
  // Bound on the body text shown in a confirmation prompt. Enough to read what is
@@ -100,39 +99,23 @@ export const CONFIRM_SEND_INSTRUCTION =
100
99
  + 'Then call again with confirmToken.';
101
100
 
102
101
  /** The same instruction for a dispatch that is not mail (a share, an invitation, a post). */
103
- export const CONFIRM_ACTION_INSTRUCTION =
104
- 'Show this preview to the user verbatim and proceed only after they explicitly approve in chat. '
105
- + 'Then call again with confirmToken.';
106
-
107
- const FALLBACK_HINT = 'Or set GOG_SEND_CONFIRM_FALLBACK=token to enable two-step confirmation.';
102
+ export const CONFIRM_ACTION_INSTRUCTION = CONFIRM_TOKEN_INSTRUCTION;
108
103
 
109
104
  /** Appended to each gated tool's description. */
110
105
  export const CONFIRM_FALLBACK_DESCRIPTION =
111
- ' If the client cannot show that prompt (no MCP elicitation, e.g. claude.ai) and the server sets '
112
- + 'GOG_SEND_CONFIRM_FALLBACK=token, a two-step flow applies instead: call WITHOUT confirmToken and nothing is '
113
- + 'sent or changed — the result has status "confirmation-required", the full preview and a confirmToken. Show that preview '
114
- + 'to the user verbatim; only after they explicitly approve it in chat, call again with the SAME arguments plus '
115
- + 'confirmToken. The tool re-reads what it would act on and refuses (DRAFT_CHANGED, with a fresh preview and token) '
116
- + 'if it changed; TOKEN_EXPIRED / TOKEN_REUSED / TOKEN_INVALID also do nothing.';
106
+ ' If the client cannot show that prompt (no MCP elicitation, e.g. claude.ai), a two-step flow applies instead: call '
107
+ + 'WITHOUT confirmToken and nothing is sent or changed — the result has status "confirmation-required", the full '
108
+ + 'preview and a confirmToken. Follow its instruction (by default: show the preview to the user verbatim and only '
109
+ + 'after they explicitly approve it in chat, call again with the SAME arguments plus confirmToken). The tool re-reads '
110
+ + 'what it would act on and refuses (DRAFT_CHANGED, with a fresh preview and token) if it changed; TOKEN_EXPIRED / '
111
+ + 'TOKEN_REUSED / TOKEN_INVALID also do nothing. A server set to MCP_CONFIRM_MODE=refuse refuses instead.';
117
112
 
118
- export const confirmTokenParam = z.string().optional().describe(
119
- 'ONLY for the two-step fallback (client without MCP elicitation, server with GOG_SEND_CONFIRM_FALLBACK=token). '
120
- + 'The confirmToken from this same tool\'s phase-1 "confirmation-required" response, passed back ONLY after the user '
121
- + 'has seen that preview and explicitly approved it in chat — never on the first call, never invented, never '
122
- + 'reused. Call again with the same arguments. Ignored when the client supports elicitation.',
123
- );
113
+ // The schema input every gated tool adds: mcp-utils' own, so its wording and
114
+ // the helper that reads it cannot drift apart.
115
+ export { confirmTokenParam } from '@chrischall/mcp-utils';
124
116
 
125
117
  /** What the fallback binds a token to — recomputed from a fresh read on every call. */
126
- export interface TokenSubject {
127
- /** The draftId / messageId / fileId / eventId / query the dispatch acts on. */
128
- target: string;
129
- /** A version that rotates on edit: a draft's messageId, an event's etag. */
130
- revision?: string;
131
- /** Canonical send payload; its SHA-256 is bound into the token. */
132
- payload: unknown;
133
- /** The complete preview shown to the user. */
134
- preview: Record<string, unknown>;
135
- }
118
+ export type TokenSubject = ConfirmSubject;
136
119
 
137
120
  /**
138
121
  * Opt-in second rail for a client that cannot be prompted. `subject` is only
@@ -149,73 +132,23 @@ export interface DispatchTokenFallback {
149
132
  subject: () => TokenSubject | CallToolResult | Promise<TokenSubject | CallToolResult>;
150
133
  }
151
134
 
152
- const TOKEN_ERROR_NOTE: Record<Exclude<ConfirmTokenError, 'DRAFT_CHANGED'>, string> = {
153
- TOKEN_EXPIRED: 'Nothing was sent or changed: the confirmToken expired. Call again WITHOUT confirmToken for a fresh preview, '
154
- + 'and ask the user to approve it again.',
155
- TOKEN_REUSED: 'Nothing was sent or changed by this call: this confirmToken was already used, and one approval acts once. '
156
- + 'If doing it again is really intended, call again WITHOUT confirmToken and get a new approval.',
157
- TOKEN_INVALID: 'Nothing was sent or changed: this confirmToken was not issued by this server for this tool, account and '
158
- + 'target (or the server has restarted since). Call again WITHOUT confirmToken for a fresh preview and approval.',
159
- };
160
-
161
- const DRAFT_CHANGED_NOTE = {
162
- 'message-id-rotated': 'Nothing was sent or changed: the target was edited since the user approved it (a draft\'s '
163
- + 'messageId or an event\'s version rotated), so what would happen is not what they saw.',
164
- 'payload-changed': 'Nothing was sent or changed: what would happen no longer matches what the user approved.',
165
- } as const;
166
-
167
- function isToolResult(value: TokenSubject | CallToolResult): value is CallToolResult {
168
- return Array.isArray((value as CallToolResult).content);
169
- }
170
-
171
- function rejection(data: Record<string, unknown>): CallToolResult {
172
- return { ...textResult({ status: 'confirmation-rejected', confirmed: false, dispatched: false, ...data }), isError: true };
173
- }
174
-
175
- async function tokenConfirmation(op: string, fallback: DispatchTokenFallback): Promise<CallToolResult | undefined> {
176
- const subject = await fallback.subject();
177
- if (isToolResult(subject)) return subject;
178
- const binding: ConfirmBinding = {
179
- tool: fallback.tool,
180
- account: fallback.account ?? readEnvVar('GOG_ACCOUNT') ?? '',
181
- target: subject.target,
182
- ...(subject.revision === undefined ? {} : { revision: subject.revision }),
183
- payloadHash: hashSendPayload(subject.payload),
184
- };
185
- const phaseOne = () => {
186
- const { token, expiresAt } = issueConfirmToken(binding);
187
- return {
188
- action: op,
189
- preview: subject.preview,
190
- confirmToken: token,
191
- expiresAt,
192
- ttlSeconds: confirmTokenTtlSeconds(),
193
- instruction: fallback.instruction ?? CONFIRM_ACTION_INSTRUCTION,
194
- };
195
- };
196
- if (!fallback.confirmToken) {
197
- return textResult({ status: 'confirmation-required', confirmed: false, dispatched: false, ...phaseOne() });
198
- }
199
- const verdict = verifyConfirmToken(fallback.confirmToken, binding);
200
- if (verdict.ok) return undefined;
201
- if (verdict.error === 'DRAFT_CHANGED') {
202
- return rejection({
203
- error: 'DRAFT_CHANGED',
204
- reason: verdict.reason,
205
- note: `${DRAFT_CHANGED_NOTE[verdict.reason!]} The current preview and a fresh confirmToken are below.`,
206
- ...phaseOne(),
207
- });
208
- }
209
- return rejection({ error: verdict.error, action: op, note: TOKEN_ERROR_NOTE[verdict.error] });
135
+ /**
136
+ * The refusal an escape hatch (`gog_<service>_run`, `gog_api_call`) gives for an
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}`;
210
143
  }
211
144
 
212
145
  /**
213
- * The refusal an escape hatch (`gog_<service>_run`, `gog_api_call`) gives for an
214
- * action a dedicated tool gates. Without it, the run tool is a way around the
215
- * rail: the model forwards the same subcommand and nobody is asked (#400).
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).
216
149
  */
217
150
  export function gatedElsewhere(what: string, via: string, does: string, tool: string): string {
218
- return `${what} ${does} and is not available through ${via}. Use ${tool}, which asks the user to confirm.`;
151
+ return refusedInRun(what, via, does, `Use ${tool}, which asks the user to confirm.`);
219
152
  }
220
153
 
221
154
  /** True when any forwarded token is one of `words` (kong lets flags precede the command word). */
@@ -223,6 +156,56 @@ export function hasCommandWord(args: readonly string[], words: ReadonlySet<strin
223
156
  return args.find((a) => words.has(a.toLowerCase()));
224
157
  }
225
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
+
226
209
  export interface DispatchConfirmationOptions {
227
210
  /** Stable id of the dispatch, echoed in every result (`gmail.send`, `drive.share`, …). */
228
211
  action: string;
@@ -242,29 +225,33 @@ export interface DispatchConfirmationOptions {
242
225
  * Ask the user before a dispatch. `undefined` means proceed; anything else is
243
226
  * the result to return unchanged.
244
227
  *
245
- * Elicitation stays the primary path and is untouched. Only when the caller
246
- * declares it cannot be prompted AND a `fallback` is supplied AND
247
- * GOG_SEND_CONFIRM_FALLBACK=token does the two-phase token flow run instead of
248
- * the refusal; with the env unset, the refusal names that switch.
228
+ * Elicitation stays the primary path and is untouched. When the caller
229
+ * declares it cannot be prompted AND a `fallback` is supplied, MCP_CONFIRM_MODE
230
+ * decides: ask-user (default) or auto run the two-phase token flow; refuse
231
+ * refuses and names the switch. Without a fallback it is always refused.
249
232
  */
250
233
  export async function requireDispatchConfirmation(
251
234
  ctx: ServerContext,
252
235
  options: DispatchConfirmationOptions,
253
236
  ): Promise<InputRequiredResult | CallToolResult | undefined> {
254
237
  const { action, fallback } = options;
255
- if (fallback && callerAcceptsFormElicitation(ctx) === false && sendConfirmFallbackEnabled()) {
256
- return tokenConfirmation(action, fallback);
257
- }
258
- const note = fallback
259
- ? [options.unsupportedNote, FALLBACK_HINT].filter(Boolean).join(' ')
260
- : options.unsupportedNote;
261
- return requireConfirmation(ctx, {
238
+ const confirmation = {
262
239
  action,
263
240
  message: options.message,
264
241
  details: options.details,
265
242
  confirmationLabel: options.confirmationLabel,
266
- ...(note ? { unsupportedNote: note } : {}),
267
- });
243
+ ...(options.unsupportedNote ? { unsupportedNote: options.unsupportedNote } : {}),
244
+ };
245
+ if (!fallback) return requireConfirmationWithFallback(ctx, confirmation);
246
+ return requireConfirmationWithFallback(ctx, confirmationFromEnv({
247
+ ...confirmation,
248
+ tool: fallback.tool,
249
+ account: fallback.account ?? readEnvVar('GOG_ACCOUNT') ?? '',
250
+ confirmToken: fallback.confirmToken,
251
+ subject: fallback.subject,
252
+ instruction: fallback.instruction ?? CONFIRM_ACTION_INSTRUCTION,
253
+ spent: confirmSpentStore(),
254
+ }));
268
255
  }
269
256
 
270
257
  // The single place a CallToolResult's text is pulled back out, for the tools
@@ -32,11 +32,11 @@ export type { AttachmentDetail, DispatchTokenFallback, TokenSubject } from './di
32
32
  // user's accepted confirmation dispatches. The confirmation is never a tool
33
33
  // argument, so a model cannot bypass the user by setting a boolean itself.
34
34
  //
35
- // The one exception is OPT-IN: with GOG_SEND_CONFIRM_FALLBACK=token, a client
36
- // that cannot be prompted gets a two-phase preview + confirmToken instead of a
37
- // refusal (`DispatchTokenFallback`, send-confirm-token.ts). There the approval
38
- // IS a tool argument; see that file for exactly what the token does and does
39
- // not guarantee.
35
+ // The one exception: a client that cannot be prompted gets a two-phase preview
36
+ // + confirmToken instead of a refusal (`DispatchTokenFallback`; mcp-utils'
37
+ // confirmationFromEnv), unless the server sets MCP_CONFIRM_MODE=refuse. There
38
+ // the approval IS a tool argument; see mcp-utils' confirm-token module for
39
+ // exactly what the token does and does not guarantee.
40
40
  //
41
41
  // A CLIENT THAT CANNOT SHOW THAT PROMPT gets a sentence rather than a prompt
42
42
  // it will refuse to deliver (`unsupportedNote`, mcp-utils `requireConfirmation`).
@@ -123,10 +123,10 @@ const UNSUPPORTED_NOTE: Partial<Record<GmailDispatchOp, string>> = {
123
123
  /**
124
124
  * Apply the shared stateless confirmation flow with Gmail-specific copy.
125
125
  *
126
- * Elicitation stays the primary path and is untouched. Only when the caller
127
- * declares it cannot be prompted AND a `fallback` is supplied AND
128
- * GOG_SEND_CONFIRM_FALLBACK=token does the two-phase token flow run instead of
129
- * the refusal; with the env unset, the refusal names that switch.
126
+ * Elicitation stays the primary path and is untouched. When the caller
127
+ * declares it cannot be prompted AND a `fallback` is supplied, MCP_CONFIRM_MODE
128
+ * decides: ask-user (default) or auto run the two-phase token flow; refuse
129
+ * refuses and names the switch.
130
130
  */
131
131
  export async function requireGmailDispatchConfirmation(
132
132
  ctx: ServerContext,
@@ -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