gogcli-mcp 4.2.5 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +714 -216
  4. package/dist/lib.js +750 -216
  5. package/manifest.json +9 -2
  6. package/package.json +2 -2
  7. package/server.json +2 -2
  8. package/src/arg-guard.ts +68 -0
  9. package/src/argv.ts +27 -0
  10. package/src/attachment-root.ts +111 -0
  11. package/src/attachments.ts +1 -0
  12. package/src/blob-upload.ts +4 -2
  13. package/src/file-roots.ts +66 -0
  14. package/src/gmail-dispatch-guard.ts +56 -16
  15. package/src/gmail-results.ts +21 -2
  16. package/src/lib.ts +6 -0
  17. package/src/run-path-guard.ts +118 -0
  18. package/src/runner.ts +86 -18
  19. package/src/tools/api.ts +39 -7
  20. package/src/tools/appscript.ts +12 -8
  21. package/src/tools/auth.ts +13 -3
  22. package/src/tools/calendar.ts +10 -8
  23. package/src/tools/chat.ts +16 -13
  24. package/src/tools/classroom.ts +27 -25
  25. package/src/tools/contacts.ts +5 -3
  26. package/src/tools/docs.ts +8 -6
  27. package/src/tools/drive.ts +42 -18
  28. package/src/tools/gmail.ts +71 -11
  29. package/src/tools/sheets.ts +10 -8
  30. package/src/tools/slides.ts +14 -9
  31. package/src/tools/tasks.ts +7 -5
  32. package/src/tools/utils.ts +52 -6
  33. package/tests/arg-guard.test.ts +80 -0
  34. package/tests/attachment-root.test.ts +130 -0
  35. package/tests/attachments.test.ts +8 -0
  36. package/tests/blob-upload.test.ts +4 -3
  37. package/tests/file-roots.test.ts +101 -0
  38. package/tests/gmail-dispatch-guard.test.ts +46 -0
  39. package/tests/gmail-results.test.ts +35 -1
  40. package/tests/run-path-guard.test.ts +142 -0
  41. package/tests/runner.test.ts +136 -0
  42. package/tests/tools/api.test.ts +74 -8
  43. package/tests/tools/appscript.test.ts +34 -8
  44. package/tests/tools/auth.test.ts +44 -17
  45. package/tests/tools/calendar.test.ts +29 -27
  46. package/tests/tools/chat.test.ts +46 -20
  47. package/tests/tools/classroom.test.ts +39 -38
  48. package/tests/tools/contacts.test.ts +3 -2
  49. package/tests/tools/docs.test.ts +44 -15
  50. package/tests/tools/drive.test.ts +106 -19
  51. package/tests/tools/gmail.test.ts +227 -29
  52. package/tests/tools/run-tool-examples.test.ts +69 -0
  53. package/tests/tools/sheets.test.ts +16 -15
  54. package/tests/tools/slides.test.ts +47 -11
  55. package/tests/tools/tasks.test.ts +7 -6
  56. package/tests/tools/utils.test.ts +32 -31
  57. package/vitest.config.ts +5 -0
package/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifest_version": "0.3",
4
4
  "name": "gogcli-mcp",
5
5
  "display_name": "gogcli",
6
- "version": "4.2.5",
6
+ "version": "4.3.0",
7
7
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -37,7 +37,8 @@
37
37
  ],
38
38
  "env": {
39
39
  "GOG_ACCOUNT": "${user_config.gog_account}",
40
- "GOG_PATH": "${user_config.gog_path}"
40
+ "GOG_PATH": "${user_config.gog_path}",
41
+ "GOG_FILE_ROOTS": "${user_config.gog_file_roots}"
41
42
  }
42
43
  }
43
44
  },
@@ -53,6 +54,12 @@
53
54
  "title": "gog Executable Path",
54
55
  "description": "Path to the gog executable (optional — defaults to 'gog' on your PATH)",
55
56
  "required": false
57
+ },
58
+ "gog_file_roots": {
59
+ "type": "string",
60
+ "title": "Allowed file directories",
61
+ "description": "Directories tools may read files from (attachments, uploads) or write files to (exports, downloads), separated by ':' (';' on Windows). Optional — defaults to ~/gogcli-mcp-files",
62
+ "required": false
56
63
  }
57
64
  },
58
65
  "tools": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
- "version": "4.2.5",
3
+ "version": "4.3.0",
4
4
  "mcpName": "io.github.chrischall/gogcli-mcp",
5
5
  "description": "MCP server wrapping gogcli for Google service access",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -41,7 +41,7 @@
41
41
  "test:coverage": "vitest run --coverage"
42
42
  },
43
43
  "dependencies": {
44
- "@chrischall/mcp-utils": "^2.1.0",
44
+ "@chrischall/mcp-utils": "^2.4.0",
45
45
  "@modelcontextprotocol/server": "^2.0.0",
46
46
  "zod": "^4.6.1"
47
47
  },
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp"
9
9
  },
10
- "version": "4.2.5",
10
+ "version": "4.3.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "4.2.5",
15
+ "version": "4.3.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -0,0 +1,68 @@
1
+ // Guards for argv this wrapper does NOT build itself.
2
+ //
3
+ // The `gog_<service>_run` escape hatches forward a model-supplied string[]
4
+ // verbatim. gog (kong) takes the LAST value of a repeated flag, so an arg like
5
+ // `--readonly=false` placed after the runner's injected `--readonly` silently
6
+ // re-enables writes on a deployment the operator locked read-only — the one
7
+ // kill switch an operator can set (audit SEC-1). The same holds for every
8
+ // other global that is a control rather than an option: the command allow/deny
9
+ // lists, the credential source (`--access-token`, `--home`, `--client`), the
10
+ // account, `--gmail-no-send` and `--no-input`.
11
+ //
12
+ // A bare `--` is refused as well. It ends flag parsing, so anything the wrapper
13
+ // appends AFTER it (a safety flag appended last, for instance) would be read as
14
+ // a positional and quietly dropped.
15
+ //
16
+ // Kept out of runner.ts on purpose: tool tests automock the runner module, and
17
+ // these checks have to run for real inside the tool handlers.
18
+
19
+ // Long-flag names whose override the model must never control, matched as
20
+ // EXACT names: the flag alone (`--readonly`) or with an attached value
21
+ // (`--readonly=false`), case-insensitively. Not as bare prefixes — that refused
22
+ // legitimate command flags that merely share a leading word, such as
23
+ // gog_zoom_auth_setup's --account-id / --client-id / --client-secret. gog has
24
+ // no flag abbreviation, so a longer spelling is a different flag, not an
25
+ // override.
26
+ const FORBIDDEN_LONG_FLAG = /^--(readonly|enable-commands|enable-commands-exact|disable-commands|access-token|home|account|acct|client|gmail-no-send|no-input|non-interactive|noninteractive)(=|$)/i;
27
+
28
+ // `-a` is the short form of --account. kong accepts short-flag CLUSTERS
29
+ // (`-ja` = `-j -a`) and an attached value (`-aother@x`), so any single-dash
30
+ // token whose letters include `a` is refused. `-5` (a negative number) and
31
+ // `-y` (force) are unaffected.
32
+ const SHORT_ACCOUNT_CLUSTER = /^-(?!-)[A-Za-z]*a/;
33
+
34
+ /** Why `arg` may not be forwarded to gog, or undefined when it is fine. */
35
+ export function forbiddenArgReason(arg: string): string | undefined {
36
+ if (arg === '--') {
37
+ return 'The argument "--" is not allowed: it ends flag parsing and would disable safety flags appended after it.';
38
+ }
39
+ const match = FORBIDDEN_LONG_FLAG.exec(arg);
40
+ if (match) {
41
+ return `The flag ${arg} is not allowed here: --${match[1].toLowerCase()} is a safety/credential control set by the server operator, not a per-call option.`;
42
+ }
43
+ if (SHORT_ACCOUNT_CLUSTER.test(arg)) {
44
+ return `The flag ${arg} is not allowed here: -a selects the account. Use the tool's account parameter instead.`;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ /** Throw on the first model-supplied arg that would override a gog safety control. */
50
+ export function assertSafeForwardedArgs(args: readonly string[]): void {
51
+ for (const arg of args) {
52
+ const reason = forbiddenArgReason(arg);
53
+ if (reason) throw new Error(reason);
54
+ }
55
+ }
56
+
57
+ // A gog subcommand is a lower-case word, possibly hyphenated (`mark-read`).
58
+ // Anything else — a flag, an empty string, a path — is a smuggling attempt or a
59
+ // mistake, and gog would parse it as something other than a subcommand.
60
+ const SUBCOMMAND_SHAPE = /^[a-z][a-z0-9-]*$/;
61
+
62
+ export function assertSafeSubcommand(subcommand: string): void {
63
+ if (!SUBCOMMAND_SHAPE.test(subcommand)) {
64
+ throw new Error(
65
+ `Invalid subcommand ${JSON.stringify(subcommand)}: pass a single gog subcommand name such as "list" or "mark-read", and put its arguments in args.`,
66
+ );
67
+ }
68
+ }
package/src/argv.ts ADDED
@@ -0,0 +1,27 @@
1
+ // Positional-argument marking for gog argv (audit BUG-1).
2
+ //
3
+ // A tool's positional values — a Gmail query, a file ID, a range, a name — are
4
+ // chosen by the model. Pushed as bare argv elements, one that starts with '-'
5
+ // is parsed by gog (kong) as a flag: `gog gmail search "-in:spam"` fails with
6
+ // "unknown flag -i", and a value starting with `--` becomes a real gog flag.
7
+ // Wrapping each value in pos() tells the runner to place it after a single
8
+ // `--`, after every flag, which is how upstream gogcli's own MCP tools call it
9
+ // (internal/cmd/mcp_tools.go).
10
+ //
11
+ // Lives outside runner.ts on purpose: tool tests automock the runner module,
12
+ // and pos() must stay a real function there.
13
+
14
+ export interface GogPositional {
15
+ /** Discriminant separating this from a plain argv string and a GogFileArg. */
16
+ kind: 'positional';
17
+ value: string;
18
+ }
19
+
20
+ /** Mark a model-supplied positional value so it is passed after `--`. */
21
+ export function pos(value: string): GogPositional {
22
+ return { kind: 'positional', value };
23
+ }
24
+
25
+ export function isGogPositional(arg: unknown): arg is GogPositional {
26
+ return typeof arg === 'object' && arg !== null && (arg as { kind?: unknown }).kind === 'positional';
27
+ }
@@ -0,0 +1,111 @@
1
+ // The private, self-cleaning directory attachment downloads land in (audit SEC-6).
2
+ //
3
+ // It used to be a fixed `/tmp/gog-attachments` that nothing ever emptied, so
4
+ // every downloaded attachment — medical records, statements, custody documents
5
+ // — piled up in world-traversable /tmp for the life of the host. And because
6
+ // the name was predictable and shared, another local user could pre-create it
7
+ // or plant a symlink there, redefining where the blob-upload "confinement"
8
+ // root pointed.
9
+ //
10
+ // Now the root is per-user (named for the uid, under the OS temp dir), created
11
+ // owner-only (0700), and verified before every use: a symlink, a non-directory
12
+ // or a directory owned by someone else is refused rather than trusted. Files a
13
+ // caller never sees again (inline, Drive and URL deliveries) are deleted as
14
+ // soon as they are delivered; files returned BY PATH are kept for the caller to
15
+ // read and swept once they are older than ATTACHMENT_TTL_MS.
16
+
17
+ import { tmpdir, userInfo } from 'node:os';
18
+ import { dirname, join, relative, resolve, isAbsolute, sep } from 'node:path';
19
+
20
+ /** Where gmail attachment downloads are written, and the only tree the blob upload reads from. */
21
+ export const ATTACHMENT_DOWNLOAD_ROOT = join(tmpdir(), `gogcli-mcp-attachments-${userInfo().uid}`);
22
+
23
+ /** How long a download returned by path is kept before a later download sweeps it. */
24
+ export const ATTACHMENT_TTL_MS = 24 * 60 * 60 * 1000;
25
+
26
+ function isInside(root: string, target: string): boolean {
27
+ const rel = relative(root, target);
28
+ return rel !== '' && !rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel);
29
+ }
30
+
31
+ /**
32
+ * Create `dir` owner-only if missing, then verify it: a real directory (not a
33
+ * symlink), owned by this user, with no group/other permissions (tightened to
34
+ * 0700 when it is ours but looser). `opts.uid` is injected by tests.
35
+ */
36
+ export async function ensurePrivateDir(dir: string, opts: { uid?: number } = {}): Promise<void> {
37
+ const { mkdir, lstat, chmod } = await import('node:fs/promises');
38
+ try {
39
+ await mkdir(dir, { mode: 0o700 });
40
+ } catch (err) {
41
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
42
+ }
43
+ const info = await lstat(dir);
44
+ if (info.isSymbolicLink() || !info.isDirectory()) {
45
+ throw new Error(`refusing to use ${dir} for attachment downloads: it is not a real directory`);
46
+ }
47
+ const uid = opts.uid ?? userInfo().uid;
48
+ if (info.uid !== uid) {
49
+ throw new Error(`refusing to use ${dir} for attachment downloads: it is owned by another user`);
50
+ }
51
+ if ((info.mode & 0o077) !== 0) await chmod(dir, 0o700);
52
+ }
53
+
54
+ /**
55
+ * Delete top-level entries of `root` last modified more than `ttlMs` ago.
56
+ * Best-effort: a missing root sweeps nothing, and one entry failing never stops
57
+ * the rest. Returns how many entries were removed.
58
+ */
59
+ export async function sweepExpiredDownloads(
60
+ root: string = ATTACHMENT_DOWNLOAD_ROOT,
61
+ ttlMs: number = ATTACHMENT_TTL_MS,
62
+ now: number = Date.now(),
63
+ ): Promise<number> {
64
+ const { readdir, lstat, rm } = await import('node:fs/promises');
65
+ let entries: string[];
66
+ try {
67
+ entries = await readdir(root);
68
+ } catch {
69
+ return 0;
70
+ }
71
+ let removed = 0;
72
+ for (const name of entries) {
73
+ const path = join(root, name);
74
+ try {
75
+ const info = await lstat(path);
76
+ if (now - info.mtimeMs > ttlMs) {
77
+ await rm(path, { recursive: true, force: true });
78
+ removed += 1;
79
+ }
80
+ } catch {
81
+ // raced with another sweep or a delivery cleanup — nothing to do
82
+ }
83
+ }
84
+ return removed;
85
+ }
86
+
87
+ /**
88
+ * Delete one delivered download, then any directories it leaves empty, up to
89
+ * (never including) `root`. A path outside `root` is left alone: this only
90
+ * ever cleans up after the server's own downloads, never a caller's `out`.
91
+ */
92
+ export async function removeDownload(path: string, root: string = ATTACHMENT_DOWNLOAD_ROOT): Promise<void> {
93
+ const { rm, rmdir } = await import('node:fs/promises');
94
+ const base = resolve(root);
95
+ const target = resolve(base, path);
96
+ if (!isInside(base, target)) return;
97
+ await rm(target, { force: true });
98
+ for (let dir = dirname(target); isInside(base, dir); dir = dirname(dir)) {
99
+ try {
100
+ await rmdir(dir);
101
+ } catch {
102
+ break; // not empty (or already gone): stop climbing
103
+ }
104
+ }
105
+ }
106
+
107
+ /** Make the download root private and sweep expired downloads out of it. */
108
+ export async function prepareDownloadRoot(root: string = ATTACHMENT_DOWNLOAD_ROOT): Promise<void> {
109
+ await ensurePrivateDir(root);
110
+ await sweepExpiredDownloads(root);
111
+ }
@@ -80,6 +80,7 @@ export const MAX_INLINE_ATTACHMENT_TOTAL_BYTES = Math.floor((MAX_REQUEST_PAYLOAD
80
80
  */
81
81
  function wireBytesOf(arg: GogArg): number {
82
82
  if (typeof arg === 'string') return Buffer.byteLength(arg, 'utf8');
83
+ if (arg.kind === 'positional') return Buffer.byteLength(arg.value, 'utf8');
83
84
  return arg.encoding === 'base64' ? arg.contents.length : Buffer.byteLength(arg.contents, 'utf8');
84
85
  }
85
86
 
@@ -25,9 +25,11 @@
25
25
  /**
26
26
  * Where gmail's attachment download writes (`defaultOutPath` in
27
27
  * packages/gogcli-mcp-gmail/src/tools/gmail-extra.ts) and the only tree this
28
- * module will read back from.
28
+ * module will read back from. Per-user and private — see attachment-root.ts.
29
29
  */
30
- export const ATTACHMENT_DOWNLOAD_ROOT = '/tmp/gog-attachments';
30
+ import { ATTACHMENT_DOWNLOAD_ROOT } from './attachment-root.js';
31
+
32
+ export { ATTACHMENT_DOWNLOAD_ROOT };
31
33
 
32
34
  /** mcp-host's blob store caps one object at 100 MiB and answers 413 past it. */
33
35
  export const MAX_BLOB_UPLOAD_BYTES = 100 * 1024 * 1024;
@@ -0,0 +1,66 @@
1
+ // Operator-configured confinement for every server-side path a tool accepts
2
+ // (audit SEC-3/SEC-4).
3
+ //
4
+ // `attach`, `localPath`, `file`, `out`, `outDir` and friends are resolved on the
5
+ // machine gog runs on, and the model chooses them. Unconfined, each is a read
6
+ // primitive (attach ~/.ssh/id_rsa, gog's credentials, /proc/<pid>/environ to an
7
+ // email) or a write primitive (drop attacker-chosen attachment bytes on
8
+ // ~/Library/LaunchAgents/x.plist or ~/.zshrc). Every such path must now resolve
9
+ // — through symlinks, via mcp-utils' assertPathWithinRoots — inside one of the
10
+ // directories the operator lists in GOG_FILE_ROOTS, or the server's own private
11
+ // attachment download directory.
12
+ //
13
+ // GOG_FILE_ROOTS is a PATH-style list (':' on POSIX). Unset, it defaults to
14
+ // ~/gogcli-mcp-files: files to attach or upload go there, and exports and
15
+ // downloads with an explicit path must be written there. Set it to a wider
16
+ // directory (e.g. your home) deliberately, not by default.
17
+
18
+ import { homedir } from 'node:os';
19
+ import { delimiter, join } from 'node:path';
20
+ import { assertPathWithinRoots, readEnvVar } from '@chrischall/mcp-utils';
21
+ import { ATTACHMENT_DOWNLOAD_ROOT } from './attachment-root.js';
22
+
23
+ export const FILE_ROOTS_ENV = 'GOG_FILE_ROOTS';
24
+
25
+ /** The default root when GOG_FILE_ROOTS is unset. */
26
+ export function defaultFileRoot(): string {
27
+ return join(homedir(), 'gogcli-mcp-files');
28
+ }
29
+
30
+ /** The operator's configured roots, or the default one. */
31
+ export function fileRoots(): string[] {
32
+ const raw = readEnvVar(FILE_ROOTS_ENV);
33
+ const roots = (raw ?? '').split(delimiter).map((r) => r.trim()).filter(Boolean);
34
+ return roots.length > 0 ? roots : [defaultFileRoot()];
35
+ }
36
+
37
+ /**
38
+ * Require `path` (a model-supplied server path, named `param` in the error) to
39
+ * lie inside an allowed root. Returns it unchanged. `allowDash` admits gog's
40
+ * `-` for tools where it means stdout; anywhere else `-` means stdin, which this
41
+ * server never writes to, so it is refused like any other stray path.
42
+ */
43
+ export function confinePath(path: string, param: string, opts: { allowDash?: boolean } = {}): string {
44
+ if (opts.allowDash && path === '-') return path;
45
+ const roots = [...fileRoots(), ATTACHMENT_DOWNLOAD_ROOT];
46
+ try {
47
+ assertPathWithinRoots(path, roots);
48
+ } catch {
49
+ throw new Error(
50
+ `${param} ${JSON.stringify(path)} is outside the directories this server may read or write `
51
+ + `(${roots.join(', ')}). Put the file in one of them, or ask the server operator to widen ${FILE_ROOTS_ENV}.`,
52
+ );
53
+ }
54
+ return path;
55
+ }
56
+
57
+ /** confinePath for every element of an optional list. */
58
+ export function confinePaths(paths: readonly string[] | undefined, param: string): void {
59
+ for (const path of paths ?? []) confinePath(path, param);
60
+ }
61
+
62
+ /** For JSON-or-`@file` inputs: confine the path of an `@file` value, pass inline JSON through. */
63
+ export function confineAtFile(value: string, param: string): string {
64
+ if (value.startsWith('@')) confinePath(value.slice(1), param);
65
+ return value;
66
+ }
@@ -2,9 +2,9 @@ import type { CallToolResult, InputRequiredResult, ServerContext } from '@modelc
2
2
  import { readEnvVar, requireConfirmation } from '@chrischall/mcp-utils';
3
3
 
4
4
  // ============================================================================
5
- // THE SAFETY RAIL. gog_gmail_reply / reply_all / send / forward / autoreply are
6
- // the only tools in this fleet that put a message irreversibly into someone
7
- // else's mailbox on the FIRST call. Every other Gmail write either stages
5
+ // THE SAFETY RAIL. gog_gmail_reply / reply_all / send / forward / autoreply /
6
+ // drafts_send, and a filter that forwards, are the tools in this fleet that put
7
+ // a message irreversibly into someone else's mailbox. Every other Gmail write either stages
8
8
  // something (drafts) or acts on mail already in this account (labels,
9
9
  // archive, trash). A caller that meant "save a draft" and picked the wrong
10
10
  // tool — or an agent that inherited the wrong reply target — used to find out
@@ -26,7 +26,7 @@ import { readEnvVar, requireConfirmation } from '@chrischall/mcp-utils';
26
26
  // ============================================================================
27
27
 
28
28
  /**
29
- * The five dispatches this rail guards, spelled once.
29
+ * The dispatches this rail guards, spelled once.
30
30
  *
31
31
  * A UNION rather than `string`, because the staging-twin table below is keyed
32
32
  * by these values and a key that matches no call site is silent: it costs the
@@ -41,7 +41,14 @@ export type GmailDispatchOp =
41
41
  | 'gmail.reply'
42
42
  | 'gmail.reply-all'
43
43
  | 'gmail.forward'
44
- | 'gmail.autoreply';
44
+ | 'gmail.autoreply'
45
+ // Sending a staged draft dispatches mail just as irreversibly as a direct
46
+ // send; draft-create then drafts-send was an unconfirmed two-step around the
47
+ // rail (audit SEC-2).
48
+ | 'gmail.drafts-send'
49
+ // A filter with a forward action sends every FUTURE matching message to
50
+ // another address — persistent exfiltration, not a one-off send.
51
+ | 'gmail.filter-forward';
45
52
 
46
53
  /** Every op, for tests that must cover the set rather than a chosen member. */
47
54
  export const GMAIL_DISPATCH_OPS: readonly GmailDispatchOp[] = [
@@ -50,6 +57,8 @@ export const GMAIL_DISPATCH_OPS: readonly GmailDispatchOp[] = [
50
57
  'gmail.reply-all',
51
58
  'gmail.forward',
52
59
  'gmail.autoreply',
60
+ 'gmail.drafts-send',
61
+ 'gmail.filter-forward',
53
62
  ];
54
63
 
55
64
  /**
@@ -66,9 +75,9 @@ export function replyDispatchOp(kind: 'reply' | 'reply-all'): GmailDispatchOp {
66
75
 
67
76
  /**
68
77
  * The staging twin of each dispatch, named in the refusal above. Every one of
69
- * these saves without sending, and `gog_gmail_drafts_send` then dispatches it —
70
- * which is the rail's own sanctioned two-step (staging is visible and
71
- * inspectable, so the send is never the FIRST call), not a way around it.
78
+ * these saves without sending. `gog_gmail_drafts_send` asks for confirmation
79
+ * too, so on a client that cannot show a prompt the way through is the USER
80
+ * sending the saved draft from Gmail — a human in the loop either way.
72
81
  *
73
82
  * `Partial<Record<…>>` and not an index signature: a key outside the union is
74
83
  * now rejected by the compiler, which is the whole point, while `autoreply`
@@ -83,6 +92,34 @@ const STAGING_TWIN: Partial<Record<GmailDispatchOp, string>> = {
83
92
  'gmail.send': 'gog_gmail_drafts_create',
84
93
  };
85
94
 
95
+ // What to say when there is no staging twin but there IS still a way through.
96
+ const UNSUPPORTED_NOTE: Partial<Record<GmailDispatchOp, string>> = {
97
+ 'gmail.drafts-send': 'The draft is still saved: ask the user to review it and send it from Gmail.',
98
+ };
99
+
100
+ // Bound on the body text shown in a confirmation prompt. Enough to read what is
101
+ // actually being sent (the point of SEC-5), small enough to keep the prompt a
102
+ // prompt rather than a copy of the message.
103
+ export const BODY_PREVIEW_MAX = 2048;
104
+
105
+ /** The first BODY_PREVIEW_MAX characters of a body, marked when cut. */
106
+ export function bodyPreview(text: string | undefined): string | undefined {
107
+ if (!text) return undefined;
108
+ if (text.length <= BODY_PREVIEW_MAX) return text;
109
+ return `${text.slice(0, BODY_PREVIEW_MAX)}… [${text.length - BODY_PREVIEW_MAX} more characters not shown]`;
110
+ }
111
+
112
+ /**
113
+ * Every attachment a dispatch will carry: a server path in full (so the user
114
+ * sees WHICH file on the gog host is leaving), an inline one by its filename.
115
+ */
116
+ export function attachmentNames(
117
+ paths: readonly string[] | undefined,
118
+ inline: ReadonlyArray<{ filename: string }> | undefined,
119
+ ): string[] {
120
+ return [...(paths ?? []), ...(inline ?? []).map((a) => a.filename)];
121
+ }
122
+
86
123
  /** Apply the shared stateless confirmation flow with Gmail-specific copy. */
87
124
  export function requireGmailDispatchConfirmation(
88
125
  ctx: ServerContext,
@@ -90,17 +127,20 @@ export function requireGmailDispatchConfirmation(
90
127
  details: Record<string, unknown>,
91
128
  ): InputRequiredResult | CallToolResult | undefined {
92
129
  const twin = STAGING_TWIN[op];
130
+ const note = twin
131
+ ? `Stage it with ${twin} instead; the user can review the draft and send it from Gmail `
132
+ + '(gog_gmail_drafts_send also asks for confirmation).'
133
+ : UNSUPPORTED_NOTE[op];
93
134
  return requireConfirmation(ctx, {
94
135
  action: op,
95
- message: 'Review and confirm this email dispatch:',
136
+ message: op === 'gmail.filter-forward'
137
+ ? 'Review and confirm this mail-forwarding filter:'
138
+ : 'Review and confirm this email dispatch:',
96
139
  details,
97
- confirmationLabel: 'Confirm that this email should be sent now.',
98
- ...(twin
99
- ? {
100
- unsupportedNote: `Stage it with ${twin} instead, review the draft, `
101
- + 'and send it with gog_gmail_drafts_send.',
102
- }
103
- : {}),
140
+ confirmationLabel: op === 'gmail.filter-forward'
141
+ ? 'Confirm that matching mail should be forwarded automatically from now on.'
142
+ : 'Confirm that this email should be sent now.',
143
+ ...(note ? { unsupportedNote: note } : {}),
104
144
  });
105
145
  }
106
146
 
@@ -3,6 +3,7 @@ import { rawTextResult } from '@chrischall/mcp-utils';
3
3
  import { run } from './runner.js';
4
4
  import { annotateTruncation, hasMorePages } from './pagination.js';
5
5
  import type { MatchCount } from './pagination.js';
6
+ import { pos } from './argv.js';
6
7
 
7
8
  // Post-processing for Gmail search output, on the seam between gog's JSON and
8
9
  // the model client. Two guarantees live here, both of which exist because a
@@ -102,7 +103,7 @@ async function countMatches(
102
103
  maxResults: COUNT_PROBE_PAGE_SIZE,
103
104
  fields: `${itemsKey}/id,nextPageToken`,
104
105
  });
105
- const raw = await run(['api', 'call', 'gmail', 'v1', method, `--params=${params}`], { account });
106
+ const raw = await run(['api', 'call', 'gmail', 'v1', pos(method), `--params=${params}`], { account });
106
107
  const parsed = JSON.parse(raw) as Record<string, unknown>;
107
108
  const items = parsed[itemsKey];
108
109
  if (!Array.isArray(items)) return {};
@@ -208,8 +209,13 @@ export async function fetchGmailPages(
208
209
  // Nothing collected yet means the caller should just see that result; once
209
210
  // pages ARE collected, return them WITH the cursor that was about to be
210
211
  // consumed, so the set still reads as truncated rather than complete.
212
+ // The failing page's own text rides along as `pageError` (audit QUAL-1):
213
+ // without it the caller sees only "more exist", retries the same failing
214
+ // page, and never learns WHY — e.g. that the account needs re-auth.
211
215
  if (parsed === undefined) {
212
- return base === undefined ? result : finish(base, itemsKey, merged, token);
216
+ return base === undefined
217
+ ? result
218
+ : finish(base, itemsKey, merged, token, pageErrorText(result, itemsKey, pages + 1));
213
219
  }
214
220
  base = parsed;
215
221
  merged.push(...(parsed[itemsKey] as unknown[]));
@@ -253,14 +259,27 @@ function parsePage(
253
259
  return Array.isArray(obj[itemsKey]) ? obj : undefined;
254
260
  }
255
261
 
262
+ // What went wrong on page `n` of a walk, bounded so a stray HTML page cannot
263
+ // become the payload. An error result is already diagnosed text (hints and
264
+ // all); anything else is output this walk could not read as a list.
265
+ const PAGE_ERROR_MAX = 2000;
266
+ function pageErrorText(result: CallToolResult, itemsKey: string, n: number): string {
267
+ const first = result.content[0];
268
+ const text = first?.type === 'text' ? first.text.slice(0, PAGE_ERROR_MAX) : '';
269
+ if (result.isError) return `page ${n} failed${text ? `: ${text}` : ''}`;
270
+ return `page ${n} returned output that is not a ${itemsKey} list`;
271
+ }
272
+
256
273
  function finish(
257
274
  base: Record<string, unknown>,
258
275
  itemsKey: string,
259
276
  merged: unknown[],
260
277
  token: string | undefined,
278
+ pageError?: string,
261
279
  ): CallToolResult {
262
280
  const out: Record<string, unknown> = { ...base, [itemsKey]: merged };
263
281
  if (token === undefined) delete out.nextPageToken;
264
282
  else out.nextPageToken = token;
283
+ if (pageError !== undefined) out.pageError = pageError;
265
284
  return rawTextResult(JSON.stringify(out));
266
285
  }
package/src/lib.ts CHANGED
@@ -26,6 +26,8 @@ export type { ReplyFlags } from './tools/gmail.js';
26
26
  // gmail sub-package's send-side forward/autoreply tools reuse these directly
27
27
  // rather than re-declaring the gate; the draft-side twins never import them.
28
28
  export {
29
+ attachmentNames,
30
+ bodyPreview,
29
31
  extractEmails,
30
32
  logGmailDispatch,
31
33
  requireGmailDispatchConfirmation,
@@ -45,6 +47,10 @@ export type { FinalizeOptions, GmailListMethod } from './gmail-results.js';
45
47
  export { bootstrapGogAuth, AUTH_BOOTSTRAP_MARKER } from './bootstrap-auth.js';
46
48
  export type { AuthBootstrapStatus, AuthBootstrapOptions } from './bootstrap-auth.js';
47
49
  export type { RunOptions, Spawner, GogArg, GogFileArg } from './runner.js';
50
+ export { pos } from './argv.js';
51
+ export { confinePath, confinePaths, confineAtFile, fileRoots, defaultFileRoot, FILE_ROOTS_ENV } from './file-roots.js';
52
+ export { prepareDownloadRoot, removeDownload, ATTACHMENT_TTL_MS } from './attachment-root.js';
53
+ export type { GogPositional } from './argv.js';
48
54
  // Caller-supplied attachment bytes — the only outbound attachment path that
49
55
  // works when the caller and gog share no filesystem (a hosted deployment such
50
56
  // as mcp-host). See src/attachments.ts.